@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.
Files changed (53) hide show
  1. package/README.md +91 -13
  2. package/VERSION +1 -1
  3. package/bin/work-plan +23 -0
  4. package/package.json +2 -2
  5. package/skills/work-plan/SKILL.md +41 -8
  6. package/skills/work-plan/commands/auto_triage.py +243 -0
  7. package/skills/work-plan/commands/batch_slot.py +184 -0
  8. package/skills/work-plan/commands/brief.py +6 -6
  9. package/skills/work-plan/commands/canonicalize.py +71 -17
  10. package/skills/work-plan/commands/close.py +21 -6
  11. package/skills/work-plan/commands/coverage.py +100 -0
  12. package/skills/work-plan/commands/duplicates.py +21 -8
  13. package/skills/work-plan/commands/group.py +86 -10
  14. package/skills/work-plan/commands/handoff.py +17 -5
  15. package/skills/work-plan/commands/hygiene.py +29 -3
  16. package/skills/work-plan/commands/init.py +39 -7
  17. package/skills/work-plan/commands/init_repo.py +43 -1
  18. package/skills/work-plan/commands/list_cmd.py +34 -6
  19. package/skills/work-plan/commands/move.py +131 -0
  20. package/skills/work-plan/commands/new_track.py +100 -23
  21. package/skills/work-plan/commands/reconcile.py +175 -33
  22. package/skills/work-plan/commands/refresh_md.py +19 -6
  23. package/skills/work-plan/commands/set_field.py +17 -7
  24. package/skills/work-plan/commands/slot.py +20 -5
  25. package/skills/work-plan/commands/where_was_i.py +23 -5
  26. package/skills/work-plan/lib/config.py +6 -0
  27. package/skills/work-plan/lib/export_model.py +57 -2
  28. package/skills/work-plan/lib/github_state.py +54 -13
  29. package/skills/work-plan/lib/notes_readme.py +38 -0
  30. package/skills/work-plan/lib/prompts.py +34 -3
  31. package/skills/work-plan/lib/tracks.py +208 -18
  32. package/skills/work-plan/tests/test_auto_triage.py +351 -0
  33. package/skills/work-plan/tests/test_batch_slot.py +291 -0
  34. package/skills/work-plan/tests/test_close_tier.py +166 -0
  35. package/skills/work-plan/tests/test_config_shared.py +57 -0
  36. package/skills/work-plan/tests/test_coverage.py +192 -0
  37. package/skills/work-plan/tests/test_export.py +204 -1
  38. package/skills/work-plan/tests/test_export_command.py +2 -2
  39. package/skills/work-plan/tests/test_github_state.py +52 -14
  40. package/skills/work-plan/tests/test_group_apply.py +411 -0
  41. package/skills/work-plan/tests/test_init_repo.py +128 -0
  42. package/skills/work-plan/tests/test_init_shared.py +185 -0
  43. package/skills/work-plan/tests/test_list_sort.py +162 -0
  44. package/skills/work-plan/tests/test_move.py +240 -0
  45. package/skills/work-plan/tests/test_new_track.py +169 -4
  46. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  47. package/skills/work-plan/tests/test_prompts.py +121 -0
  48. package/skills/work-plan/tests/test_reconcile_move.py +154 -0
  49. package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
  50. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  51. package/skills/work-plan/tests/test_tracks.py +395 -1
  52. package/skills/work-plan/tests/test_where_was_i.py +135 -0
  53. 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
- track_name = positional[0] if positional else None
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
- track = find_track_by_name(choice, tracks)
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
- track = find_track_by_name(track_name, tracks)
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": t.meta.get("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": list(t.meta.get("next_up") or []),
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 gh (sequential). Unchanged semantics."""
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
- result = fetch_issue(repo, num)
47
- if result is not None:
48
- results.append(result)
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 export_model
76
- expects (assignees as [{login}], milestone as {title}|None). None for a null node.
77
- On success returns a dict with keys: number, title, state, assignees, milestone."""
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
- "assignees": assignees,
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
- def _gql_query(owner: str, name: str, numbers: list) -> str:
93
- fields = ("number title state assignees(first: 10) { nodes { login } } milestone { title }")
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) -> dict:
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 blank.
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 (no stdin) as no.
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 resolve_github_for_folder, resolve_local_path_for_folder
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
- notes_root = Path(cfg["notes_root"]).expanduser()
27
- if not notes_root.exists():
28
- return []
29
- return _walk(notes_root, cfg, include_archive=False)
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
- def find_track_by_name(name: str, tracks: list[Track],
42
- *, active_only: bool = False) -> Optional[Track]:
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
- Returns the single match or None. Used by every command that takes a track arg.
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
- return matching[0] if len(matching) == 1 else None
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 only."""
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
- out = []
63
- for md_path in sorted(notes_root.rglob("*.md")):
64
- if "archive" not in md_path.parts:
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
- if md_path.name.startswith((".", "_")):
207
+ local_path = Path(entry["local"]).expanduser()
208
+ if not is_valid_git_repo(local_path):
67
209
  continue
68
- out.append(_build_track(md_path, notes_root, cfg))
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
  )