@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
@@ -0,0 +1,184 @@
1
+ """batch-slot subcommand — slot multiple issues into a track at once."""
2
+ import json
3
+ import subprocess
4
+
5
+ from lib.config import load_config, ConfigError
6
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
7
+ from lib.frontmatter import write_file
8
+ from lib.write_guard import needs_confirm, make_token, valid_token
9
+ from lib.prompts import parse_flags
10
+
11
+
12
+ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
13
+ """Active tracks in `repo` (excluding `target_name`) whose frontmatter
14
+ already lists `issue_num`. Shared with slot.py."""
15
+ owners = []
16
+ for t in tracks:
17
+ if not t.has_frontmatter or t.name == target_name or t.repo != repo:
18
+ continue
19
+ if t.meta.get("status") not in ("active", "in-progress", "blocked"):
20
+ continue
21
+ if issue_num in (t.meta.get("github", {}).get("issues") or []):
22
+ owners.append(t)
23
+ return owners
24
+
25
+
26
+ def run(args: list[str]) -> int:
27
+ flags, positional = parse_flags(
28
+ args, {"--confirm", "--move", "--no-move", "--repo"}
29
+ )
30
+
31
+ if len(positional) < 2:
32
+ print(
33
+ "usage: work_plan.py batch-slot <issue-num>... <track | track@repo>"
34
+ " [--repo=<key>]"
35
+ )
36
+ return 2
37
+
38
+ # Last positional is the track; everything before is an issue number.
39
+ *issue_strs, target_arg = positional
40
+
41
+ issue_nums: list[int] = []
42
+ for s in issue_strs:
43
+ try:
44
+ issue_nums.append(int(s))
45
+ except ValueError:
46
+ print(f"ERROR: '{s}' is not an issue number.")
47
+ return 2
48
+
49
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
50
+
51
+ target_name = target_arg
52
+ repo_qualifier = repo_flag
53
+ if target_arg:
54
+ name_from_arg, repo_from_arg = parse_track_repo_arg(target_arg)
55
+ target_name = name_from_arg
56
+ if repo_from_arg:
57
+ repo_qualifier = repo_from_arg
58
+
59
+ if "--move" in flags and "--no-move" in flags:
60
+ print("ERROR: --move and --no-move are mutually exclusive.")
61
+ return 2
62
+
63
+ try:
64
+ cfg = load_config()
65
+ except ConfigError as e:
66
+ print(f"ERROR: {e}")
67
+ return 1
68
+
69
+ tracks = discover_tracks(cfg)
70
+
71
+ try:
72
+ target = find_track_by_name(
73
+ target_name, tracks, active_only=True, repo=repo_qualifier
74
+ )
75
+ except AmbiguousTrackError as e:
76
+ print(str(e))
77
+ return 1
78
+ if not target:
79
+ print(f"No active track matching '{target_name}'.")
80
+ return 1
81
+
82
+ # Confirm gate — fire once for the whole batch.
83
+ confirm = flags.get("--confirm")
84
+ if target.repo and needs_confirm(target.repo, cfg) and not (
85
+ isinstance(confirm, str) and valid_token(confirm, target.repo, target.name)
86
+ ):
87
+ print(
88
+ json.dumps(
89
+ {
90
+ "needs_confirm": True,
91
+ "reason": (
92
+ f"{target.repo} is PUBLIC (or visibility unknown); "
93
+ f"batch-slotting {len(issue_nums)} issue(s) will be"
94
+ f" written there."
95
+ ),
96
+ "token": make_token(target.repo, target.name),
97
+ }
98
+ )
99
+ )
100
+ return 0
101
+
102
+ do_move = "--move" in flags
103
+
104
+ # Collect source tracks that need issue removal (consolidated per source).
105
+ source_removals: dict[str, tuple] = {} # source_name -> (source_track, set[issue_num])
106
+
107
+ issues = list(target.meta.get("github", {}).get("issues") or [])
108
+ skipped: list[int] = []
109
+ slotted: list[int] = []
110
+
111
+ for issue_num in issue_nums:
112
+ if issue_num in issues:
113
+ skipped.append(issue_num)
114
+ continue
115
+
116
+ # Milestone mismatch check (non-blocking warning).
117
+ proc = subprocess.run(
118
+ ["gh", "issue", "view", str(issue_num),
119
+ "--repo", target.repo, "--json", "milestone"],
120
+ capture_output=True, text=True,
121
+ )
122
+ if proc.returncode == 0:
123
+ info = json.loads(proc.stdout)
124
+ m = info.get("milestone", {})
125
+ if (
126
+ m and m.get("title")
127
+ and m["title"] != target.meta.get("milestone_alignment")
128
+ ):
129
+ print(
130
+ f"⚠ #{issue_num} is on milestone '{m['title']}', "
131
+ f"track '{target.name}' aligned to"
132
+ f" '{target.meta.get('milestone_alignment')}'."
133
+ )
134
+
135
+ # Prior-owner detection.
136
+ sources = _find_prior_owners(
137
+ issue_num, target.repo, target.name, tracks
138
+ )
139
+
140
+ issues.append(issue_num)
141
+ slotted.append(issue_num)
142
+
143
+ if sources and do_move:
144
+ for src in sources:
145
+ if src.name not in source_removals:
146
+ source_removals[src.name] = (src, set())
147
+ source_removals[src.name][1].add(issue_num)
148
+ elif sources and not do_move:
149
+ names = ", ".join(f"'{t.name}'" for t in sources)
150
+ print(
151
+ f"ℹ #{issue_num} still listed in {names}"
152
+ f" — re-run with --move to relocate."
153
+ )
154
+
155
+ if not slotted:
156
+ if skipped:
157
+ print(
158
+ f"All {len(skipped)} issue(s) already in track"
159
+ f" '{target.name}'."
160
+ )
161
+ return 0
162
+
163
+ # Write source tracks (consolidated removals).
164
+ if do_move:
165
+ for src_name, (src, removals) in source_removals.items():
166
+ src_issues = [
167
+ n for n in (src.meta.get("github", {}).get("issues") or [])
168
+ if n not in removals
169
+ ]
170
+ src.meta.setdefault("github", {})["issues"] = src_issues
171
+ write_file(src.path, src.meta, src.body)
172
+ removed_str = ", ".join(f"#{n}" for n in sorted(removals))
173
+ print(f" ✓ Removed {removed_str} from '{src_name}'.")
174
+
175
+ # Write target track once.
176
+ target.meta.setdefault("github", {})["issues"] = sorted(issues)
177
+ write_file(target.path, target.meta, target.body)
178
+
179
+ slotted_str = ", ".join(f"#{n}" for n in slotted)
180
+ print(f"✓ Slotted {slotted_str} into '{target.name}'.")
181
+ if skipped:
182
+ skipped_str = ", ".join(f"#{n}" for n in skipped)
183
+ print(f"ℹ Skipped (already in track): {skipped_str}.")
184
+ return 0
@@ -3,7 +3,10 @@ from datetime import datetime
3
3
  from pathlib import Path
4
4
 
5
5
  from lib.config import load_config, ConfigError
6
- from lib.tracks import discover_tracks, discover_archived_tracks, filter_tracks_by_repo
6
+ from lib.tracks import (
7
+ discover_tracks, discover_archived_tracks, filter_tracks_by_repo,
8
+ priority_rank, recency_sort_key,
9
+ )
7
10
  from lib.github_state import fetch_issues, extract_priority, short_milestone
8
11
  from lib.prompts import parse_flags
9
12
  from lib.git_state import (
@@ -193,11 +196,8 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
193
196
  return gap_seconds_to_label(int(gs))
194
197
 
195
198
  in_prog_rank = 0 if operational_status == "in-progress" else 1
196
- pri_rank = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(meta.get("launch_priority", "P3"), 3)
197
- recency_key = (
198
- -parse_iso_timestamp(meta["last_touched"]).timestamp()
199
- if meta.get("last_touched") else 0
200
- )
199
+ pri_rank = priority_rank(meta)
200
+ recency_key = recency_sort_key(meta)
201
201
 
202
202
  return {
203
203
  "name": meta.get("track", track.name),
@@ -7,7 +7,7 @@ ONLY this table (skipping narrative tables in the existing body).
7
7
  Use --all to canonicalize every active track that doesn't yet have one.
8
8
  """
9
9
  from lib.config import load_config, ConfigError
10
- from lib.tracks import discover_tracks, find_track_by_name
10
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
11
11
  from lib.github_state import fetch_issues, state_to_status_label, format_assignees
12
12
  from lib.frontmatter import write_file
13
13
  from lib.status_table import CANONICAL_MARKER, find_canonical_status_tables, render_issue_row
@@ -15,15 +15,24 @@ from lib.prompts import parse_flags
15
15
 
16
16
 
17
17
  def run(args: list[str]) -> int:
18
- flags, positional = parse_flags(args, {"--all", "--force"})
18
+ flags, positional = parse_flags(args, {"--all", "--force", "--repo"})
19
19
  do_all = flags.get("--all", False)
20
20
  force = flags.get("--force", False)
21
- track_name = positional[0] if positional else None
21
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
22
+ track_arg = positional[0] if positional else None
22
23
 
23
- if not do_all and not track_name:
24
- print("usage: work_plan.py canonicalize <track-name> | --all [--force]")
24
+ if not do_all and not track_arg:
25
+ print("usage: work_plan.py canonicalize <track-name> | --all [--force] [--repo=<key>]")
25
26
  return 2
26
27
 
28
+ track_name = track_arg
29
+ repo_qualifier = repo_flag
30
+ if track_arg:
31
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
32
+ track_name = name_from_arg
33
+ if repo_from_arg:
34
+ repo_qualifier = repo_from_arg
35
+
27
36
  try:
28
37
  cfg = load_config()
29
38
  except ConfigError as e:
@@ -35,8 +44,16 @@ def run(args: list[str]) -> int:
35
44
  if do_all:
36
45
  targets = [t for t in tracks if t.has_frontmatter
37
46
  and t.meta.get("status") in ("active", "in-progress", "blocked")]
47
+ if repo_qualifier:
48
+ from lib.tracks import filter_tracks_by_repo
49
+ targets = filter_tracks_by_repo(targets, repo_qualifier)
38
50
  else:
39
- target = find_track_by_name(track_name, tracks, active_only=True)
51
+ try:
52
+ target = find_track_by_name(track_name, tracks, active_only=True,
53
+ repo=repo_qualifier)
54
+ except AmbiguousTrackError as e:
55
+ print(str(e))
56
+ return 1
40
57
  if not target:
41
58
  print(f"No active track matching '{track_name}'.")
42
59
  return 1
@@ -60,6 +77,7 @@ def run(args: list[str]) -> int:
60
77
 
61
78
  new_body = _insert_canonical_table(
62
79
  track.body, issue_nums, issues_by_num, replace=force,
80
+ milestone_alignment=track.meta.get("milestone_alignment"),
63
81
  )
64
82
  write_file(track.path, track.meta, new_body)
65
83
  print(f" ✓ {track.name}: canonical table added/refreshed ({len(issue_nums)} issues)")
@@ -71,9 +89,10 @@ def run(args: list[str]) -> int:
71
89
 
72
90
 
73
91
  def _insert_canonical_table(body: str, issue_nums: list[int],
74
- issues_by_num: dict, replace: bool = False) -> str:
92
+ issues_by_num: dict, replace: bool = False,
93
+ milestone_alignment=None) -> str:
75
94
  """Insert (or replace) a canonical table at the top of the body."""
76
- table_md = _render_canonical_table(issue_nums, issues_by_num)
95
+ table_md = _render_canonical_table(issue_nums, issues_by_num, milestone_alignment)
77
96
 
78
97
  if replace:
79
98
  # Strip existing canonical block (marker + heading + table + separator)
@@ -85,22 +104,57 @@ def _insert_canonical_table(body: str, issue_nums: list[int],
85
104
  return leading_whitespace + table_md + "\n---\n\n" + body_stripped
86
105
 
87
106
 
88
- def _render_canonical_table(issue_nums: list[int], issues_by_num: dict) -> str:
107
+ def _render_canonical_table(issue_nums: list[int], issues_by_num: dict,
108
+ milestone_alignment=None) -> str:
89
109
  lines = [
90
110
  "## Issues (canonical)",
91
111
  "",
92
112
  f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
93
113
  "",
94
- "| # | Title | Assignee | Status |",
95
- "|---|---|---|---|",
96
114
  ]
115
+
116
+ # Build a normalized issue list with compact milestone strings.
117
+ from lib.github_state import short_milestone
118
+ norm_issues = []
97
119
  for num in sorted(issue_nums):
98
- i = issues_by_num.get(num, {})
99
- lines.append(render_issue_row(
100
- num, i.get("title", "(not fetched)"),
101
- format_assignees(i), state_to_status_label(i.get("state")),
102
- ))
103
- lines.append("")
120
+ gh = issues_by_num.get(num, {})
121
+ ms = short_milestone(gh.get("milestone")) or None
122
+ norm_issues.append({"number": num, "milestone": ms, "_gh": gh})
123
+
124
+ from lib.export_model import group_issues_by_milestone
125
+ groups = group_issues_by_milestone(norm_issues, milestone_alignment)
126
+
127
+ if len(groups) <= 1:
128
+ # Single milestone group (or all null) — render flat, same as before.
129
+ lines.append("| # | Title | Assignee | Status |")
130
+ lines.append("|---|---|---|---|")
131
+ for num in sorted(issue_nums):
132
+ i = issues_by_num.get(num, {})
133
+ lines.append(render_issue_row(
134
+ num, i.get("title", "(not fetched)"),
135
+ format_assignees(i), state_to_status_label(i.get("state")),
136
+ ))
137
+ lines.append("")
138
+ return "\n".join(lines)
139
+
140
+ # Multiple milestone groups — render with section headings.
141
+ for label, issues in groups:
142
+ if label:
143
+ heading = f"{label} ({len(issues)})"
144
+ else:
145
+ heading = f"No milestone ({len(issues)})"
146
+ lines.append(f"### {heading}")
147
+ lines.append("")
148
+ lines.append("| # | Title | Assignee | Status |")
149
+ lines.append("|---|---|---|---|")
150
+ for norm in issues:
151
+ num = norm["number"]
152
+ i = norm["_gh"]
153
+ lines.append(render_issue_row(
154
+ num, i.get("title", "(not fetched)"),
155
+ format_assignees(i), state_to_status_label(i.get("state")),
156
+ ))
157
+ lines.append("")
104
158
  return "\n".join(lines)
105
159
 
106
160
 
@@ -4,7 +4,7 @@ import shutil
4
4
  from pathlib import Path
5
5
 
6
6
  from lib.config import load_config, ConfigError
7
- from lib.tracks import discover_tracks, find_track_by_name
7
+ from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
8
8
  from lib.frontmatter import write_file
9
9
  from lib.write_guard import needs_confirm, make_token, valid_token
10
10
  from lib.prompts import parse_flags
@@ -15,13 +15,17 @@ VALID_STATES = {"shipped", "parked", "abandoned"}
15
15
  def run(args: list[str]) -> int:
16
16
  # --confirm uses equals form: --confirm=<token>
17
17
  # --state and --note also use equals form: --state=shipped, --note=...
18
- flags, positional = parse_flags(args, {"--state", "--note", "--confirm"})
18
+ # --repo uses equals form: --repo=<key>
19
+ flags, positional = parse_flags(args, {"--state", "--note", "--confirm", "--repo"})
19
20
 
20
21
  if not positional:
21
- print("usage: work_plan.py close <track-name> --state=shipped|parked|abandoned [--note=<text>] [--confirm=<token>]")
22
+ print("usage: work_plan.py close <track-name> --state=shipped|parked|abandoned [--note=<text>] [--confirm=<token>] [--repo=<key>]")
22
23
  return 2
23
24
 
24
- track_name = positional[0]
25
+ track_arg = positional[0]
26
+ name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
27
+ track_name = name_from_arg
28
+ repo_qualifier = repo_from_arg or (flags.get("--repo") if flags.get("--repo") is not True else None)
25
29
 
26
30
  # Validate --state (required)
27
31
  end_state = flags.get("--state")
@@ -39,7 +43,11 @@ def run(args: list[str]) -> int:
39
43
  return 1
40
44
 
41
45
  tracks = discover_tracks(cfg)
42
- track = find_track_by_name(track_name, tracks)
46
+ try:
47
+ track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
48
+ except AmbiguousTrackError as e:
49
+ print(str(e))
50
+ return 1
43
51
  if not track:
44
52
  print(f"No track matching '{track_name}'.")
45
53
  return 1
@@ -79,5 +87,12 @@ def run(args: list[str]) -> int:
79
87
  archive_dir.mkdir(parents=True, exist_ok=True)
80
88
  dest = archive_dir / track.path.name
81
89
  shutil.move(str(track.path), str(dest))
82
- print(f"✓ '{track.name}' marked {end_state}, moved to {dest.relative_to(notes_root)}")
90
+ # Use relative path from tier root; fall back to absolute if outside notes_root
91
+ try:
92
+ display = dest.relative_to(notes_root)
93
+ except ValueError:
94
+ display = dest
95
+ print(f"✓ '{track.name}' marked {end_state}, moved to {display}")
96
+ if getattr(track, "tier", None) == "shared":
97
+ print(" ↑ shared track — commit + push to share this archive with teammates.")
83
98
  return 0
@@ -0,0 +1,100 @@
1
+ """coverage subcommand: report open issues not referenced by any track.
2
+
3
+ Read-only. Fetches live from gh — no cache. Use --repo=<key> to scope to
4
+ one repo; omit for all configured repos. Use --list to print untracked
5
+ issue titles. Use --limit=N to control how many are shown (default 20).
6
+ """
7
+ from lib.config import load_config, ConfigError
8
+ from lib.tracks import discover_tracks
9
+ from lib.github_state import fetch_open_issues
10
+ from lib.prompts import parse_flags
11
+
12
+
13
+ def run(args: list[str]) -> int:
14
+ flags, _ = parse_flags(args, {"--list", "--repo"})
15
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
16
+ show_list = bool(flags.get("--list"))
17
+
18
+ limit = 20
19
+ for a in args:
20
+ if a.startswith("--limit="):
21
+ try:
22
+ limit = int(a.split("=", 1)[1])
23
+ except ValueError:
24
+ print("ERROR: --limit must be an integer.")
25
+ return 2
26
+
27
+ try:
28
+ cfg = load_config()
29
+ except ConfigError as e:
30
+ print(f"ERROR: {e}")
31
+ return 1
32
+
33
+ repos_cfg = cfg.get("repos", {})
34
+
35
+ if repo_flag:
36
+ if repo_flag not in repos_cfg:
37
+ print(f"ERROR: repo folder '{repo_flag}' not in config.yml.")
38
+ return 1
39
+ folders = [repo_flag]
40
+ else:
41
+ folders = list(repos_cfg.keys())
42
+
43
+ if not folders:
44
+ print("ERROR: no repos configured in config.yml.")
45
+ return 1
46
+
47
+ tracks = discover_tracks(cfg)
48
+
49
+ # Build per-repo set of tracked issue numbers across all tracks.
50
+ tracked_by_repo: dict[str, set] = {}
51
+ for t in tracks:
52
+ if not t.repo or not t.has_frontmatter:
53
+ continue
54
+ nums = t.meta.get("github", {}).get("issues") or []
55
+ tracked_by_repo.setdefault(t.repo, set()).update(nums)
56
+
57
+ any_output = False
58
+ for folder in folders:
59
+ repo = repos_cfg[folder].get("github")
60
+ if not repo:
61
+ continue
62
+
63
+ print(f"Fetching open issues for {repo}...")
64
+ open_issues = fetch_open_issues(repo)
65
+ tracked = tracked_by_repo.get(repo, set())
66
+
67
+ untracked = [i for i in open_issues if i.get("number") not in tracked]
68
+ total = len(open_issues)
69
+ n_untracked = len(untracked)
70
+ n_tracked = total - n_untracked
71
+ pct_tracked = round(100 * n_tracked / total) if total else 0
72
+ pct_untracked = 100 - pct_tracked if total else 0
73
+
74
+ print()
75
+ print(f"{folder} ({repo}):")
76
+ print(f" Open issues: {total}")
77
+ if total == 0:
78
+ print(" No open issues.")
79
+ else:
80
+ print(f" In a track: {n_tracked} ({pct_tracked}%)")
81
+ if n_untracked == 0:
82
+ print(" Untracked: 0 — full coverage!")
83
+ else:
84
+ print(f" Untracked: {n_untracked} ({pct_untracked}%)")
85
+ if show_list:
86
+ shown = untracked[:limit]
87
+ for i in shown:
88
+ num = i.get("number", "?")
89
+ title = i.get("title", "")
90
+ print(f" #{num} {title}")
91
+ remainder = n_untracked - len(shown)
92
+ if remainder > 0:
93
+ print(f" … and {remainder} more")
94
+ else:
95
+ print(f" Run with --list to see titles, or /work-plan group to cluster.")
96
+ any_output = True
97
+
98
+ if not any_output:
99
+ print("No repos with a 'github' entry found in config.")
100
+ return 0
@@ -23,10 +23,12 @@ def run(args: list[str]) -> int:
23
23
  threshold_arg = next((a for a in args if a.startswith("--min-similarity=")), None)
24
24
  limit_arg = next((a for a in args if a.startswith("--limit=")), None)
25
25
  state_arg = next((a for a in args if a.startswith("--state=")), None)
26
+ timeout_arg = next((a for a in args if a.startswith("--timeout=")), None)
26
27
 
27
28
  threshold = float(threshold_arg.split("=", 1)[1]) if threshold_arg else 0.70
28
29
  limit = int(limit_arg.split("=", 1)[1]) if limit_arg else 20
29
30
  state = state_arg.split("=", 1)[1] if state_arg else "open"
31
+ gh_timeout = int(timeout_arg.split("=", 1)[1]) if timeout_arg else 30
30
32
 
31
33
  try:
32
34
  cfg = load_config()
@@ -49,12 +51,17 @@ def run(args: list[str]) -> int:
49
51
  repo = cfg["repos"][folder]["github"]
50
52
 
51
53
  print(f"Fetching {state} issues from {repo}...")
52
- proc = subprocess.run(
53
- ["gh", "issue", "list", "--repo", repo,
54
- "--state", state, "--limit", "1000",
55
- "--json", "number,title,url"],
56
- capture_output=True, text=True,
57
- )
54
+ try:
55
+ proc = subprocess.run(
56
+ ["gh", "issue", "list", "--repo", repo,
57
+ "--state", state, "--limit", "1000",
58
+ "--json", "number,title,url"],
59
+ capture_output=True, text=True,
60
+ timeout=gh_timeout,
61
+ )
62
+ except subprocess.TimeoutExpired:
63
+ print(f"ERROR: gh issue list timed out after {gh_timeout}s")
64
+ return 1
58
65
  if proc.returncode != 0:
59
66
  print(f"ERROR fetching issues: {proc.stderr}")
60
67
  return 1
@@ -69,11 +76,15 @@ def run(args: list[str]) -> int:
69
76
 
70
77
  # Pairwise similarity (O(n²) but fine for n<=1000)
71
78
  pairs = []
72
- for idx_a in range(len(normalized)):
79
+ total = len(normalized)
80
+ tick_interval = max(1, total // 10)
81
+ for idx_a in range(total):
82
+ if idx_a % tick_interval == 0:
83
+ print(f" ... {idx_a}/{total} compared", end="\r", flush=True)
73
84
  a, norm_a = normalized[idx_a]
74
85
  if len(norm_a) < 5:
75
86
  continue
76
- for idx_b in range(idx_a + 1, len(normalized)):
87
+ for idx_b in range(idx_a + 1, total):
77
88
  b, norm_b = normalized[idx_b]
78
89
  if len(norm_b) < 5:
79
90
  continue
@@ -81,6 +92,8 @@ def run(args: list[str]) -> int:
81
92
  if ratio >= threshold:
82
93
  pairs.append((ratio, a, b))
83
94
 
95
+ print() # clear the \r progress line
96
+
84
97
  pairs.sort(key=lambda x: -x[0])
85
98
  pairs = pairs[:limit]
86
99