@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
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|