session_context.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821
  1. #!/usr/bin/env python3
  2. """
  3. Session context generation (default + record modes).
  4. Provides:
  5. get_context_json - JSON output for default mode
  6. get_context_text - Text output for default mode
  7. get_context_record_json - JSON for record mode
  8. get_context_text_record - Text for record mode
  9. output_json - Print JSON
  10. output_text - Print text
  11. """
  12. from __future__ import annotations
  13. import json
  14. import os
  15. import re
  16. import subprocess
  17. from pathlib import Path
  18. from .active_task import resolve_context_key
  19. from .config import get_git_packages
  20. from .git import run_git
  21. from .packages_context import get_packages_section
  22. from .tasks import iter_active_tasks, load_task, get_all_statuses, children_progress
  23. from .paths import (
  24. DIR_SCRIPTS,
  25. DIR_SPEC,
  26. DIR_TASKS,
  27. DIR_WORKFLOW,
  28. DIR_WORKSPACE,
  29. count_lines,
  30. get_active_journal_file,
  31. get_current_task,
  32. get_current_task_source,
  33. get_developer,
  34. get_repo_root,
  35. get_tasks_dir,
  36. )
  37. # =============================================================================
  38. # Helpers
  39. # =============================================================================
  40. _PACKAGE_NAME = "@mindfoldhq/trellis"
  41. _UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
  42. _VERSION_RE = re.compile(
  43. r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
  44. )
  45. _VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
  46. _POLYREPO_IGNORED_DIRS = {
  47. "node_modules",
  48. "target",
  49. "dist",
  50. "build",
  51. "out",
  52. "bin",
  53. "obj",
  54. "vendor",
  55. "coverage",
  56. "tmp",
  57. "__pycache__",
  58. }
  59. _POLYREPO_SCAN_MAX_DEPTH = 2
  60. def _is_git_worktree(path: Path) -> bool:
  61. """Return True when path is inside a Git worktree."""
  62. rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
  63. return rc == 0 and out.strip().lower() == "true"
  64. def _parse_recent_commits(log_output: str) -> list[dict]:
  65. """Parse `git log --oneline` output into structured commit entries."""
  66. commits = []
  67. for line in log_output.splitlines():
  68. if not line.strip():
  69. continue
  70. parts = line.split(" ", 1)
  71. if len(parts) >= 2:
  72. commits.append({"hash": parts[0], "message": parts[1]})
  73. elif len(parts) == 1:
  74. commits.append({"hash": parts[0], "message": ""})
  75. return commits
  76. def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | None:
  77. """Collect Git status for one known repository directory."""
  78. if not (repo_dir / ".git").exists():
  79. return None
  80. _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
  81. branch = branch_out.strip() or "unknown"
  82. _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
  83. changes = len([l for l in status_out.splitlines() if l.strip()])
  84. _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
  85. return {
  86. "name": name,
  87. "path": rel_path,
  88. "branch": branch,
  89. "isClean": changes == 0,
  90. "uncommittedChanges": changes,
  91. "recentCommits": _parse_recent_commits(log_out),
  92. }
  93. def _collect_root_git_info(repo_root: Path) -> dict:
  94. """Collect root Git info without pretending a non-Git root is clean."""
  95. if not _is_git_worktree(repo_root):
  96. return {
  97. "isRepo": False,
  98. "branch": "",
  99. "isClean": False,
  100. "uncommittedChanges": 0,
  101. "recentCommits": [],
  102. }
  103. _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
  104. branch = branch_out.strip() or "unknown"
  105. _, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
  106. status_lines = [line for line in status_out.splitlines() if line.strip()]
  107. _, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
  108. _, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
  109. return {
  110. "isRepo": True,
  111. "branch": branch,
  112. "isClean": len(status_lines) == 0,
  113. "uncommittedChanges": len(status_lines),
  114. "statusShort": short_out.splitlines(),
  115. "recentCommits": _parse_recent_commits(log_out),
  116. }
  117. def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
  118. """Discover child Git repositories using the init-time polyrepo heuristic."""
  119. found: list[str] = []
  120. def is_candidate_dir(path: Path) -> bool:
  121. name = path.name
  122. return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
  123. def scan(rel_dir: Path, depth: int) -> None:
  124. if depth >= _POLYREPO_SCAN_MAX_DEPTH:
  125. return
  126. abs_dir = repo_root / rel_dir
  127. try:
  128. children = sorted(abs_dir.iterdir(), key=lambda p: p.name)
  129. except OSError:
  130. return
  131. for child in children:
  132. if not child.is_dir() or not is_candidate_dir(child):
  133. continue
  134. child_rel = (
  135. rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
  136. )
  137. if (child / ".git").exists():
  138. found.append(child_rel.as_posix())
  139. continue
  140. scan(child_rel, depth + 1)
  141. scan(Path("."), 0)
  142. if len(found) < 2:
  143. return []
  144. return [(path.replace("/", "_"), path) for path in sorted(found)]
  145. def _collect_package_git_info(
  146. repo_root: Path,
  147. discover_unconfigured: bool = False,
  148. ) -> list[dict]:
  149. """Collect Git status for independent package repositories.
  150. Packages marked with ``git: true`` in config.yaml are authoritative.
  151. When the Trellis root is not a Git repo and no configured package repos are
  152. available, optionally fall back to the bounded polyrepo child scan.
  153. Returns:
  154. List of dicts with keys: name, path, branch, isClean,
  155. uncommittedChanges, recentCommits.
  156. Empty list if no git-repo packages are configured.
  157. """
  158. git_pkgs = get_git_packages(repo_root)
  159. result = []
  160. for pkg_name, pkg_path in git_pkgs.items():
  161. pkg_dir = repo_root / pkg_path
  162. info = _collect_git_repo_info(pkg_name, pkg_path, pkg_dir)
  163. if info is not None:
  164. result.append(info)
  165. if result or not discover_unconfigured:
  166. return result
  167. discovered = []
  168. for pkg_name, pkg_path in _discover_child_git_repos(repo_root):
  169. info = _collect_git_repo_info(pkg_name, pkg_path, repo_root / pkg_path)
  170. if info is not None:
  171. discovered.append(info)
  172. return discovered
  173. def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
  174. """Append root Git status without misleading non-Git roots."""
  175. lines.append("## GIT STATUS")
  176. if not root_git_info["isRepo"]:
  177. lines.append("Root is not a Git repository.")
  178. lines.append("Run Git commands from the package repository paths listed below.")
  179. else:
  180. lines.append(f"Branch: {root_git_info['branch']}")
  181. if root_git_info["isClean"]:
  182. lines.append("Working directory: Clean")
  183. else:
  184. lines.append(
  185. f"Working directory: {root_git_info['uncommittedChanges']} "
  186. "uncommitted change(s)"
  187. )
  188. lines.append("")
  189. lines.append("Changes:")
  190. for line in root_git_info.get("statusShort", [])[:10]:
  191. lines.append(line)
  192. lines.append("")
  193. lines.append("## RECENT COMMITS")
  194. if not root_git_info["isRepo"]:
  195. lines.append(
  196. "Root has no Git commit history because it is not a Git repository."
  197. )
  198. elif root_git_info["recentCommits"]:
  199. for commit in root_git_info["recentCommits"]:
  200. lines.append(f"{commit['hash']} {commit['message']}")
  201. else:
  202. lines.append("(no commits)")
  203. lines.append("")
  204. def _append_package_git_context(lines: list[str], package_git_info: list[dict]) -> None:
  205. """Append Git status and recent commits for package repositories."""
  206. for pkg in package_git_info:
  207. lines.append(f"## GIT STATUS ({pkg['name']}: {pkg['path']})")
  208. lines.append(f"Branch: {pkg['branch']}")
  209. if pkg["isClean"]:
  210. lines.append("Working directory: Clean")
  211. else:
  212. lines.append(
  213. f"Working directory: {pkg['uncommittedChanges']} uncommitted change(s)"
  214. )
  215. lines.append("")
  216. lines.append(f"## RECENT COMMITS ({pkg['name']}: {pkg['path']})")
  217. if pkg["recentCommits"]:
  218. for commit in pkg["recentCommits"]:
  219. lines.append(f"{commit['hash']} {commit['message']}")
  220. else:
  221. lines.append("(no commits)")
  222. lines.append("")
  223. def _read_project_version(repo_root: Path) -> str | None:
  224. try:
  225. version = (repo_root / DIR_WORKFLOW / ".version").read_text(
  226. encoding="utf-8"
  227. ).strip()
  228. except OSError:
  229. return None
  230. return version or None
  231. def _fetch_trellis_version_output() -> str | None:
  232. try:
  233. result = subprocess.run(
  234. ["trellis", "--version"],
  235. capture_output=True,
  236. text=True,
  237. encoding="utf-8",
  238. errors="replace",
  239. timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
  240. )
  241. except (OSError, subprocess.SubprocessError, TimeoutError):
  242. return None
  243. if result.returncode != 0:
  244. return None
  245. output = f"{result.stdout}\n{result.stderr}".strip()
  246. return output or None
  247. def _extract_available_update_version(output: str) -> str | None:
  248. update_match = re.search(
  249. r"Trellis update available:\s*"
  250. r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
  251. output,
  252. )
  253. if update_match:
  254. return update_match.group("latest").strip()
  255. candidates = _VERSION_TOKEN_RE.findall(output)
  256. return candidates[-1] if candidates else None
  257. def _resolve_available_update_version() -> str | None:
  258. output = _fetch_trellis_version_output()
  259. if not output:
  260. return None
  261. return _extract_available_update_version(output)
  262. def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
  263. match = _VERSION_RE.match(version)
  264. if not match:
  265. return None
  266. major, minor, patch, prerelease = match.groups()
  267. numbers = (int(major), int(minor or "0"), int(patch or "0"))
  268. prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
  269. return numbers, prerelease_parts
  270. def _compare_prerelease(
  271. left: tuple[str, ...] | None,
  272. right: tuple[str, ...] | None,
  273. ) -> int:
  274. if left is None and right is None:
  275. return 0
  276. if left is None:
  277. return 1
  278. if right is None:
  279. return -1
  280. for left_part, right_part in zip(left, right):
  281. if left_part == right_part:
  282. continue
  283. left_numeric = left_part.isdigit()
  284. right_numeric = right_part.isdigit()
  285. if left_numeric and right_numeric:
  286. left_int = int(left_part)
  287. right_int = int(right_part)
  288. return (left_int > right_int) - (left_int < right_int)
  289. if left_numeric:
  290. return -1
  291. if right_numeric:
  292. return 1
  293. return (left_part > right_part) - (left_part < right_part)
  294. return (len(left) > len(right)) - (len(left) < len(right))
  295. def _compare_versions(left: str, right: str) -> int | None:
  296. parsed_left = _parse_version(left)
  297. parsed_right = _parse_version(right)
  298. if parsed_left is None or parsed_right is None:
  299. return None
  300. left_numbers, left_prerelease = parsed_left
  301. right_numbers, right_prerelease = parsed_right
  302. if left_numbers != right_numbers:
  303. return (left_numbers > right_numbers) - (left_numbers < right_numbers)
  304. return _compare_prerelease(left_prerelease, right_prerelease)
  305. def _update_marker_path(repo_root: Path) -> Path:
  306. context_key = resolve_context_key()
  307. if not context_key:
  308. terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
  309. context_key = terminal_key or f"ppid-{os.getppid()}"
  310. safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
  311. if not safe_key:
  312. safe_key = "session"
  313. return (
  314. repo_root
  315. / DIR_WORKFLOW
  316. / ".runtime"
  317. / f"update-check-{safe_key[:160]}.marker"
  318. )
  319. def _mark_update_check_attempted(repo_root: Path) -> bool:
  320. marker_path = _update_marker_path(repo_root)
  321. if marker_path.exists():
  322. return False
  323. try:
  324. marker_path.parent.mkdir(parents=True, exist_ok=True)
  325. marker_path.write_text("checked\n", encoding="utf-8")
  326. except OSError:
  327. pass
  328. return True
  329. def _get_update_hint(repo_root: Path) -> str | None:
  330. marker_path = _update_marker_path(repo_root)
  331. if marker_path.exists():
  332. return None
  333. current_version = _read_project_version(repo_root)
  334. if not current_version:
  335. return None
  336. latest_version = _resolve_available_update_version()
  337. if not latest_version:
  338. return None
  339. _mark_update_check_attempted(repo_root)
  340. comparison = _compare_versions(current_version, latest_version)
  341. if comparison is None or comparison >= 0:
  342. return None
  343. return (
  344. f"Trellis update available: {current_version} -> {latest_version}, "
  345. f"run npm install -g {_PACKAGE_NAME}@latest"
  346. )
  347. # =============================================================================
  348. # JSON Output
  349. # =============================================================================
  350. def get_context_json(repo_root: Path | None = None) -> dict:
  351. """Get context as a dictionary.
  352. Args:
  353. repo_root: Repository root path. Defaults to auto-detected.
  354. Returns:
  355. Context dictionary.
  356. """
  357. if repo_root is None:
  358. repo_root = get_repo_root()
  359. developer = get_developer(repo_root)
  360. tasks_dir = get_tasks_dir(repo_root)
  361. journal_file = get_active_journal_file(repo_root)
  362. journal_lines = 0
  363. journal_relative = ""
  364. if journal_file and developer:
  365. journal_lines = count_lines(journal_file)
  366. journal_relative = (
  367. f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
  368. )
  369. root_git_info = _collect_root_git_info(repo_root)
  370. # Tasks
  371. tasks = [
  372. {
  373. "dir": t.dir_name,
  374. "name": t.name,
  375. "status": t.status,
  376. "children": list(t.children),
  377. "parent": t.parent,
  378. }
  379. for t in iter_active_tasks(tasks_dir)
  380. ]
  381. # Package git repos (independent sub-repositories)
  382. pkg_git_info = _collect_package_git_info(
  383. repo_root,
  384. discover_unconfigured=not root_git_info["isRepo"],
  385. )
  386. result = {
  387. "developer": developer or "",
  388. "git": {
  389. "isRepo": root_git_info["isRepo"],
  390. "branch": root_git_info["branch"],
  391. "isClean": root_git_info["isClean"],
  392. "uncommittedChanges": root_git_info["uncommittedChanges"],
  393. "recentCommits": root_git_info["recentCommits"],
  394. },
  395. "tasks": {
  396. "active": tasks,
  397. "directory": f"{DIR_WORKFLOW}/{DIR_TASKS}",
  398. },
  399. "journal": {
  400. "file": journal_relative,
  401. "lines": journal_lines,
  402. "nearLimit": journal_lines > 1800,
  403. },
  404. }
  405. if pkg_git_info:
  406. result["packageGit"] = pkg_git_info
  407. return result
  408. def output_json(repo_root: Path | None = None) -> None:
  409. """Output context in JSON format.
  410. Args:
  411. repo_root: Repository root path. Defaults to auto-detected.
  412. """
  413. context = get_context_json(repo_root)
  414. print(json.dumps(context, indent=2, ensure_ascii=False))
  415. # =============================================================================
  416. # Text Output
  417. # =============================================================================
  418. def get_context_text(repo_root: Path | None = None) -> str:
  419. """Get context as formatted text.
  420. Args:
  421. repo_root: Repository root path. Defaults to auto-detected.
  422. Returns:
  423. Formatted text output.
  424. """
  425. if repo_root is None:
  426. repo_root = get_repo_root()
  427. lines = []
  428. lines.append("========================================")
  429. lines.append("SESSION CONTEXT")
  430. lines.append("========================================")
  431. lines.append("")
  432. developer = get_developer(repo_root)
  433. # Developer section
  434. lines.append("## DEVELOPER")
  435. if not developer:
  436. lines.append(
  437. f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
  438. )
  439. return "\n".join(lines)
  440. lines.append(f"Name: {developer}")
  441. lines.append("")
  442. root_git_info = _collect_root_git_info(repo_root)
  443. _append_root_git_context(lines, root_git_info)
  444. # Package git repos — independent sub-repositories
  445. _append_package_git_context(
  446. lines,
  447. _collect_package_git_info(
  448. repo_root,
  449. discover_unconfigured=not root_git_info["isRepo"],
  450. ),
  451. )
  452. # Current task
  453. lines.append("## CURRENT TASK")
  454. current_task = get_current_task(repo_root)
  455. if current_task:
  456. current_task_dir = repo_root / current_task
  457. source_type, context_key, _ = get_current_task_source(repo_root)
  458. lines.append(f"Path: {current_task}")
  459. lines.append(
  460. f"Source: {source_type}" + (f":{context_key}" if context_key else "")
  461. )
  462. ct = load_task(current_task_dir)
  463. if ct:
  464. lines.append(f"Name: {ct.name}")
  465. lines.append(f"Status: {ct.status}")
  466. lines.append(f"Created: {ct.raw.get('createdAt', 'unknown')}")
  467. if ct.description:
  468. lines.append(f"Description: {ct.description}")
  469. # Check for prd.md
  470. prd_file = current_task_dir / "prd.md"
  471. if prd_file.is_file():
  472. lines.append("")
  473. lines.append("[!] This task has prd.md - read it for task details")
  474. else:
  475. lines.append("(none)")
  476. lines.append("")
  477. # Active tasks
  478. lines.append("## ACTIVE TASKS")
  479. tasks_dir = get_tasks_dir(repo_root)
  480. task_count = 0
  481. # Collect all task data for hierarchy display
  482. all_tasks = {t.dir_name: t for t in iter_active_tasks(tasks_dir)}
  483. all_statuses = {name: t.status for name, t in all_tasks.items()}
  484. def _print_task_tree(name: str, indent: int = 0) -> None:
  485. nonlocal task_count
  486. t = all_tasks[name]
  487. progress = children_progress(t.children, all_statuses)
  488. prefix = " " * indent
  489. lines.append(f"{prefix}- {name}/ ({t.status}){progress} @{t.assignee or '-'}")
  490. task_count += 1
  491. for child in t.children:
  492. if child in all_tasks:
  493. _print_task_tree(child, indent + 1)
  494. for dir_name in sorted(all_tasks.keys()):
  495. if not all_tasks[dir_name].parent:
  496. _print_task_tree(dir_name)
  497. if task_count == 0:
  498. lines.append("(no active tasks)")
  499. lines.append(f"Total: {task_count} active task(s)")
  500. lines.append("")
  501. # My tasks
  502. lines.append("## MY TASKS (Assigned to me)")
  503. my_task_count = 0
  504. for t in all_tasks.values():
  505. if t.assignee == developer and t.status != "done":
  506. progress = children_progress(t.children, all_statuses)
  507. lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress}")
  508. my_task_count += 1
  509. if my_task_count == 0:
  510. lines.append("(no tasks assigned to you)")
  511. lines.append("")
  512. # Journal file
  513. lines.append("## JOURNAL FILE")
  514. journal_file = get_active_journal_file(repo_root)
  515. if journal_file:
  516. journal_lines = count_lines(journal_file)
  517. relative = f"{DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/{journal_file.name}"
  518. lines.append(f"Active file: {relative}")
  519. lines.append(f"Line count: {journal_lines} / 2000")
  520. if journal_lines > 1800:
  521. lines.append("[!] WARNING: Approaching 2000 line limit!")
  522. else:
  523. lines.append("No journal file found")
  524. lines.append("")
  525. # Packages
  526. packages_text = get_packages_section(repo_root)
  527. if packages_text:
  528. lines.append(packages_text)
  529. lines.append("")
  530. # Paths
  531. lines.append("## PATHS")
  532. lines.append(f"Workspace: {DIR_WORKFLOW}/{DIR_WORKSPACE}/{developer}/")
  533. lines.append(f"Tasks: {DIR_WORKFLOW}/{DIR_TASKS}/")
  534. lines.append(f"Spec: {DIR_WORKFLOW}/{DIR_SPEC}/")
  535. lines.append("")
  536. lines.append("========================================")
  537. return "\n".join(lines)
  538. # =============================================================================
  539. # Record Mode
  540. # =============================================================================
  541. def get_context_record_json(repo_root: Path | None = None) -> dict:
  542. """Get record-mode context as a dictionary.
  543. Focused on: my active tasks, git status, current task.
  544. """
  545. if repo_root is None:
  546. repo_root = get_repo_root()
  547. developer = get_developer(repo_root)
  548. tasks_dir = get_tasks_dir(repo_root)
  549. root_git_info = _collect_root_git_info(repo_root)
  550. # My tasks (single pass — collect statuses and filter by assignee)
  551. all_tasks_list = list(iter_active_tasks(tasks_dir))
  552. all_statuses = {t.dir_name: t.status for t in all_tasks_list}
  553. my_tasks = []
  554. for t in all_tasks_list:
  555. if t.assignee == developer:
  556. done = sum(
  557. 1 for c in t.children
  558. if all_statuses.get(c) in ("completed", "done")
  559. )
  560. my_tasks.append({
  561. "dir": t.dir_name,
  562. "title": t.title,
  563. "status": t.status,
  564. "priority": t.priority,
  565. "children": list(t.children),
  566. "childrenDone": done,
  567. "parent": t.parent,
  568. "meta": t.meta,
  569. })
  570. # Current task
  571. current_task_info = None
  572. current_task = get_current_task(repo_root)
  573. if current_task:
  574. source_type, context_key, _ = get_current_task_source(repo_root)
  575. ct = load_task(repo_root / current_task)
  576. if ct:
  577. current_task_info = {
  578. "path": current_task,
  579. "name": ct.name,
  580. "status": ct.status,
  581. "source": source_type,
  582. "contextKey": context_key,
  583. }
  584. # Package git repos
  585. pkg_git_info = _collect_package_git_info(
  586. repo_root,
  587. discover_unconfigured=not root_git_info["isRepo"],
  588. )
  589. result = {
  590. "developer": developer or "",
  591. "git": {
  592. "isRepo": root_git_info["isRepo"],
  593. "branch": root_git_info["branch"],
  594. "isClean": root_git_info["isClean"],
  595. "uncommittedChanges": root_git_info["uncommittedChanges"],
  596. "recentCommits": root_git_info["recentCommits"],
  597. },
  598. "myTasks": my_tasks,
  599. "currentTask": current_task_info,
  600. }
  601. if pkg_git_info:
  602. result["packageGit"] = pkg_git_info
  603. return result
  604. def get_context_text_record(repo_root: Path | None = None) -> str:
  605. """Get context as formatted text for record-session mode.
  606. Focused output: MY ACTIVE TASKS first (with [!!!] emphasis),
  607. then GIT STATUS, RECENT COMMITS, CURRENT TASK.
  608. """
  609. if repo_root is None:
  610. repo_root = get_repo_root()
  611. lines: list[str] = []
  612. lines.append("========================================")
  613. lines.append("SESSION CONTEXT (RECORD MODE)")
  614. lines.append("========================================")
  615. lines.append("")
  616. developer = get_developer(repo_root)
  617. if not developer:
  618. lines.append(
  619. f"ERROR: Not initialized. Run: python3 ./{DIR_WORKFLOW}/{DIR_SCRIPTS}/init_developer.py <name>"
  620. )
  621. return "\n".join(lines)
  622. # MY ACTIVE TASKS — first and prominent
  623. lines.append(f"## [!!!] MY ACTIVE TASKS (Assigned to {developer})")
  624. lines.append("[!] Review whether any should be archived before recording this session.")
  625. lines.append("")
  626. tasks_dir = get_tasks_dir(repo_root)
  627. my_task_count = 0
  628. # Single pass — collect all tasks and filter by assignee
  629. all_statuses = get_all_statuses(tasks_dir)
  630. for t in iter_active_tasks(tasks_dir):
  631. if t.assignee == developer:
  632. progress = children_progress(t.children, all_statuses)
  633. lines.append(f"- [{t.priority}] {t.title} ({t.status}){progress} — {t.dir_name}")
  634. my_task_count += 1
  635. if my_task_count == 0:
  636. lines.append("(no active tasks assigned to you)")
  637. lines.append("")
  638. root_git_info = _collect_root_git_info(repo_root)
  639. _append_root_git_context(lines, root_git_info)
  640. # Package git repos — independent sub-repositories
  641. _append_package_git_context(
  642. lines,
  643. _collect_package_git_info(
  644. repo_root,
  645. discover_unconfigured=not root_git_info["isRepo"],
  646. ),
  647. )
  648. # CURRENT TASK
  649. lines.append("## CURRENT TASK")
  650. current_task = get_current_task(repo_root)
  651. if current_task:
  652. source_type, context_key, _ = get_current_task_source(repo_root)
  653. lines.append(f"Path: {current_task}")
  654. lines.append(
  655. f"Source: {source_type}" + (f":{context_key}" if context_key else "")
  656. )
  657. ct = load_task(repo_root / current_task)
  658. if ct:
  659. lines.append(f"Name: {ct.name}")
  660. lines.append(f"Status: {ct.status}")
  661. else:
  662. lines.append("(none)")
  663. lines.append("")
  664. lines.append("========================================")
  665. return "\n".join(lines)
  666. def output_text(repo_root: Path | None = None) -> None:
  667. """Output context in text format.
  668. Args:
  669. repo_root: Repository root path. Defaults to auto-detected.
  670. """
  671. if repo_root is None:
  672. repo_root = get_repo_root()
  673. update_hint = _get_update_hint(repo_root)
  674. if update_hint:
  675. print(update_hint)
  676. print("")
  677. print(get_context_text(repo_root))