@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.
- package/LICENSE +21 -0
- package/README.md +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +82 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +40 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +109 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- package/skills/work-plan/tests/test_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +251 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_tracks.py +56 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- 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
|