@stylusnexus/work-plan 2026.6.9

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 (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +36 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +119 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/brief.py +247 -0
  11. package/skills/work-plan/commands/canonicalize.py +122 -0
  12. package/skills/work-plan/commands/close.py +83 -0
  13. package/skills/work-plan/commands/duplicates.py +111 -0
  14. package/skills/work-plan/commands/export.py +69 -0
  15. package/skills/work-plan/commands/group.py +234 -0
  16. package/skills/work-plan/commands/handoff.py +855 -0
  17. package/skills/work-plan/commands/hygiene.py +104 -0
  18. package/skills/work-plan/commands/init.py +96 -0
  19. package/skills/work-plan/commands/init_repo.py +90 -0
  20. package/skills/work-plan/commands/list_cmd.py +39 -0
  21. package/skills/work-plan/commands/new_track.py +148 -0
  22. package/skills/work-plan/commands/plan_status.py +296 -0
  23. package/skills/work-plan/commands/reconcile.py +172 -0
  24. package/skills/work-plan/commands/refresh_md.py +132 -0
  25. package/skills/work-plan/commands/set_field.py +54 -0
  26. package/skills/work-plan/commands/set_notes_root.py +53 -0
  27. package/skills/work-plan/commands/slot.py +139 -0
  28. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  29. package/skills/work-plan/commands/where_was_i.py +325 -0
  30. package/skills/work-plan/lib/__init__.py +0 -0
  31. package/skills/work-plan/lib/closure.py +72 -0
  32. package/skills/work-plan/lib/config.py +82 -0
  33. package/skills/work-plan/lib/doc_discovery.py +41 -0
  34. package/skills/work-plan/lib/drift.py +32 -0
  35. package/skills/work-plan/lib/export_model.py +40 -0
  36. package/skills/work-plan/lib/frontmatter.py +48 -0
  37. package/skills/work-plan/lib/git_state.py +180 -0
  38. package/skills/work-plan/lib/github_state.py +296 -0
  39. package/skills/work-plan/lib/llm_evidence.py +45 -0
  40. package/skills/work-plan/lib/manifest.py +164 -0
  41. package/skills/work-plan/lib/new_issues.py +69 -0
  42. package/skills/work-plan/lib/next_up.py +98 -0
  43. package/skills/work-plan/lib/prompts.py +68 -0
  44. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  45. package/skills/work-plan/lib/render.py +83 -0
  46. package/skills/work-plan/lib/scratch.py +14 -0
  47. package/skills/work-plan/lib/session_log.py +39 -0
  48. package/skills/work-plan/lib/status_header.py +60 -0
  49. package/skills/work-plan/lib/status_table.py +227 -0
  50. package/skills/work-plan/lib/tracks.py +109 -0
  51. package/skills/work-plan/lib/verdict.py +51 -0
  52. package/skills/work-plan/lib/write_guard.py +39 -0
  53. package/skills/work-plan/tests/__init__.py +0 -0
  54. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  55. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  56. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  58. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  59. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  60. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  61. package/skills/work-plan/tests/test_close.py +273 -0
  62. package/skills/work-plan/tests/test_closure.py +51 -0
  63. package/skills/work-plan/tests/test_config.py +85 -0
  64. package/skills/work-plan/tests/test_config_seed.py +41 -0
  65. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  66. package/skills/work-plan/tests/test_drift.py +38 -0
  67. package/skills/work-plan/tests/test_export.py +91 -0
  68. package/skills/work-plan/tests/test_export_command.py +295 -0
  69. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  70. package/skills/work-plan/tests/test_git_state.py +51 -0
  71. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  72. package/skills/work-plan/tests/test_github_state.py +508 -0
  73. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  74. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  75. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  76. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  77. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  78. package/skills/work-plan/tests/test_init.py +289 -0
  79. package/skills/work-plan/tests/test_init_repo.py +251 -0
  80. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  81. package/skills/work-plan/tests/test_manifest.py +162 -0
  82. package/skills/work-plan/tests/test_new_issues.py +130 -0
  83. package/skills/work-plan/tests/test_new_track.py +445 -0
  84. package/skills/work-plan/tests/test_next_up.py +149 -0
  85. package/skills/work-plan/tests/test_plan_status.py +68 -0
  86. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  87. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  88. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  89. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  90. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  91. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  92. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  93. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  94. package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
  95. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  96. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  97. package/skills/work-plan/tests/test_render.py +110 -0
  98. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  99. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  100. package/skills/work-plan/tests/test_session_log.py +39 -0
  101. package/skills/work-plan/tests/test_set_field.py +77 -0
  102. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  103. package/skills/work-plan/tests/test_slot.py +243 -0
  104. package/skills/work-plan/tests/test_slot_move.py +128 -0
  105. package/skills/work-plan/tests/test_smoke.py +46 -0
  106. package/skills/work-plan/tests/test_status_header.py +79 -0
  107. package/skills/work-plan/tests/test_status_table.py +162 -0
  108. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  109. package/skills/work-plan/tests/test_tracks.py +56 -0
  110. package/skills/work-plan/tests/test_verdict.py +60 -0
  111. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  112. package/skills/work-plan/tests/test_write_guard.py +53 -0
  113. package/skills/work-plan/work_plan.py +210 -0
@@ -0,0 +1,72 @@
1
+ """Closure-ready signal detection."""
2
+ from dataclasses import dataclass
3
+ from datetime import datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from lib.git_state import last_commit_date, branch_exists
8
+
9
+
10
+ @dataclass
11
+ class ClosureSignals:
12
+ """The 5 closure signals from the spec."""
13
+ all_issues_closed: bool
14
+ all_branches_done: bool
15
+ next_up_empty: bool
16
+ cold_14d: bool
17
+ no_recent_related_issues: bool
18
+
19
+
20
+ def is_closure_ready(signals: ClosureSignals) -> tuple[bool, list[str]]:
21
+ """All signals must be true. Returns (ready, blocking-reasons)."""
22
+ reasons = []
23
+ if not signals.all_issues_closed:
24
+ reasons.append("open issues remain")
25
+ if not signals.all_branches_done:
26
+ reasons.append("branches still active")
27
+ if not signals.next_up_empty:
28
+ reasons.append("next_up is not empty")
29
+ if not signals.cold_14d:
30
+ reasons.append("recent commits within 14 days")
31
+ if not signals.no_recent_related_issues:
32
+ reasons.append("new related issues in last 30 days")
33
+ return (not reasons, reasons)
34
+
35
+
36
+ def compute_signals(track_meta: dict, github_issues: list[dict],
37
+ repo_path: Optional[Path],
38
+ recent_related_count: int) -> ClosureSignals:
39
+ """Build ClosureSignals from observed state."""
40
+ listed_issue_nums = track_meta.get("github", {}).get("issues") or []
41
+ state_by_num = {i["number"]: i.get("state", "OPEN") for i in github_issues}
42
+
43
+ all_closed = bool(listed_issue_nums) and all(
44
+ state_by_num.get(n, "OPEN") == "CLOSED" for n in listed_issue_nums
45
+ )
46
+
47
+ branches = track_meta.get("github", {}).get("branches") or []
48
+ if repo_path:
49
+ all_branches_done = all(not branch_exists(b, repo_path) for b in branches)
50
+ else:
51
+ all_branches_done = len(branches) == 0
52
+
53
+ next_up_empty = not (track_meta.get("next_up") or [])
54
+
55
+ cutoff = datetime.now() - timedelta(days=14)
56
+ cold = True
57
+ if repo_path:
58
+ for b in branches:
59
+ last = last_commit_date(b, repo_path)
60
+ if last and last > cutoff:
61
+ cold = False
62
+ break
63
+
64
+ no_recent = recent_related_count == 0
65
+
66
+ return ClosureSignals(
67
+ all_issues_closed=all_closed,
68
+ all_branches_done=all_branches_done,
69
+ next_up_empty=next_up_empty,
70
+ cold_14d=cold,
71
+ no_recent_related_issues=no_recent,
72
+ )
@@ -0,0 +1,82 @@
1
+ """Load + validate ~/.claude/work-plan/config.yml."""
2
+ import json
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ DEFAULT_CONFIG_PATH = Path.home() / ".claude" / "work-plan" / "config.yml"
8
+ DEFAULT_NOTES_ROOT = Path.home() / ".claude" / "work-plan" / "notes"
9
+
10
+ _SEED_TEMPLATE = (
11
+ "# work-plan config — auto-seeded on first run. Edit to customize.\n"
12
+ "# Run /work-plan init-repo <key> --github=<org/repo> to populate repos:.\n"
13
+ "notes_root: {notes_root}\n"
14
+ "repos: {{}}\n"
15
+ )
16
+
17
+
18
+ class ConfigError(Exception):
19
+ pass
20
+
21
+
22
+ def ensure_config(path: Path = DEFAULT_CONFIG_PATH,
23
+ notes_root: Path = DEFAULT_NOTES_ROOT) -> bool:
24
+ """Create a default config.yml (and notes_root dir) if absent.
25
+
26
+ Single source of the seed content — install.sh/install.ps1 delegate here, so
27
+ plugin installs (which run no install hook) and script installs behave
28
+ identically. `notes_root` is written as an ABSOLUTE path (never a literal
29
+ `~`, which downstream `Path(...)` would not expand). Returns True if it
30
+ created the file, False if it already existed.
31
+ """
32
+ path = Path(path)
33
+ if path.exists():
34
+ return False
35
+ notes_root = Path(notes_root).expanduser()
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ notes_root.mkdir(parents=True, exist_ok=True)
38
+ path.write_text(_SEED_TEMPLATE.format(notes_root=notes_root), encoding="utf-8")
39
+ return True
40
+
41
+
42
+ def load_config(path: Path = DEFAULT_CONFIG_PATH,
43
+ notes_root: Path = DEFAULT_NOTES_ROOT) -> dict:
44
+ """Load and validate. Self-seeds a default config if absent (no install hook
45
+ exists for plugin installs). Normalizes string-shape repo entries to dicts."""
46
+ path = Path(path)
47
+ if not path.exists():
48
+ ensure_config(path, notes_root)
49
+ text = path.read_text(encoding="utf-8")
50
+ proc = subprocess.run(
51
+ ["yq", "-o=json", "."], input=text,
52
+ capture_output=True, text=True, check=True,
53
+ )
54
+ cfg = json.loads(proc.stdout)
55
+ if not isinstance(cfg, dict):
56
+ raise ConfigError(f"config.yml must be a YAML mapping; got {type(cfg).__name__}")
57
+ if "notes_root" not in cfg:
58
+ raise ConfigError("config.yml missing required key 'notes_root'.")
59
+ cfg.setdefault("repos", {})
60
+ # Normalize string-shape entries to dict shape
61
+ for folder, val in list(cfg["repos"].items()):
62
+ if isinstance(val, str):
63
+ cfg["repos"][folder] = {"github": val, "local": None}
64
+ elif isinstance(val, dict):
65
+ val.setdefault("local", None)
66
+ if "github" not in val:
67
+ raise ConfigError(f"repo '{folder}' missing 'github' key")
68
+ else:
69
+ raise ConfigError(f"repo '{folder}' must be string or dict, got {type(val).__name__}")
70
+ return cfg
71
+
72
+
73
+ def resolve_github_for_folder(folder_name: str, cfg: dict) -> Optional[str]:
74
+ entry = cfg.get("repos", {}).get(folder_name)
75
+ return entry.get("github") if entry else None
76
+
77
+
78
+ def resolve_local_path_for_folder(folder_name: str, cfg: dict) -> Optional[Path]:
79
+ entry = cfg.get("repos", {}).get(folder_name)
80
+ if not entry or not entry.get("local"):
81
+ return None
82
+ return Path(entry["local"]).expanduser()
@@ -0,0 +1,41 @@
1
+ """Discover plan/spec docs in a repo via configurable globs, and classify each."""
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ DEFAULT_GLOBS = [
7
+ "docs/superpowers/plans/*.md",
8
+ "docs/superpowers/specs/*.md",
9
+ "docs/plans/*.md",
10
+ ]
11
+
12
+
13
+ @dataclass
14
+ class Doc:
15
+ path: Path # absolute
16
+ rel: str # repo-relative POSIX-style
17
+ kind: str # "plan" | "spec" | "adhoc"
18
+
19
+
20
+ def classify_kind(rel: str) -> str:
21
+ """Heuristic doc-kind from its repo-relative path."""
22
+ if rel.endswith("-design.md") or "/specs/" in rel:
23
+ return "spec"
24
+ if "/plans/" in rel:
25
+ return "plan"
26
+ return "adhoc"
27
+
28
+
29
+ def discover_docs(repo_root: Path, globs: Optional[list] = None) -> list:
30
+ globs = globs or DEFAULT_GLOBS
31
+ repo_root = Path(repo_root)
32
+ out = []
33
+ seen = set()
34
+ for g in globs:
35
+ for p in sorted(repo_root.glob(g)):
36
+ if not p.is_file() or p in seen:
37
+ continue
38
+ seen.add(p)
39
+ rel = p.relative_to(repo_root).as_posix()
40
+ out.append(Doc(path=p, rel=rel, kind=classify_kind(rel)))
41
+ return out
@@ -0,0 +1,32 @@
1
+ """Detect drift between body status table and GitHub state."""
2
+ from lib.status_table import find_status_table, ISSUE_NUM_RE
3
+
4
+
5
+ def detect_drift(body: str, github_issues: list[dict]) -> list[dict]:
6
+ """Return list of {issue, body_status, github_state} for drifted rows."""
7
+ table = find_status_table(body)
8
+ if not table:
9
+ return []
10
+
11
+ state_by_num = {i["number"]: i.get("state", "OPEN") for i in github_issues}
12
+ drift = []
13
+ sidx = table["status_col_index"]
14
+ for row in table["rows"]:
15
+ nums = []
16
+ for cell in row["cells"]:
17
+ nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
18
+ if not nums:
19
+ continue
20
+ body_status = row["cells"][sidx].strip().lower() if sidx < len(row["cells"]) else ""
21
+ for num in nums:
22
+ if num not in state_by_num:
23
+ continue
24
+ gh_state = state_by_num[num]
25
+ looks_closed = any(k in body_status for k in ("✅", "shipped", "merged", "closed"))
26
+ looks_open = "🔲" in body_status or "open" in body_status
27
+
28
+ if gh_state == "CLOSED" and not looks_closed:
29
+ drift.append({"issue": num, "body_status": body_status, "github_state": "CLOSED"})
30
+ elif gh_state == "OPEN" and looks_closed:
31
+ drift.append({"issue": num, "body_status": body_status, "github_state": "OPEN"})
32
+ return drift
@@ -0,0 +1,40 @@
1
+ """Build the versioned viewer export structure from tracks + fetched issues."""
2
+ from lib.github_state import format_assignees, short_milestone
3
+
4
+ SCHEMA = 1
5
+
6
+ def _issue(i: dict) -> dict:
7
+ state = (i.get("state") or "OPEN").lower()
8
+ return {
9
+ "number": i.get("number"),
10
+ "title": i.get("title", ""),
11
+ "state": "closed" if state in ("closed", "merged") else "open",
12
+ "assignee": (format_assignees(i) if i.get("assignees") else "—"),
13
+ "milestone": short_milestone(i.get("milestone")) or None,
14
+ }
15
+
16
+ def build_export(tracks, issues_by_track, visibility, now: str,
17
+ untracked_by_repo=None) -> dict:
18
+ out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
19
+ for t in tracks:
20
+ issues = [_issue(i) for i in issues_by_track.get(t.name, [])]
21
+ opened = sum(1 for i in issues if i["state"] == "open")
22
+ out["tracks"].append({
23
+ "name": t.name,
24
+ "repo": t.repo,
25
+ "tier": getattr(t, "tier", "private") or "private",
26
+ "status": t.meta.get("status"),
27
+ "launch_priority": t.meta.get("launch_priority"),
28
+ "milestone_alignment": t.meta.get("milestone_alignment"),
29
+ "visibility": visibility.get(t.repo),
30
+ "blockers": list(t.meta.get("blockers") or []),
31
+ "next_up": list(t.meta.get("next_up") or []),
32
+ "rollup": {"open": opened, "closed": len(issues) - opened},
33
+ "issues": issues,
34
+ })
35
+ out["untracked"] = [
36
+ {"repo": repo, "issues": [_issue(r) for r in rows]}
37
+ for repo, rows in (untracked_by_repo or {}).items()
38
+ if rows
39
+ ]
40
+ return out
@@ -0,0 +1,48 @@
1
+ """Parse + write YAML frontmatter on markdown files. Body-preserving."""
2
+ import json
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Tuple
7
+
8
+ # Use [ \t]* (not \s*) so horizontal-only whitespace is consumed after ---,
9
+ # preserving any leading newline that is part of the body.
10
+ FRONTMATTER_RE = re.compile(r"^---[ \t]*\n(.*?)\n---[ \t]*\n(.*)$", re.DOTALL)
11
+
12
+
13
+ def parse_file(path: Path) -> Tuple[dict, str]:
14
+ """Parse markdown with optional YAML frontmatter. Returns (meta, body)."""
15
+ text = Path(path).read_text(encoding="utf-8")
16
+ match = FRONTMATTER_RE.match(text)
17
+ if not match:
18
+ return ({}, text)
19
+ meta = _yaml_to_dict(match.group(1))
20
+ return (meta, match.group(2))
21
+
22
+
23
+ def write_file(path: Path, meta: dict, body: str) -> None:
24
+ """Write markdown with frontmatter. Empty meta = body only."""
25
+ if not meta:
26
+ Path(path).write_text(body, encoding="utf-8")
27
+ return
28
+ yaml_text = _dict_to_yaml(meta)
29
+ Path(path).write_text(f"---\n{yaml_text}---\n{body}", encoding="utf-8")
30
+
31
+
32
+ def _yaml_to_dict(yaml_text: str) -> dict:
33
+ proc = subprocess.run(
34
+ ["yq", "-o=json", "."], input=yaml_text,
35
+ capture_output=True, text=True, check=True,
36
+ )
37
+ return json.loads(proc.stdout)
38
+
39
+
40
+ def _dict_to_yaml(d: dict) -> str:
41
+ proc = subprocess.run(
42
+ ["yq", "-P", "."], input=json.dumps(d),
43
+ capture_output=True, text=True, check=True,
44
+ )
45
+ out = proc.stdout
46
+ if not out.endswith("\n"):
47
+ out += "\n"
48
+ return out
@@ -0,0 +1,180 @@
1
+ """Local git queries + time helpers."""
2
+ import subprocess
3
+ from datetime import date, datetime, timedelta
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ def gap_seconds_to_label(seconds: int) -> str:
9
+ """'Nm ago' / 'Nh ago' / 'Nd ago'."""
10
+ minutes = seconds // 60
11
+ if minutes < 60:
12
+ return f"{minutes}m ago"
13
+ hours = minutes // 60
14
+ if hours < 24:
15
+ return f"{hours}h ago"
16
+ days = hours // 24
17
+ return f"{days}d ago"
18
+
19
+
20
+ def parse_iso_timestamp(s: str) -> datetime:
21
+ if "T" in s:
22
+ return datetime.strptime(s, "%Y-%m-%dT%H:%M")
23
+ return datetime.strptime(s, "%Y-%m-%d")
24
+
25
+
26
+ def current_branch(repo_path: Path) -> Optional[str]:
27
+ if not repo_path or not Path(repo_path).exists():
28
+ return None
29
+ proc = subprocess.run(
30
+ ["git", "-C", str(repo_path), "branch", "--show-current"],
31
+ capture_output=True, text=True,
32
+ )
33
+ if proc.returncode != 0:
34
+ return None
35
+ return proc.stdout.strip() or None
36
+
37
+
38
+ def has_uncommitted(repo_path: Path) -> bool:
39
+ if not repo_path or not Path(repo_path).exists():
40
+ return False
41
+ proc = subprocess.run(
42
+ ["git", "-C", str(repo_path), "status", "--short"],
43
+ capture_output=True, text=True,
44
+ )
45
+ return proc.returncode == 0 and bool(proc.stdout.strip())
46
+
47
+
48
+ def uncommitted_file_count(repo_path: Path) -> int:
49
+ if not repo_path or not Path(repo_path).exists():
50
+ return 0
51
+ proc = subprocess.run(
52
+ ["git", "-C", str(repo_path), "status", "--short"],
53
+ capture_output=True, text=True,
54
+ )
55
+ if proc.returncode != 0:
56
+ return 0
57
+ return len([l for l in proc.stdout.splitlines() if l.strip()])
58
+
59
+
60
+ def commits_ahead(branch_name: str, base: str, repo_path: Path) -> int:
61
+ if not repo_path or not Path(repo_path).exists():
62
+ return 0
63
+ proc = subprocess.run(
64
+ ["git", "-C", str(repo_path), "rev-list", "--count", f"{base}..{branch_name}"],
65
+ capture_output=True, text=True,
66
+ )
67
+ if proc.returncode != 0:
68
+ return 0
69
+ try:
70
+ return int(proc.stdout.strip())
71
+ except ValueError:
72
+ return 0
73
+
74
+
75
+ def branch_exists(branch_name: str, repo_path: Path) -> bool:
76
+ if not repo_path or not Path(repo_path).exists():
77
+ return False
78
+ proc = subprocess.run(
79
+ ["git", "-C", str(repo_path), "rev-parse", "--verify", branch_name],
80
+ capture_output=True, text=True,
81
+ )
82
+ return proc.returncode == 0
83
+
84
+
85
+ def _has_recent_commits(branch_name: str, repo_path: Path, hours: int = 24) -> bool:
86
+ if not repo_path or not Path(repo_path).exists():
87
+ return False
88
+ if not branch_exists(branch_name, repo_path):
89
+ return False
90
+ since = (datetime.now() - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S")
91
+ proc = subprocess.run(
92
+ ["git", "-C", str(repo_path), "log", branch_name,
93
+ f"--since={since}", "--pretty=format:%H"],
94
+ capture_output=True, text=True,
95
+ )
96
+ return proc.returncode == 0 and bool(proc.stdout.strip())
97
+
98
+
99
+ def branch_in_progress(branch_name: str, repo_path: Path) -> bool:
100
+ """Detect 'in-progress':
101
+ - It's the current branch AND has uncommitted changes, OR
102
+ - It has commits in the last 24 hours.
103
+ """
104
+ if not repo_path or not Path(repo_path).exists():
105
+ return False
106
+ if not branch_exists(branch_name, repo_path):
107
+ return False
108
+ cur = current_branch(repo_path)
109
+ if cur == branch_name and has_uncommitted(repo_path):
110
+ return True
111
+ return _has_recent_commits(branch_name, repo_path, hours=24)
112
+
113
+
114
+ def last_commit_date(branch_name: str, repo_path: Path) -> Optional[datetime]:
115
+ """Most recent commit timestamp on branch (naive)."""
116
+ if not repo_path or not Path(repo_path).exists():
117
+ return None
118
+ if not branch_exists(branch_name, repo_path):
119
+ return None
120
+ proc = subprocess.run(
121
+ ["git", "-C", str(repo_path), "log", "-1", branch_name, "--pretty=format:%cI"],
122
+ capture_output=True, text=True,
123
+ )
124
+ if proc.returncode != 0 or not proc.stdout.strip():
125
+ return None
126
+ try:
127
+ s = proc.stdout.strip().split("+")[0].split("Z")[0]
128
+ return datetime.fromisoformat(s)
129
+ except (ValueError, IndexError):
130
+ return None
131
+
132
+
133
+ def path_last_commit_date(rel_path: str, repo_path: Path) -> Optional[datetime]:
134
+ """Timestamp of the most recent commit touching `rel_path` (naive datetime)."""
135
+ if not repo_path or not Path(repo_path).exists():
136
+ return None
137
+ proc = subprocess.run(
138
+ ["git", "-C", str(repo_path), "log", "-1", "--pretty=format:%cI", "--", rel_path],
139
+ capture_output=True, text=True,
140
+ )
141
+ if proc.returncode != 0 or not proc.stdout.strip():
142
+ return None
143
+ try:
144
+ s = proc.stdout.strip().split("+")[0].split("Z")[0]
145
+ return datetime.fromisoformat(s)
146
+ except (ValueError, IndexError):
147
+ return None
148
+
149
+
150
+ def path_committed_since(rel_path: str, since: date, repo_path: Path) -> bool:
151
+ """True if `rel_path` has any commit on/around `since` or later (a datetime.date).
152
+
153
+ `git log --since` resolves to local midnight and can drop commits made on the
154
+ plan date itself (timezone-dependent) — the common case where a plan is written
155
+ and its files land the same day. We widen the window by one day so same-day
156
+ Modify commits are reliably counted; including the prior day is an acceptable
157
+ cost for a liveness heuristic.
158
+ """
159
+ if not repo_path or not Path(repo_path).exists():
160
+ return False
161
+ window_start = since - timedelta(days=1)
162
+ proc = subprocess.run(
163
+ ["git", "-C", str(repo_path), "log",
164
+ f"--since={window_start.isoformat()}", "--pretty=format:%H", "--", rel_path],
165
+ capture_output=True, text=True,
166
+ )
167
+ return proc.returncode == 0 and bool(proc.stdout.strip())
168
+
169
+
170
+ def git_mv(src_rel: str, dst_rel: str, repo_path: Path) -> bool:
171
+ """git-mv `src_rel` -> `dst_rel` (both repo-relative), creating the dest
172
+ directory first. Returns True on success. History-preserving."""
173
+ if not repo_path or not Path(repo_path).exists():
174
+ return False
175
+ (Path(repo_path) / dst_rel).parent.mkdir(parents=True, exist_ok=True)
176
+ proc = subprocess.run(
177
+ ["git", "-C", str(repo_path), "mv", src_rel, dst_rel],
178
+ capture_output=True, text=True,
179
+ )
180
+ return proc.returncode == 0