e567e5f717
- Updated bundled skills documentation to clarify the structure and usage across all platforms, ensuring consistency in skill root paths. - Introduced a new `inject-spec-context.py` hook for path-scoped spec context injection, improving the relevance of injected specs during file interactions. - Enhanced existing hooks to support workflow resolution, allowing for dynamic selection of workflows based on task context. - Added a command to manage workflow selections for active tasks, enabling better task management and workflow adherence. - Updated configuration options for spec injection, including character limits and refresh windows, to optimize performance and usability.
476 lines
13 KiB
Python
Executable File
476 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Common path utilities for Trellis workflow.
|
|
|
|
Provides:
|
|
get_repo_root - Get repository root directory
|
|
get_developer - Get developer name
|
|
get_workspace_dir - Get developer workspace directory
|
|
get_tasks_dir - Get tasks directory
|
|
get_active_journal_file - Get current journal file
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
|
|
# =============================================================================
|
|
# Path Constants (change here to rename directories)
|
|
# =============================================================================
|
|
|
|
# Directory names
|
|
DIR_WORKFLOW = ".trellis"
|
|
DIR_WORKSPACE = "workspace"
|
|
DIR_TASKS = "tasks"
|
|
DIR_ARCHIVE = "archive"
|
|
DIR_SPEC = "spec"
|
|
DIR_SCRIPTS = "scripts"
|
|
|
|
# File names
|
|
FILE_DEVELOPER = ".developer"
|
|
FILE_CURRENT_TASK = ".current-task"
|
|
FILE_TASK_JSON = "task.json"
|
|
FILE_JOURNAL_PREFIX = "journal-"
|
|
|
|
|
|
# =============================================================================
|
|
# Repository Root
|
|
# =============================================================================
|
|
|
|
def get_repo_root(start_path: Path | None = None) -> Path:
|
|
"""Find the nearest directory containing .trellis/ folder.
|
|
|
|
This handles nested git repos correctly (e.g., test project inside another repo).
|
|
|
|
Args:
|
|
start_path: Starting directory to search from. Defaults to current directory.
|
|
|
|
Returns:
|
|
Path to repository root, or current directory if no .trellis/ found.
|
|
"""
|
|
current = (start_path or Path.cwd()).resolve()
|
|
|
|
while current != current.parent:
|
|
if (current / DIR_WORKFLOW).is_dir():
|
|
return current
|
|
current = current.parent
|
|
|
|
# Fallback to current directory if no .trellis/ found
|
|
return Path.cwd().resolve()
|
|
|
|
|
|
# =============================================================================
|
|
# Developer
|
|
# =============================================================================
|
|
|
|
def get_developer(repo_root: Path | None = None) -> str | None:
|
|
"""Get developer name from .developer file.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Developer name or None if not initialized.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
|
|
|
if not dev_file.is_file():
|
|
return None
|
|
|
|
try:
|
|
content = dev_file.read_text(encoding="utf-8")
|
|
for line in content.splitlines():
|
|
if line.startswith("name="):
|
|
return line.split("=", 1)[1].strip()
|
|
except (OSError, IOError):
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def get_developer_workflow(repo_root: Path | None = None) -> str | None:
|
|
"""Get the personal workflow override from the .developer file.
|
|
|
|
Reads an optional ``workflow=<id>`` line from the gitignored ``.developer``
|
|
file — the per-developer, git-excluded override that outranks the
|
|
team-shared ``default_workflow`` in config.yaml. Returns None when the file
|
|
or the line is absent/blank. Never raises. Additive to ``get_developer``:
|
|
the ``name=`` reader ignores this line and vice versa.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
dev_file = repo_root / DIR_WORKFLOW / FILE_DEVELOPER
|
|
|
|
if not dev_file.is_file():
|
|
return None
|
|
|
|
try:
|
|
content = dev_file.read_text(encoding="utf-8")
|
|
for line in content.splitlines():
|
|
if line.startswith("workflow="):
|
|
return line.split("=", 1)[1].strip() or None
|
|
except (OSError, IOError):
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def check_developer(repo_root: Path | None = None) -> bool:
|
|
"""Check if developer is initialized.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
True if developer is initialized.
|
|
"""
|
|
return get_developer(repo_root) is not None
|
|
|
|
|
|
# =============================================================================
|
|
# Tasks Directory
|
|
# =============================================================================
|
|
|
|
def get_tasks_dir(repo_root: Path | None = None) -> Path:
|
|
"""Get tasks directory path.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Path to tasks directory.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
return repo_root / DIR_WORKFLOW / DIR_TASKS
|
|
|
|
|
|
# =============================================================================
|
|
# Workspace Directory
|
|
# =============================================================================
|
|
|
|
def get_workspace_dir(repo_root: Path | None = None) -> Path | None:
|
|
"""Get developer workspace directory.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Path to workspace directory or None if developer not set.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
developer = get_developer(repo_root)
|
|
if developer:
|
|
return repo_root / DIR_WORKFLOW / DIR_WORKSPACE / developer
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Journal File
|
|
# =============================================================================
|
|
|
|
def get_active_journal_file(repo_root: Path | None = None) -> Path | None:
|
|
"""Get the current active journal file.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Path to active journal file or None if not found.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
workspace_dir = get_workspace_dir(repo_root)
|
|
if workspace_dir is None or not workspace_dir.is_dir():
|
|
return None
|
|
|
|
latest: Path | None = None
|
|
highest = 0
|
|
|
|
for f in workspace_dir.glob(f"{FILE_JOURNAL_PREFIX}*.md"):
|
|
if not f.is_file():
|
|
continue
|
|
|
|
# Extract number from filename
|
|
name = f.stem # e.g., "journal-1"
|
|
match = re.search(r"(\d+)$", name)
|
|
if match:
|
|
num = int(match.group(1))
|
|
if num > highest:
|
|
highest = num
|
|
latest = f
|
|
|
|
return latest
|
|
|
|
|
|
def count_lines(file_path: Path) -> int:
|
|
"""Count lines in a file.
|
|
|
|
Args:
|
|
file_path: Path to file.
|
|
|
|
Returns:
|
|
Number of lines, or 0 if file doesn't exist.
|
|
"""
|
|
if not file_path.is_file():
|
|
return 0
|
|
|
|
try:
|
|
return len(file_path.read_text(encoding="utf-8").splitlines())
|
|
except (OSError, IOError):
|
|
return 0
|
|
|
|
|
|
# =============================================================================
|
|
# Current Task Management
|
|
# =============================================================================
|
|
|
|
def normalize_task_ref(task_ref: str) -> str:
|
|
"""Normalize a task ref for stable runtime storage.
|
|
|
|
Stored refs should prefer repo-relative POSIX paths like
|
|
`.trellis/tasks/03-27-my-task`, even on Windows. Absolute paths are preserved
|
|
unless they can later be converted back to repo-relative form by callers.
|
|
"""
|
|
normalized = task_ref.strip()
|
|
if not normalized:
|
|
return ""
|
|
|
|
path_obj = Path(normalized)
|
|
if path_obj.is_absolute():
|
|
return str(path_obj)
|
|
|
|
normalized = normalized.replace("\\", "/")
|
|
while normalized.startswith("./"):
|
|
normalized = normalized[2:]
|
|
|
|
if normalized.startswith(f"{DIR_TASKS}/"):
|
|
return f"{DIR_WORKFLOW}/{normalized}"
|
|
|
|
return normalized
|
|
|
|
|
|
def resolve_task_ref(task_ref: str, repo_root: Path | None = None) -> Path | None:
|
|
"""Resolve a task ref to an absolute task directory path."""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
normalized = normalize_task_ref(task_ref)
|
|
if not normalized:
|
|
return None
|
|
|
|
path_obj = Path(normalized)
|
|
if path_obj.is_absolute():
|
|
return path_obj
|
|
|
|
if normalized.startswith(f"{DIR_WORKFLOW}/"):
|
|
return repo_root / path_obj
|
|
|
|
return repo_root / DIR_WORKFLOW / DIR_TASKS / path_obj
|
|
|
|
|
|
def get_current_task(
|
|
repo_root: Path | None = None,
|
|
platform_input: dict | None = None,
|
|
platform: str | None = None,
|
|
) -> str | None:
|
|
"""Get current task directory path (relative to repo_root).
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Relative path to current task directory or None.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
from .active_task import resolve_active_task
|
|
|
|
return resolve_active_task(repo_root, platform_input, platform).task_path
|
|
|
|
|
|
def get_current_task_abs(
|
|
repo_root: Path | None = None,
|
|
platform_input: dict | None = None,
|
|
platform: str | None = None,
|
|
) -> Path | None:
|
|
"""Get current task directory absolute path.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
Absolute path to current task directory or None.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
relative = get_current_task(repo_root, platform_input, platform)
|
|
if relative:
|
|
return resolve_task_ref(relative, repo_root)
|
|
return None
|
|
|
|
|
|
def get_current_task_source(
|
|
repo_root: Path | None = None,
|
|
platform_input: dict | None = None,
|
|
platform: str | None = None,
|
|
) -> tuple[str, str | None, str | None]:
|
|
"""Get active task source as (`source`, `context_key`, `task_path`)."""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
from .active_task import get_current_task_source as _get_source
|
|
|
|
return _get_source(repo_root, platform_input, platform)
|
|
|
|
|
|
def set_current_task(
|
|
task_path: str,
|
|
repo_root: Path | None = None,
|
|
platform_input: dict | None = None,
|
|
platform: str | None = None,
|
|
) -> bool:
|
|
"""Set current task in session scope.
|
|
|
|
Args:
|
|
task_path: Task directory path (relative to repo_root).
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
True on success, False on error.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
from .active_task import set_active_task
|
|
|
|
return set_active_task(
|
|
task_path,
|
|
repo_root,
|
|
platform_input=platform_input,
|
|
platform=platform,
|
|
) is not None
|
|
|
|
|
|
def clear_current_task(
|
|
repo_root: Path | None = None,
|
|
platform_input: dict | None = None,
|
|
platform: str | None = None,
|
|
) -> bool:
|
|
"""Clear current task in session scope.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
True on success.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
from .active_task import clear_active_task
|
|
|
|
clear_active_task(
|
|
repo_root,
|
|
platform_input=platform_input,
|
|
platform=platform,
|
|
)
|
|
return True
|
|
|
|
|
|
def has_current_task(repo_root: Path | None = None) -> bool:
|
|
"""Check if has current task.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Defaults to auto-detected.
|
|
|
|
Returns:
|
|
True if current task is set.
|
|
"""
|
|
return get_current_task(repo_root) is not None
|
|
|
|
|
|
# =============================================================================
|
|
# Task ID Generation
|
|
# =============================================================================
|
|
|
|
def generate_task_date_prefix() -> str:
|
|
"""Generate task ID based on date (MM-DD format).
|
|
|
|
Returns:
|
|
Date prefix string (e.g., "01-21").
|
|
"""
|
|
return datetime.now().strftime("%m-%d")
|
|
|
|
|
|
# =============================================================================
|
|
# Monorepo / Package Paths
|
|
# =============================================================================
|
|
|
|
|
|
def get_spec_dir(package: str | None = None, repo_root: Path | None = None) -> Path:
|
|
"""Get the spec directory path.
|
|
|
|
Single-repo: .trellis/spec
|
|
Monorepo with package: .trellis/spec/<package>
|
|
|
|
Uses lazy import to avoid circular dependency with config.py.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
from .config import get_spec_base
|
|
|
|
base = get_spec_base(package, repo_root)
|
|
return repo_root / DIR_WORKFLOW / base
|
|
|
|
|
|
def get_package_path(package: str, repo_root: Path | None = None) -> Path | None:
|
|
"""Get a package's source directory absolute path from config.
|
|
|
|
Returns:
|
|
Absolute path to the package directory, or None if not found.
|
|
"""
|
|
if repo_root is None:
|
|
repo_root = get_repo_root()
|
|
|
|
from .config import get_packages
|
|
|
|
packages = get_packages(repo_root)
|
|
if not packages or package not in packages:
|
|
return None
|
|
|
|
info = packages[package]
|
|
if isinstance(info, dict):
|
|
rel_path = info.get("path", package)
|
|
else:
|
|
rel_path = str(info)
|
|
|
|
return repo_root / rel_path
|
|
|
|
|
|
# =============================================================================
|
|
# Main Entry (for testing)
|
|
# =============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
repo = get_repo_root()
|
|
print(f"Repository root: {repo}")
|
|
print(f"Developer: {get_developer(repo)}")
|
|
print(f"Tasks dir: {get_tasks_dir(repo)}")
|
|
print(f"Workspace dir: {get_workspace_dir(repo)}")
|
|
print(f"Journal file: {get_active_journal_file(repo)}")
|
|
print(f"Current task: {get_current_task(repo)}")
|