@stylusnexus/work-plan 2026.6.9 → 2026.6.10
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/README.md +91 -13
- package/VERSION +1 -1
- package/bin/work-plan +23 -0
- package/package.json +2 -2
- package/skills/work-plan/SKILL.md +41 -8
- package/skills/work-plan/commands/auto_triage.py +243 -0
- package/skills/work-plan/commands/batch_slot.py +184 -0
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/canonicalize.py +71 -17
- package/skills/work-plan/commands/close.py +21 -6
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +21 -8
- package/skills/work-plan/commands/group.py +86 -10
- package/skills/work-plan/commands/handoff.py +17 -5
- package/skills/work-plan/commands/hygiene.py +29 -3
- package/skills/work-plan/commands/init.py +39 -7
- package/skills/work-plan/commands/init_repo.py +43 -1
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/move.py +131 -0
- package/skills/work-plan/commands/new_track.py +100 -23
- package/skills/work-plan/commands/reconcile.py +175 -33
- package/skills/work-plan/commands/refresh_md.py +19 -6
- package/skills/work-plan/commands/set_field.py +17 -7
- package/skills/work-plan/commands/slot.py +20 -5
- package/skills/work-plan/commands/where_was_i.py +23 -5
- package/skills/work-plan/lib/config.py +6 -0
- package/skills/work-plan/lib/export_model.py +57 -2
- package/skills/work-plan/lib/github_state.py +54 -13
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +34 -3
- package/skills/work-plan/lib/tracks.py +208 -18
- package/skills/work-plan/tests/test_auto_triage.py +351 -0
- package/skills/work-plan/tests/test_batch_slot.py +291 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_export.py +204 -1
- package/skills/work-plan/tests/test_export_command.py +2 -2
- package/skills/work-plan/tests/test_github_state.py +52 -14
- package/skills/work-plan/tests/test_group_apply.py +411 -0
- package/skills/work-plan/tests/test_init_repo.py +128 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_move.py +240 -0
- package/skills/work-plan/tests/test_new_track.py +169 -4
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_prompts.py +121 -0
- package/skills/work-plan/tests/test_reconcile_move.py +154 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +395 -1
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- package/skills/work-plan/work_plan.py +38 -18
|
@@ -25,7 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
from typing import Optional
|
|
26
26
|
|
|
27
27
|
from lib.config import load_config, ConfigError
|
|
28
|
-
from lib.tracks import discover_tracks, find_track_by_name
|
|
28
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
29
29
|
from lib.prompts import prompt_input, parse_flags
|
|
30
30
|
from lib.github_state import fetch_issues, short_milestone
|
|
31
31
|
from lib.git_state import (
|
|
@@ -40,8 +40,18 @@ RULE_WIDTH = 57
|
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def run(args: list[str]) -> int:
|
|
43
|
-
flags, positional = parse_flags(args, {"--pick"})
|
|
44
|
-
|
|
43
|
+
flags, positional = parse_flags(args, {"--pick", "--repo"})
|
|
44
|
+
track_arg = positional[0] if positional else None
|
|
45
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
46
|
+
|
|
47
|
+
# Resolve track name and repo qualifier from <track>@<repo> syntax
|
|
48
|
+
track_name = track_arg
|
|
49
|
+
repo_qualifier = repo_flag
|
|
50
|
+
if track_arg:
|
|
51
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
52
|
+
track_name = name_from_arg
|
|
53
|
+
if repo_from_arg:
|
|
54
|
+
repo_qualifier = repo_from_arg
|
|
45
55
|
|
|
46
56
|
try:
|
|
47
57
|
cfg = load_config()
|
|
@@ -78,12 +88,20 @@ def run(args: list[str]) -> int:
|
|
|
78
88
|
return 1
|
|
79
89
|
track = active[idx]
|
|
80
90
|
else:
|
|
81
|
-
|
|
91
|
+
try:
|
|
92
|
+
track = find_track_by_name(choice, tracks, repo=repo_qualifier)
|
|
93
|
+
except AmbiguousTrackError as e:
|
|
94
|
+
print(str(e))
|
|
95
|
+
return 1
|
|
82
96
|
if not track:
|
|
83
97
|
print(f"No track matching '{choice}'.")
|
|
84
98
|
return 1
|
|
85
99
|
else:
|
|
86
|
-
|
|
100
|
+
try:
|
|
101
|
+
track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
|
|
102
|
+
except AmbiguousTrackError as e:
|
|
103
|
+
print(str(e))
|
|
104
|
+
return 1
|
|
87
105
|
if not track:
|
|
88
106
|
print(f"No track matching '{track_name}'.")
|
|
89
107
|
return 1
|
|
@@ -70,6 +70,12 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH,
|
|
|
70
70
|
return cfg
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def is_valid_git_repo(path: Path) -> bool:
|
|
74
|
+
"""Return True if path is a directory that contains a .git entry."""
|
|
75
|
+
p = Path(path)
|
|
76
|
+
return p.is_dir() and (p / ".git").exists()
|
|
77
|
+
|
|
78
|
+
|
|
73
79
|
def resolve_github_for_folder(folder_name: str, cfg: dict) -> Optional[str]:
|
|
74
80
|
entry = cfg.get("repos", {}).get(folder_name)
|
|
75
81
|
return entry.get("github") if entry else None
|
|
@@ -3,6 +3,55 @@ from lib.github_state import format_assignees, short_milestone
|
|
|
3
3
|
|
|
4
4
|
SCHEMA = 1
|
|
5
5
|
|
|
6
|
+
|
|
7
|
+
def milestone_sort_key(issue: dict, milestone_alignment=None):
|
|
8
|
+
"""Sort key for an issue dict (must have 'number' and 'milestone').
|
|
9
|
+
|
|
10
|
+
Returns (tier, milestone_label, number) so that:
|
|
11
|
+
0. issues matching milestone_alignment come first
|
|
12
|
+
1. issues with other non-null milestones come next, grouped by label
|
|
13
|
+
2. issues with null/empty milestone come last.
|
|
14
|
+
|
|
15
|
+
milestone may be a compact string (as from short_milestone) or None.
|
|
16
|
+
"""
|
|
17
|
+
ms = issue.get("milestone")
|
|
18
|
+
num = issue.get("number", 0) or 0
|
|
19
|
+
if ms is None or ms == "":
|
|
20
|
+
return (2, "", num)
|
|
21
|
+
if ms == milestone_alignment:
|
|
22
|
+
return (0, ms, num)
|
|
23
|
+
return (1, ms, num)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def group_issues_by_milestone(issues, milestone_alignment=None):
|
|
27
|
+
"""Partition sorted issues into [(label, [issue, ...]), ...].
|
|
28
|
+
|
|
29
|
+
label is the compact milestone string; None for the no-milestone group.
|
|
30
|
+
Groups are emitted in milestone_sort_key order. A single-group result
|
|
31
|
+
means all issues share the same milestone (or all lack one) — callers
|
|
32
|
+
can use this to decide whether to render section headings.
|
|
33
|
+
"""
|
|
34
|
+
if not issues:
|
|
35
|
+
return []
|
|
36
|
+
sorted_issues = sorted(issues,
|
|
37
|
+
key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
38
|
+
groups = []
|
|
39
|
+
current_label = None # sentinel — always differs from the first real label
|
|
40
|
+
current_group = []
|
|
41
|
+
for i in sorted_issues:
|
|
42
|
+
label = i.get("milestone") or None
|
|
43
|
+
if label != current_label:
|
|
44
|
+
if current_group:
|
|
45
|
+
groups.append((current_label, current_group))
|
|
46
|
+
current_label = label
|
|
47
|
+
current_group = [i]
|
|
48
|
+
else:
|
|
49
|
+
current_group.append(i)
|
|
50
|
+
if current_group:
|
|
51
|
+
groups.append((current_label, current_group))
|
|
52
|
+
return groups
|
|
53
|
+
|
|
54
|
+
|
|
6
55
|
def _issue(i: dict) -> dict:
|
|
7
56
|
state = (i.get("state") or "OPEN").lower()
|
|
8
57
|
return {
|
|
@@ -13,22 +62,28 @@ def _issue(i: dict) -> dict:
|
|
|
13
62
|
"milestone": short_milestone(i.get("milestone")) or None,
|
|
14
63
|
}
|
|
15
64
|
|
|
65
|
+
|
|
16
66
|
def build_export(tracks, issues_by_track, visibility, now: str,
|
|
17
67
|
untracked_by_repo=None) -> dict:
|
|
18
68
|
out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
|
|
19
69
|
for t in tracks:
|
|
20
70
|
issues = [_issue(i) for i in issues_by_track.get(t.name, [])]
|
|
71
|
+
milestone_alignment = t.meta.get("milestone_alignment")
|
|
72
|
+
issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
21
73
|
opened = sum(1 for i in issues if i["state"] == "open")
|
|
74
|
+
closed_nums = {i["number"] for i in issues if i["state"] == "closed"}
|
|
75
|
+
next_up = [n for n in (t.meta.get("next_up") or []) if n not in closed_nums]
|
|
22
76
|
out["tracks"].append({
|
|
23
77
|
"name": t.name,
|
|
24
78
|
"repo": t.repo,
|
|
25
79
|
"tier": getattr(t, "tier", "private") or "private",
|
|
26
80
|
"status": t.meta.get("status"),
|
|
27
81
|
"launch_priority": t.meta.get("launch_priority"),
|
|
28
|
-
"milestone_alignment":
|
|
82
|
+
"milestone_alignment": milestone_alignment,
|
|
29
83
|
"visibility": visibility.get(t.repo),
|
|
30
84
|
"blockers": list(t.meta.get("blockers") or []),
|
|
31
|
-
"next_up":
|
|
85
|
+
"next_up": next_up,
|
|
86
|
+
"depends_on": list(t.meta.get("depends_on") or []),
|
|
32
87
|
"rollup": {"open": opened, "closed": len(issues) - opened},
|
|
33
88
|
"issues": issues,
|
|
34
89
|
})
|
|
@@ -37,15 +37,23 @@ def fetch_issue(repo: str, number: int) -> Optional[dict]:
|
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def fetch_issues(repo: str, issue_numbers: Iterable[int]) -> list[dict]:
|
|
40
|
-
"""Fetch state of multiple issues via
|
|
40
|
+
"""Fetch state of multiple issues via batched GraphQL (full field set).
|
|
41
|
+
Falls back to per-issue `gh issue view` for any numbers the GraphQL query
|
|
42
|
+
didn't return (preserves existing behaviour for transient failures).
|
|
43
|
+
Returns a list in the same order as `issue_numbers` (skips not-found)."""
|
|
41
44
|
nums = list(issue_numbers)
|
|
42
45
|
if not nums:
|
|
43
46
|
return []
|
|
47
|
+
# Fast path: batched GraphQL with full field set
|
|
48
|
+
gql_results = fetch_repo_issues_graphql(repo, nums, fields=_GQL_FIELDS_FULL)
|
|
49
|
+
# Fall back to per-issue fetch for anything GraphQL missed
|
|
44
50
|
results = []
|
|
45
51
|
for num in nums:
|
|
46
|
-
|
|
47
|
-
if
|
|
48
|
-
|
|
52
|
+
issue = gql_results.get(num)
|
|
53
|
+
if issue is None:
|
|
54
|
+
issue = fetch_issue(repo, num)
|
|
55
|
+
if issue is not None:
|
|
56
|
+
results.append(issue)
|
|
49
57
|
return results
|
|
50
58
|
|
|
51
59
|
|
|
@@ -72,11 +80,15 @@ def fetch_issues_concurrent(jobs: Iterable[tuple], max_workers: int = MAX_FETCH_
|
|
|
72
80
|
|
|
73
81
|
|
|
74
82
|
def _normalize_gql_node(node) -> Optional[dict]:
|
|
75
|
-
"""Reshape a GraphQL issueOrPullRequest node into the REST-ish shape
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
"""Reshape a GraphQL issueOrPullRequest node into the REST-ish shape callers
|
|
84
|
+
expect (labels as [{name}], assignees as [{login}], milestone as {title}|None).
|
|
85
|
+
None for a null node.
|
|
86
|
+
On success returns a dict with keys: number, title, state, labels, milestone,
|
|
87
|
+
closedAt, body, url, updatedAt, assignees."""
|
|
78
88
|
if not node:
|
|
79
89
|
return None
|
|
90
|
+
labels = [{"name": l.get("name")} for l in
|
|
91
|
+
((node.get("labels") or {}).get("nodes") or []) if l.get("name")]
|
|
80
92
|
assignees = [{"login": a.get("login")} for a in
|
|
81
93
|
((node.get("assignees") or {}).get("nodes") or []) if a.get("login")]
|
|
82
94
|
ms = node.get("milestone")
|
|
@@ -84,13 +96,38 @@ def _normalize_gql_node(node) -> Optional[dict]:
|
|
|
84
96
|
"number": node.get("number"),
|
|
85
97
|
"title": node.get("title", ""),
|
|
86
98
|
"state": node.get("state", "OPEN"),
|
|
87
|
-
"
|
|
99
|
+
"labels": labels,
|
|
88
100
|
"milestone": {"title": ms["title"]} if ms and ms.get("title") else None,
|
|
101
|
+
"closedAt": node.get("closedAt"),
|
|
102
|
+
"body": node.get("body", ""),
|
|
103
|
+
"url": node.get("url", ""),
|
|
104
|
+
"updatedAt": node.get("updatedAt"),
|
|
105
|
+
"assignees": assignees,
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
# Shared GQL field set used by both export (lean) and fetch_issues (full).
|
|
110
|
+
# Kept as a module-level constant so _gql_query can parameterize at the call site.
|
|
111
|
+
_GQL_FIELDS_FULL = (
|
|
112
|
+
"number title state"
|
|
113
|
+
" labels(first: 20) { nodes { name } }"
|
|
114
|
+
" milestone { title }"
|
|
115
|
+
" closedAt body url updatedAt"
|
|
116
|
+
" assignees(first: 10) { nodes { login } }"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
_GQL_FIELDS_LEAN = (
|
|
120
|
+
"number title state"
|
|
121
|
+
" assignees(first: 10) { nodes { login } }"
|
|
122
|
+
" milestone { title }"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _gql_query(owner: str, name: str, numbers: list,
|
|
127
|
+
fields: str = _GQL_FIELDS_LEAN) -> str:
|
|
128
|
+
"""Build a batched GraphQL query for issueOrPullRequest nodes.
|
|
129
|
+
`fields` selects the GQL field set; _GQL_FIELDS_LEAN for export, _GQL_FIELDS_FULL
|
|
130
|
+
for fetch_issues (which needs labels, closedAt, body, url, updatedAt)."""
|
|
94
131
|
aliases = "\n".join(
|
|
95
132
|
f' i{n}: issueOrPullRequest(number: {int(n)}) {{ '
|
|
96
133
|
f'... on Issue {{ {fields} }} ... on PullRequest {{ {fields} }} }}'
|
|
@@ -100,10 +137,14 @@ def _gql_query(owner: str, name: str, numbers: list) -> str:
|
|
|
100
137
|
|
|
101
138
|
|
|
102
139
|
def fetch_repo_issues_graphql(repo: str, numbers, chunk: int = GQL_CHUNK,
|
|
103
|
-
max_workers: int = MAX_FETCH_WORKERS
|
|
140
|
+
max_workers: int = MAX_FETCH_WORKERS,
|
|
141
|
+
fields: str = _GQL_FIELDS_LEAN) -> dict:
|
|
104
142
|
"""Fetch exactly `numbers` from `repo` via batched GraphQL (issueOrPullRequest, so
|
|
105
143
|
PRs are included). Returns {number: normalized_issue} for those found. Never raises;
|
|
106
|
-
missing/null/errored numbers are simply omitted (caller may fall back per-issue).
|
|
144
|
+
missing/null/errored numbers are simply omitted (caller may fall back per-issue).
|
|
145
|
+
|
|
146
|
+
`fields` selects the GQL field set; _GQL_FIELDS_LEAN (default) for export,
|
|
147
|
+
_GQL_FIELDS_FULL for fetch_issues (which needs labels, closedAt, body, url)."""
|
|
107
148
|
try:
|
|
108
149
|
nums = list(dict.fromkeys(int(n) for n in numbers))
|
|
109
150
|
except (ValueError, TypeError):
|
|
@@ -116,7 +157,7 @@ def fetch_repo_issues_graphql(repo: str, numbers, chunk: int = GQL_CHUNK,
|
|
|
116
157
|
def _run(batch):
|
|
117
158
|
try:
|
|
118
159
|
proc = subprocess.run(
|
|
119
|
-
["gh", "api", "graphql", "-f", "query=" + _gql_query(owner, name, batch)],
|
|
160
|
+
["gh", "api", "graphql", "-f", "query=" + _gql_query(owner, name, batch, fields=fields)],
|
|
120
161
|
capture_output=True, text=True,
|
|
121
162
|
)
|
|
122
163
|
except Exception:
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""First-creation-only seed for .work-plan/README.md."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
README_CONTENT = """\
|
|
5
|
+
# .work-plan/
|
|
6
|
+
|
|
7
|
+
This folder contains **shared planning tracks** managed by [`work-plan`](https://github.com/stylusnexus/work-plan-toolkit).
|
|
8
|
+
|
|
9
|
+
Each `.md` file is a planning track: a lightweight document with YAML frontmatter that
|
|
10
|
+
points at GitHub issues and captures session notes. GitHub is canonical for issue state;
|
|
11
|
+
these files are the *planning context* that travels with the code.
|
|
12
|
+
|
|
13
|
+
## Shared vs. private tracks
|
|
14
|
+
|
|
15
|
+
Tracks in this folder are the **shared tier** — they're committed and sync via `git pull`.
|
|
16
|
+
To keep a track private (personal notes, not for teammates), use `--private` when creating
|
|
17
|
+
it and it will go into your local `notes_root` folder instead.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
Install the toolkit: [stylusnexus/work-plan-toolkit](https://github.com/stylusnexus/work-plan-toolkit)
|
|
22
|
+
Also available as a Claude/Codex plugin: [stylusnexus/agent-plugins](https://github.com/stylusnexus/agent-plugins)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def seed_readme(work_plan_dir: Path) -> bool:
|
|
27
|
+
"""Write README.md into work_plan_dir if and only if the dir was just created
|
|
28
|
+
(i.e. it did not previously contain a README.md). Returns True if written.
|
|
29
|
+
|
|
30
|
+
Rule: only seeds on first creation. If README.md already exists (even if empty),
|
|
31
|
+
leaves it alone. If the user deleted it inside an existing folder, does NOT
|
|
32
|
+
resurrect it — deletion is a respected opt-out.
|
|
33
|
+
"""
|
|
34
|
+
readme = work_plan_dir / "README.md"
|
|
35
|
+
if readme.exists():
|
|
36
|
+
return False
|
|
37
|
+
readme.write_text(README_CONTENT, encoding="utf-8")
|
|
38
|
+
return True
|
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
"""Shared CLI helpers: prompts and arg parsing."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _stdin_is_interactive() -> bool:
|
|
6
|
+
"""True only when stdin is a real terminal we can block on for a reply.
|
|
7
|
+
|
|
8
|
+
When the CLI is launched with stdin wired to a pipe or socket that stays
|
|
9
|
+
open but never delivers a line — e.g. the VS Code extension spawning
|
|
10
|
+
`work_plan.py` — `input()` blocks forever (no data, no EOF). A closed pipe
|
|
11
|
+
raises EOFError and is handled; an *idle open* one hangs. Guarding on
|
|
12
|
+
`isatty()` lets the prompt helpers fall back to their default instead of
|
|
13
|
+
deadlocking. Non-interactive callers should pass an explicit flag
|
|
14
|
+
(`--yes`, `--draft`) rather than rely on the prompt.
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
return bool(sys.stdin) and sys.stdin.isatty()
|
|
18
|
+
except (ValueError, AttributeError):
|
|
19
|
+
# stdin closed/detached, or replaced by an object without isatty.
|
|
20
|
+
return False
|
|
2
21
|
|
|
3
22
|
|
|
4
23
|
def prompt_input(message: str, default: str = "") -> str:
|
|
5
24
|
"""Print prompt and read a free-form line. Treats EOF (no stdin) as default.
|
|
6
25
|
|
|
7
|
-
Returns the stripped input, or `default` if EOF or
|
|
26
|
+
Returns the stripped input, or `default` if EOF, blank, or there is no
|
|
27
|
+
interactive terminal to read from.
|
|
8
28
|
"""
|
|
9
29
|
print(message)
|
|
30
|
+
if not _stdin_is_interactive():
|
|
31
|
+
print(f"(no interactive terminal — using default {default!r})")
|
|
32
|
+
return default
|
|
10
33
|
try:
|
|
11
34
|
line = input().strip()
|
|
12
35
|
except EOFError:
|
|
@@ -15,7 +38,12 @@ def prompt_input(message: str, default: str = "") -> str:
|
|
|
15
38
|
|
|
16
39
|
|
|
17
40
|
def prompt_lines() -> list[str]:
|
|
18
|
-
"""Read lines from stdin until blank line or EOF. Returns list of non-blank lines.
|
|
41
|
+
"""Read lines from stdin until blank line or EOF. Returns list of non-blank lines.
|
|
42
|
+
|
|
43
|
+
With no interactive terminal, returns an empty list rather than blocking.
|
|
44
|
+
"""
|
|
45
|
+
if not _stdin_is_interactive():
|
|
46
|
+
return []
|
|
19
47
|
out = []
|
|
20
48
|
try:
|
|
21
49
|
while True:
|
|
@@ -29,11 +57,14 @@ def prompt_lines() -> list[str]:
|
|
|
29
57
|
|
|
30
58
|
|
|
31
59
|
def prompt_yes_no(message: str = "Apply? [y/N]") -> bool:
|
|
32
|
-
"""Print prompt and read y/N. Treats EOF
|
|
60
|
+
"""Print prompt and read y/N. Treats EOF or no terminal as no.
|
|
33
61
|
|
|
34
62
|
Returns True only if user explicitly types 'y' (case-insensitive).
|
|
35
63
|
"""
|
|
36
64
|
print(message)
|
|
65
|
+
if not _stdin_is_interactive():
|
|
66
|
+
print("(no interactive terminal — defaulting to no)")
|
|
67
|
+
return False
|
|
37
68
|
try:
|
|
38
69
|
choice = input().strip().lower()
|
|
39
70
|
except EOFError:
|
|
@@ -1,10 +1,42 @@
|
|
|
1
|
-
"""Discover tracks under notes_root."""
|
|
1
|
+
"""Discover tracks under notes_root and shared .work-plan/ dirs."""
|
|
2
|
+
import sys
|
|
2
3
|
from dataclasses import dataclass, field
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
6
7
|
from lib.frontmatter import parse_file
|
|
7
|
-
from lib.config import
|
|
8
|
+
from lib.config import (
|
|
9
|
+
resolve_github_for_folder,
|
|
10
|
+
resolve_local_path_for_folder,
|
|
11
|
+
is_valid_git_repo,
|
|
12
|
+
)
|
|
13
|
+
from lib.git_state import parse_iso_timestamp
|
|
14
|
+
|
|
15
|
+
_PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def priority_rank(meta: dict) -> int:
|
|
19
|
+
"""Rank a track's launch_priority for ascending sort: P0<P1<P2<P3<anything.
|
|
20
|
+
|
|
21
|
+
Unknown / missing values (e.g. "—" or absent) sort after all known ranks.
|
|
22
|
+
"""
|
|
23
|
+
return _PRIORITY_RANK.get(meta.get("launch_priority"), len(_PRIORITY_RANK))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def recency_sort_key(meta: dict) -> float:
|
|
27
|
+
"""Sort key for last_touched recency (most recent first when sorted ascending).
|
|
28
|
+
|
|
29
|
+
Returns the negative POSIX timestamp so that a plain ascending sort puts the
|
|
30
|
+
most-recently-touched track first. Tracks with no (or unparseable)
|
|
31
|
+
last_touched return +inf, sorting them LAST.
|
|
32
|
+
"""
|
|
33
|
+
raw = meta.get("last_touched")
|
|
34
|
+
if not raw:
|
|
35
|
+
return float("inf")
|
|
36
|
+
try:
|
|
37
|
+
return -parse_iso_timestamp(raw).timestamp()
|
|
38
|
+
except (ValueError, TypeError):
|
|
39
|
+
return float("inf")
|
|
8
40
|
|
|
9
41
|
|
|
10
42
|
@dataclass
|
|
@@ -19,14 +51,37 @@ class Track:
|
|
|
19
51
|
local_path: Optional[Path] = None
|
|
20
52
|
meta: dict = field(default_factory=dict)
|
|
21
53
|
body: str = ""
|
|
54
|
+
tier: Optional[str] = None
|
|
22
55
|
|
|
23
56
|
|
|
24
57
|
def discover_tracks(cfg: dict) -> list[Track]:
|
|
25
|
-
"""Walk notes_root for active (non-archived) .md files
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
58
|
+
"""Walk notes_root for active (non-archived) .md files, then union with
|
|
59
|
+
shared tracks from each configured repo's .work-plan/ directory.
|
|
60
|
+
Shared wins on (repo, name) collisions.
|
|
61
|
+
"""
|
|
62
|
+
private = _discover_private_tracks(cfg, include_archive=False)
|
|
63
|
+
shared = _discover_shared_tracks(cfg, include_archive=False)
|
|
64
|
+
|
|
65
|
+
# Build lookup for shared tracks keyed by (repo, name)
|
|
66
|
+
shared_keys: dict = {}
|
|
67
|
+
for t in shared:
|
|
68
|
+
key = (t.repo, t.name)
|
|
69
|
+
shared_keys[key] = t
|
|
70
|
+
|
|
71
|
+
# Merge: private tracks that have no colliding shared track are kept
|
|
72
|
+
merged = list(shared)
|
|
73
|
+
for t in private:
|
|
74
|
+
key = (t.repo, t.name)
|
|
75
|
+
if key in shared_keys:
|
|
76
|
+
print(
|
|
77
|
+
f"WARN: track {t.name!r} (repo={t.repo!r}) exists in both shared"
|
|
78
|
+
f" ({shared_keys[key].path}) and private ({t.path}); using shared.",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
merged.append(t)
|
|
83
|
+
|
|
84
|
+
return merged
|
|
30
85
|
|
|
31
86
|
|
|
32
87
|
def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
|
|
@@ -38,37 +93,171 @@ def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
|
|
|
38
93
|
or (t.repo and t.repo.lower() == k)]
|
|
39
94
|
|
|
40
95
|
|
|
41
|
-
|
|
42
|
-
|
|
96
|
+
class AmbiguousTrackError(Exception):
|
|
97
|
+
"""Raised when a track name matches more than one track across repos."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, name: str, candidates: list[Track]):
|
|
100
|
+
self.name = name
|
|
101
|
+
self.candidates = candidates
|
|
102
|
+
repos = [f" {t.name} (repo: {t.repo or t.folder!r})" for t in candidates]
|
|
103
|
+
super().__init__(
|
|
104
|
+
f"Track {name!r} is ambiguous — found in {len(candidates)} repos:\n"
|
|
105
|
+
+ "\n".join(repos)
|
|
106
|
+
+ f"\nUse --repo=<key> or '{name}@<repo>' to disambiguate."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_track_repo_arg(arg: str) -> tuple:
|
|
111
|
+
"""Split 'trackname@repokey' into (trackname, repokey); return (arg, None) if no @."""
|
|
112
|
+
if "@" in arg:
|
|
113
|
+
name, _, repo = arg.rpartition("@")
|
|
114
|
+
return (name, repo) if name else (arg, None)
|
|
115
|
+
return (arg, None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def find_track_by_name(
|
|
119
|
+
name: str, tracks: list[Track],
|
|
120
|
+
*, active_only: bool = False, repo: Optional[str] = None
|
|
121
|
+
) -> Optional[Track]:
|
|
43
122
|
"""Find a single Track matching `name` (filename stem OR frontmatter `track`).
|
|
44
123
|
|
|
124
|
+
If repo is given, first filter to tracks matching that repo (folder key or
|
|
125
|
+
GitHub slug, case-insensitive). Then find a single name match.
|
|
126
|
+
|
|
45
127
|
If active_only=True, only considers tracks with status active/in-progress/blocked.
|
|
46
|
-
|
|
128
|
+
|
|
129
|
+
Returns the single match or None (0 matches).
|
|
130
|
+
Raises AmbiguousTrackError if 2+ matches remain after filtering.
|
|
47
131
|
"""
|
|
48
132
|
candidates = tracks
|
|
133
|
+
if repo:
|
|
134
|
+
candidates = filter_tracks_by_repo(candidates, repo)
|
|
49
135
|
if active_only:
|
|
50
136
|
candidates = [t for t in candidates if t.has_frontmatter
|
|
51
137
|
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
52
138
|
matching = [t for t in candidates if t.has_frontmatter
|
|
53
139
|
and (t.name == name or t.meta.get("track") == name)]
|
|
54
|
-
|
|
140
|
+
if len(matching) <= 1:
|
|
141
|
+
return matching[0] if matching else None
|
|
142
|
+
raise AmbiguousTrackError(name, matching)
|
|
55
143
|
|
|
56
144
|
|
|
57
145
|
def discover_archived_tracks(cfg: dict) -> list[Track]:
|
|
58
|
-
"""Walk notes_root for archived .md files
|
|
146
|
+
"""Walk notes_root for archived .md files, and also scan each repo's
|
|
147
|
+
.work-plan/archive/ for shared archived tracks.
|
|
148
|
+
|
|
149
|
+
Deduplicates by (repo, name): shared wins over private, same as
|
|
150
|
+
discover_tracks for active tracks.
|
|
151
|
+
"""
|
|
152
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
153
|
+
private_archived: list[Track] = []
|
|
154
|
+
if notes_root.exists():
|
|
155
|
+
for md_path in sorted(notes_root.rglob("*.md")):
|
|
156
|
+
if "archive" not in md_path.parts:
|
|
157
|
+
continue
|
|
158
|
+
if md_path.name.startswith((".", "_")):
|
|
159
|
+
continue
|
|
160
|
+
private_archived.append(_build_track(md_path, notes_root, cfg))
|
|
161
|
+
|
|
162
|
+
shared_archived = _discover_shared_tracks(cfg, include_archive=True,
|
|
163
|
+
archive_only=True)
|
|
164
|
+
|
|
165
|
+
# Build lookup for shared tracks keyed by (repo, name)
|
|
166
|
+
shared_keys: dict = {}
|
|
167
|
+
for t in shared_archived:
|
|
168
|
+
key = (t.repo, t.name)
|
|
169
|
+
shared_keys[key] = t
|
|
170
|
+
|
|
171
|
+
# Merge: shared wins on collision
|
|
172
|
+
merged = list(shared_archived)
|
|
173
|
+
for t in private_archived:
|
|
174
|
+
key = (t.repo, t.name)
|
|
175
|
+
if key in shared_keys:
|
|
176
|
+
print(
|
|
177
|
+
f"WARN: archived track {t.name!r} (repo={t.repo!r}) exists in"
|
|
178
|
+
f" both shared ({shared_keys[key].path}) and private"
|
|
179
|
+
f" ({t.path}); using shared.",
|
|
180
|
+
file=sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
merged.append(t)
|
|
184
|
+
|
|
185
|
+
return merged
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Private helpers
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def _discover_private_tracks(cfg: dict, include_archive: bool) -> list[Track]:
|
|
59
193
|
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
60
194
|
if not notes_root.exists():
|
|
61
195
|
return []
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
196
|
+
return _walk(notes_root, cfg, include_archive=include_archive)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _discover_shared_tracks(cfg: dict, include_archive: bool = False,
|
|
200
|
+
archive_only: bool = False) -> list[Track]:
|
|
201
|
+
"""Walk each configured repo's local clone .work-plan/ directory."""
|
|
202
|
+
out: list[Track] = []
|
|
203
|
+
repos = cfg.get("repos", {})
|
|
204
|
+
for folder_key, entry in repos.items():
|
|
205
|
+
if not entry or not entry.get("local"):
|
|
65
206
|
continue
|
|
66
|
-
|
|
207
|
+
local_path = Path(entry["local"]).expanduser()
|
|
208
|
+
if not is_valid_git_repo(local_path):
|
|
67
209
|
continue
|
|
68
|
-
|
|
210
|
+
github_repo = entry.get("github")
|
|
211
|
+
notes_dir = local_path / ".work-plan"
|
|
212
|
+
if not notes_dir.is_dir():
|
|
213
|
+
continue
|
|
214
|
+
for md_path in sorted(notes_dir.rglob("*.md")):
|
|
215
|
+
# Skip dotfiles and README
|
|
216
|
+
if md_path.name.startswith(".") or md_path.name == "README.md":
|
|
217
|
+
continue
|
|
218
|
+
in_archive = "archive" in md_path.relative_to(notes_dir).parts
|
|
219
|
+
if archive_only and not in_archive:
|
|
220
|
+
continue
|
|
221
|
+
if not include_archive and in_archive:
|
|
222
|
+
continue
|
|
223
|
+
out.append(_build_shared_track(
|
|
224
|
+
md_path, folder_key, github_repo, local_path
|
|
225
|
+
))
|
|
69
226
|
return out
|
|
70
227
|
|
|
71
228
|
|
|
229
|
+
def _build_shared_track(md_path: Path, folder_key: str,
|
|
230
|
+
github_repo: Optional[str], local_path: Path) -> Track:
|
|
231
|
+
"""Build a Track from a shared .work-plan/ markdown file."""
|
|
232
|
+
meta, body = parse_file(md_path)
|
|
233
|
+
has_fm = bool(meta)
|
|
234
|
+
|
|
235
|
+
# Single-owner rule: if frontmatter disagrees with folder config, warn and
|
|
236
|
+
# use the folder's configured github repo (never the frontmatter value).
|
|
237
|
+
if has_fm and meta.get("github", {}).get("repo"):
|
|
238
|
+
fm_repo = meta["github"]["repo"]
|
|
239
|
+
if fm_repo != github_repo:
|
|
240
|
+
print(
|
|
241
|
+
f"WARN: shared track {md_path.name!r} frontmatter github.repo"
|
|
242
|
+
f" differs from folder config; using folder {github_repo!r}",
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return Track(
|
|
247
|
+
path=md_path,
|
|
248
|
+
name=md_path.stem,
|
|
249
|
+
has_frontmatter=has_fm,
|
|
250
|
+
needs_init=False,
|
|
251
|
+
needs_filing=False,
|
|
252
|
+
repo=github_repo,
|
|
253
|
+
folder=folder_key,
|
|
254
|
+
local_path=local_path,
|
|
255
|
+
meta=meta,
|
|
256
|
+
body=body,
|
|
257
|
+
tier="shared",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
72
261
|
def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
|
|
73
262
|
out = []
|
|
74
263
|
for md_path in sorted(notes_root.rglob("*.md")):
|
|
@@ -80,7 +269,7 @@ def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
|
|
|
80
269
|
return out
|
|
81
270
|
|
|
82
271
|
|
|
83
|
-
def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> Track:
|
|
272
|
+
def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> "Track":
|
|
84
273
|
meta, body = parse_file(md_path)
|
|
85
274
|
has_fm = bool(meta)
|
|
86
275
|
rel = md_path.relative_to(notes_root)
|
|
@@ -106,4 +295,5 @@ def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> Track:
|
|
|
106
295
|
local_path=local,
|
|
107
296
|
meta=meta,
|
|
108
297
|
body=body,
|
|
298
|
+
tier="private",
|
|
109
299
|
)
|