@stylusnexus/work-plan 2026.6.9 → 2026.6.10-2
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 +94 -15
- 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 +31 -62
- 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 +32 -11
- package/skills/work-plan/commands/hygiene.py +29 -3
- package/skills/work-plan/commands/init.py +49 -7
- package/skills/work-plan/commands/init_repo.py +51 -3
- 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 +107 -23
- package/skills/work-plan/commands/reconcile.py +175 -33
- package/skills/work-plan/commands/refresh_md.py +125 -43
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/commands/set_field.py +17 -7
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/slot.py +20 -5
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- 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/frontmatter.py +12 -3
- package/skills/work-plan/lib/git_state.py +61 -52
- package/skills/work-plan/lib/github_state.py +100 -26
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +46 -4
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/lib/tracks.py +214 -19
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
- 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.py +12 -12
- 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 +55 -17
- package/skills/work-plan/tests/test_group_apply.py +411 -0
- package/skills/work-plan/tests/test_init_repo.py +140 -7
- 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 +176 -11
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
- 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_refresh_md.py +159 -61
- package/skills/work-plan/tests/test_rename_track.py +351 -0
- package/skills/work-plan/tests/test_repo_filter.py +6 -6
- package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
- package/skills/work-plan/tests/test_set_notes_root.py +6 -2
- package/skills/work-plan/tests/test_status_table.py +61 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +398 -4
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- package/skills/work-plan/work_plan.py +51 -26
- /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""move subcommand — source-first issue relocation between tracks."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from lib.config import load_config, ConfigError
|
|
5
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
6
|
+
from lib.frontmatter import write_file
|
|
7
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
8
|
+
from lib.prompts import parse_flags
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(args: list[str]) -> int:
|
|
12
|
+
"""move <issue-num> <from-track> <to-track> [--confirm=<token>] [--repo=<key>]
|
|
13
|
+
|
|
14
|
+
Removes <issue-num> from <from-track>'s frontmatter and adds it to
|
|
15
|
+
<to-track>'s frontmatter. Both tracks must be active and in the same
|
|
16
|
+
repo. Public-repo writes gate behind --confirm (same flow as slot/set).
|
|
17
|
+
"""
|
|
18
|
+
flags, positional = parse_flags(args, {"--confirm", "--repo"})
|
|
19
|
+
if len(positional) < 3:
|
|
20
|
+
print("usage: work_plan.py move <issue-num> <from-track> <to-track> [--confirm=<token>] [--repo=<key>]")
|
|
21
|
+
return 2
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
issue_num = int(positional[0])
|
|
25
|
+
except ValueError:
|
|
26
|
+
print(f"ERROR: '{positional[0]}' is not an issue number.")
|
|
27
|
+
return 2
|
|
28
|
+
|
|
29
|
+
from_arg, to_arg = positional[1], positional[2]
|
|
30
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
31
|
+
|
|
32
|
+
# Resolve from-track
|
|
33
|
+
from_name = from_arg
|
|
34
|
+
repo_qualifier = repo_flag
|
|
35
|
+
name_from, repo_from = parse_track_repo_arg(from_arg)
|
|
36
|
+
if name_from:
|
|
37
|
+
from_name = name_from
|
|
38
|
+
if repo_from:
|
|
39
|
+
repo_qualifier = repo_from
|
|
40
|
+
|
|
41
|
+
# Resolve to-track (may override repo qualifier)
|
|
42
|
+
to_name = to_arg
|
|
43
|
+
name_to, repo_to = parse_track_repo_arg(to_arg)
|
|
44
|
+
if name_to:
|
|
45
|
+
to_name = name_to
|
|
46
|
+
if repo_to:
|
|
47
|
+
repo_qualifier = repo_to
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
cfg = load_config()
|
|
51
|
+
except ConfigError as e:
|
|
52
|
+
print(f"ERROR: {e}")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
tracks = discover_tracks(cfg)
|
|
56
|
+
|
|
57
|
+
# Find both tracks (active only)
|
|
58
|
+
try:
|
|
59
|
+
src = find_track_by_name(from_name, tracks, active_only=True, repo=repo_qualifier)
|
|
60
|
+
except AmbiguousTrackError as e:
|
|
61
|
+
print(str(e))
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
if not src:
|
|
65
|
+
print(f"No active track matching '{from_name}'.")
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
dst = find_track_by_name(to_name, tracks, active_only=True, repo=repo_qualifier)
|
|
70
|
+
except AmbiguousTrackError as e:
|
|
71
|
+
print(str(e))
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
if not dst:
|
|
75
|
+
print(f"No active track matching '{to_name}'.")
|
|
76
|
+
return 1
|
|
77
|
+
|
|
78
|
+
# Same-repo guard
|
|
79
|
+
if src.repo != dst.repo:
|
|
80
|
+
print(f"ERROR: cross-repo moves not supported ({src.repo} ≠ {dst.repo}).")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
# Same-track no-op
|
|
84
|
+
if src.name == dst.name:
|
|
85
|
+
print(f"#{issue_num} already in track '{src.name}'.")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
# Validate issue is in source
|
|
89
|
+
src_issues = list(src.meta.get("github", {}).get("issues") or [])
|
|
90
|
+
if issue_num not in src_issues:
|
|
91
|
+
print(f"ERROR: #{issue_num} is not in track '{src.name}'.")
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
# Check if already in destination
|
|
95
|
+
dst_issues = list(dst.meta.get("github", {}).get("issues") or [])
|
|
96
|
+
if issue_num in dst_issues:
|
|
97
|
+
print(f"#{issue_num} already in track '{dst.name}'. Removing from '{src.name}' only.")
|
|
98
|
+
# Still remove from source even if already in dest
|
|
99
|
+
src_issues.remove(issue_num)
|
|
100
|
+
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
101
|
+
write_file(src.path, src.meta, src.body)
|
|
102
|
+
print(f" ✓ Removed #{issue_num} from '{src.name}'.")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
# Public-repo confirm gate (on the destination write)
|
|
106
|
+
confirm = flags.get("--confirm")
|
|
107
|
+
if dst.repo and needs_confirm(dst.repo, cfg) and not (
|
|
108
|
+
isinstance(confirm, str) and valid_token(confirm, dst.repo, dst.name)
|
|
109
|
+
):
|
|
110
|
+
print(json.dumps({
|
|
111
|
+
"needs_confirm": True,
|
|
112
|
+
"reason": (
|
|
113
|
+
f"{dst.repo} is PUBLIC (or visibility unknown); "
|
|
114
|
+
f"moving #{issue_num} will be written there."
|
|
115
|
+
),
|
|
116
|
+
"token": make_token(dst.repo, dst.name),
|
|
117
|
+
}))
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
# Execute: remove from source, add to destination
|
|
121
|
+
src_issues.remove(issue_num)
|
|
122
|
+
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
123
|
+
write_file(src.path, src.meta, src.body)
|
|
124
|
+
print(f" ✓ Removed #{issue_num} from '{src.name}'.")
|
|
125
|
+
|
|
126
|
+
dst_issues.append(issue_num)
|
|
127
|
+
dst.meta.setdefault("github", {})["issues"] = sorted(dst_issues)
|
|
128
|
+
write_file(dst.path, dst.meta, dst.body)
|
|
129
|
+
print(f" ✓ Added #{issue_num} to '{dst.name}'.")
|
|
130
|
+
|
|
131
|
+
return 0
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
"""new-track subcommand — one-shot non-interactive track creation.
|
|
2
2
|
|
|
3
|
-
Creates a brand-new <slug>.md under notes_root/<folder>/
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Creates a brand-new <slug>.md under notes_root/<folder>/ (private tier) or
|
|
4
|
+
<local>/.work-plan/ (shared tier) with frontmatter written from flags.
|
|
5
|
+
Designed for headless callers (e.g. the VS Code extension) that cannot run
|
|
6
|
+
interactive init + do not know notes_root upfront.
|
|
6
7
|
|
|
7
8
|
Usage:
|
|
8
9
|
new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]
|
|
9
|
-
[--private] [--confirm=<token>]
|
|
10
|
+
[--private] [--commit] [--confirm=<token>]
|
|
10
11
|
"""
|
|
11
12
|
import json
|
|
12
13
|
import re
|
|
14
|
+
import subprocess
|
|
13
15
|
from datetime import datetime
|
|
14
16
|
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
15
18
|
|
|
16
|
-
from lib.config import load_config, ConfigError
|
|
19
|
+
from lib.config import load_config, ConfigError, is_valid_git_repo
|
|
17
20
|
from lib.frontmatter import write_file
|
|
18
21
|
from lib.prompts import parse_flags
|
|
19
22
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
@@ -22,16 +25,63 @@ _VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
|
|
|
22
25
|
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
23
26
|
|
|
24
27
|
|
|
28
|
+
def _git_commit_track(track_file: Path, slug: str) -> None:
|
|
29
|
+
"""Stage and commit a single shared track file (path-scoped, no git add .)."""
|
|
30
|
+
# The clone root is .work-plan/'s parent
|
|
31
|
+
clone_root = track_file.parent.parent
|
|
32
|
+
if not is_valid_git_repo(clone_root):
|
|
33
|
+
print(f"⚠ --commit ignored: track is private (not in a git repo)")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# Determine current branch name for the success message
|
|
37
|
+
branch = "HEAD"
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "-C", str(clone_root), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
41
|
+
capture_output=True, text=True, check=False,
|
|
42
|
+
)
|
|
43
|
+
if result.returncode == 0:
|
|
44
|
+
branch = result.stdout.strip()
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
# Stage ONLY this file (never git add .)
|
|
49
|
+
try:
|
|
50
|
+
subprocess.run(
|
|
51
|
+
["git", "-C", str(clone_root), "add", str(track_file)],
|
|
52
|
+
capture_output=True, text=True, check=True,
|
|
53
|
+
)
|
|
54
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
55
|
+
msg = getattr(e, "stderr", str(e))
|
|
56
|
+
print(f"⚠ --commit: git add failed ({msg.strip()!r}) — continuing without commit")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Commit with a conventional message
|
|
60
|
+
commit_msg = f"chore: add shared track '{slug}'"
|
|
61
|
+
try:
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["git", "-C", str(clone_root), "commit", "-m", commit_msg],
|
|
64
|
+
capture_output=True, text=True, check=True,
|
|
65
|
+
)
|
|
66
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
67
|
+
msg = getattr(e, "stderr", str(e))
|
|
68
|
+
print(f"⚠ --commit: git commit failed ({msg.strip()!r}) — continuing without commit")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
print(f"✓ committed '{slug}' to {branch}")
|
|
72
|
+
|
|
73
|
+
|
|
25
74
|
def run(args: list[str]) -> int:
|
|
26
75
|
flags, positional = parse_flags(
|
|
27
|
-
args, {"--priority", "--milestone", "--private", "--confirm"}
|
|
76
|
+
args, {"--priority", "--milestone", "--private", "--confirm", "--commit"}
|
|
28
77
|
)
|
|
29
78
|
|
|
30
79
|
# Require exactly 2 positionals: repo and slug
|
|
31
80
|
if len(positional) < 2:
|
|
32
81
|
print(
|
|
33
82
|
"usage: work_plan.py new-track <repo> <slug>"
|
|
34
|
-
" [--priority=P0..P3] [--milestone=<m>] [--private] [--
|
|
83
|
+
" [--priority=P0..P3] [--milestone=<m>] [--private] [--commit]"
|
|
84
|
+
" [--confirm=<token>]"
|
|
35
85
|
)
|
|
36
86
|
return 2
|
|
37
87
|
|
|
@@ -53,6 +103,13 @@ def run(args: list[str]) -> int:
|
|
|
53
103
|
elif "/" in repo_arg:
|
|
54
104
|
github = repo_arg
|
|
55
105
|
folder = repo_arg.rsplit("/", 1)[-1]
|
|
106
|
+
# Validate the derived folder segment (#195). `rsplit` caps traversal at
|
|
107
|
+
# one segment, but a slug like `x/..` yields folder=".." → the track
|
|
108
|
+
# would be written one level ABOVE notes_root. A real GitHub repo name
|
|
109
|
+
# matches [A-Za-z0-9._-]+ and is never "." / ".." — reject anything else.
|
|
110
|
+
if folder in ("", ".", "..") or not re.fullmatch(r"[A-Za-z0-9._-]+", folder):
|
|
111
|
+
print(f"ERROR: cannot derive a safe notes folder from '{repo_arg}'.")
|
|
112
|
+
return 2
|
|
56
113
|
else:
|
|
57
114
|
print(
|
|
58
115
|
f"ERROR: unknown repo '{repo_arg}' — pass a configured key"
|
|
@@ -85,14 +142,30 @@ def run(args: list[str]) -> int:
|
|
|
85
142
|
milestone = milestone_flag if isinstance(milestone_flag, str) else "v1.0.0"
|
|
86
143
|
|
|
87
144
|
# ------------------------------------------------------------------
|
|
88
|
-
#
|
|
145
|
+
# Determine target path: shared (.work-plan/) or private (notes_root/)
|
|
146
|
+
# Shared route: repo is registered, has a local path, and it's a valid git repo.
|
|
147
|
+
# --private overrides to force the private (notes_root) route.
|
|
89
148
|
# ------------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
149
|
+
use_private = "--private" in flags
|
|
150
|
+
|
|
151
|
+
shared_path: Optional[Path] = None
|
|
152
|
+
if not use_private and folder in cfg.get("repos", {}):
|
|
153
|
+
local_raw = cfg["repos"][folder].get("local")
|
|
154
|
+
if local_raw:
|
|
155
|
+
local_path = Path(local_raw).expanduser()
|
|
156
|
+
if is_valid_git_repo(local_path):
|
|
157
|
+
shared_path = local_path / ".work-plan" / f"{slug}.md"
|
|
94
158
|
|
|
95
|
-
|
|
159
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
160
|
+
if shared_path is not None:
|
|
161
|
+
path = shared_path
|
|
162
|
+
is_shared = True
|
|
163
|
+
else:
|
|
164
|
+
if not notes_root.exists():
|
|
165
|
+
print(f"ERROR: notes_root {notes_root} does not exist.")
|
|
166
|
+
return 1
|
|
167
|
+
path = notes_root / folder / f"{slug}.md"
|
|
168
|
+
is_shared = False
|
|
96
169
|
|
|
97
170
|
if path.exists():
|
|
98
171
|
print(f"ERROR: track '{slug}' already exists at {path}")
|
|
@@ -115,13 +188,6 @@ def run(args: list[str]) -> int:
|
|
|
115
188
|
}))
|
|
116
189
|
return 0
|
|
117
190
|
|
|
118
|
-
# ------------------------------------------------------------------
|
|
119
|
-
# --private flag: accepted for forward-compat but is a no-op today.
|
|
120
|
-
# Every track is effectively private now; the two-tier shared/private
|
|
121
|
-
# model is unbuilt. We accept the flag so callers don't error out.
|
|
122
|
-
# ------------------------------------------------------------------
|
|
123
|
-
# (no branch on --private beyond parsing it)
|
|
124
|
-
|
|
125
191
|
# ------------------------------------------------------------------
|
|
126
192
|
# Create folder if missing, then write the track file
|
|
127
193
|
# ------------------------------------------------------------------
|
|
@@ -134,15 +200,33 @@ def run(args: list[str]) -> int:
|
|
|
134
200
|
"launch_priority": priority,
|
|
135
201
|
"milestone_alignment": milestone,
|
|
136
202
|
"github": {"repo": github, "issues": [], "branches": []},
|
|
137
|
-
"
|
|
203
|
+
"depends_on": [],
|
|
138
204
|
"last_touched": now,
|
|
139
205
|
"last_handoff": now,
|
|
140
206
|
"next_up": [],
|
|
141
207
|
"blockers": [],
|
|
142
208
|
}
|
|
209
|
+
if is_shared:
|
|
210
|
+
meta["tier"] = "shared"
|
|
211
|
+
|
|
143
212
|
body = f"# {slug}\n"
|
|
144
213
|
write_file(path, meta, body)
|
|
145
214
|
|
|
146
|
-
|
|
147
|
-
|
|
215
|
+
if is_shared:
|
|
216
|
+
print(f"✓ Created shared track '{slug}' for {github} at {path}")
|
|
217
|
+
else:
|
|
218
|
+
rel = path.relative_to(notes_root)
|
|
219
|
+
print(f"✓ Created track '{slug}' for {github} at {rel}")
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------
|
|
222
|
+
# --commit: stage + commit the track file to the shared repo (non-fatal)
|
|
223
|
+
# Only meaningful for shared tracks; warn and skip for private.
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
want_commit = "--commit" in flags
|
|
226
|
+
if want_commit:
|
|
227
|
+
if is_shared:
|
|
228
|
+
_git_commit_track(path, slug)
|
|
229
|
+
else:
|
|
230
|
+
print("⚠ --commit ignored: track is private (not in a git repo)")
|
|
231
|
+
|
|
148
232
|
return 0
|
|
@@ -12,8 +12,11 @@ For a given track:
|
|
|
12
12
|
of "anything closed looks unlabeled."
|
|
13
13
|
- Compare against frontmatter `github.issues`.
|
|
14
14
|
- Propose ADDS (labeled in GitHub but missing from frontmatter).
|
|
15
|
-
- Propose
|
|
16
|
-
|
|
15
|
+
- Propose MOVES (in track A's frontmatter, but now labeled for exactly one
|
|
16
|
+
other active track B in the same repo — a relabel; remove from A, add to B).
|
|
17
|
+
- Propose FLAGS (in frontmatter but no longer labeled, with no single move
|
|
18
|
+
target — possible orphan).
|
|
19
|
+
- User confirms before writing to the LOCAL frontmatter file(s).
|
|
17
20
|
|
|
18
21
|
READ-ONLY GITHUB CONTRACT
|
|
19
22
|
reconcile only READS GitHub via `gh issue list` and `gh pr list`. It NEVER
|
|
@@ -26,11 +29,16 @@ Run with --all to reconcile every active track in one pass.
|
|
|
26
29
|
"""
|
|
27
30
|
import json
|
|
28
31
|
import subprocess
|
|
32
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
29
33
|
|
|
30
34
|
from lib.config import load_config, ConfigError
|
|
31
|
-
from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo
|
|
35
|
+
from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
|
|
32
36
|
from lib.frontmatter import write_file
|
|
33
37
|
from lib.prompts import parse_flags, prompt_input
|
|
38
|
+
from lib.write_guard import needs_confirm
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PER_TRACK_TIMEOUT = 15 # seconds; each gh call gets this budget
|
|
34
42
|
|
|
35
43
|
|
|
36
44
|
def _resolve_labels(track) -> list[str]:
|
|
@@ -59,13 +67,19 @@ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
|
|
|
59
67
|
seen: dict[int, dict] = {}
|
|
60
68
|
for lab in labels:
|
|
61
69
|
for kind in ("issue", "pr"):
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
try:
|
|
71
|
+
proc = subprocess.run(
|
|
72
|
+
["gh", kind, "list", "--repo", repo,
|
|
73
|
+
"--label", lab,
|
|
74
|
+
"--state", "all", "--limit", "200",
|
|
75
|
+
"--json", "number,title,state"],
|
|
76
|
+
capture_output=True, text=True,
|
|
77
|
+
timeout=PER_TRACK_TIMEOUT,
|
|
78
|
+
)
|
|
79
|
+
except subprocess.TimeoutExpired:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"gh {kind} query timed out for label '{lab}'"
|
|
82
|
+
)
|
|
69
83
|
if proc.returncode != 0:
|
|
70
84
|
raise RuntimeError(
|
|
71
85
|
f"gh {kind} query failed for label '{lab}': {proc.stderr.strip()}"
|
|
@@ -76,19 +90,28 @@ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
|
|
|
76
90
|
|
|
77
91
|
|
|
78
92
|
def run(args: list[str]) -> int:
|
|
79
|
-
flags, positional = parse_flags(args, {"--all", "--draft", "--repo"})
|
|
93
|
+
flags, positional = parse_flags(args, {"--all", "--draft", "--repo", "--yes"})
|
|
80
94
|
do_all = flags.get("--all", False)
|
|
81
95
|
draft = flags.get("--draft", False)
|
|
96
|
+
yes = flags.get("--yes", False)
|
|
82
97
|
repo_key = flags.get("--repo")
|
|
83
98
|
if repo_key is True:
|
|
84
|
-
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
|
|
99
|
+
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
|
|
85
100
|
return 2
|
|
86
|
-
|
|
101
|
+
track_arg = positional[0] if positional else None
|
|
87
102
|
|
|
88
|
-
if not do_all and not
|
|
89
|
-
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
|
|
103
|
+
if not do_all and not track_arg and not repo_key:
|
|
104
|
+
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
|
|
90
105
|
return 2
|
|
91
106
|
|
|
107
|
+
track_name = track_arg
|
|
108
|
+
repo_qualifier = repo_key
|
|
109
|
+
if track_arg:
|
|
110
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
111
|
+
track_name = name_from_arg
|
|
112
|
+
if repo_from_arg:
|
|
113
|
+
repo_qualifier = repo_from_arg
|
|
114
|
+
|
|
92
115
|
try:
|
|
93
116
|
cfg = load_config()
|
|
94
117
|
except ConfigError as e:
|
|
@@ -99,7 +122,7 @@ def run(args: list[str]) -> int:
|
|
|
99
122
|
active = [t for t in tracks if t.has_frontmatter
|
|
100
123
|
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
101
124
|
|
|
102
|
-
if do_all or repo_key:
|
|
125
|
+
if do_all or (repo_key and not track_arg):
|
|
103
126
|
targets = active
|
|
104
127
|
if repo_key:
|
|
105
128
|
targets = filter_tracks_by_repo(targets, repo_key)
|
|
@@ -107,32 +130,115 @@ def run(args: list[str]) -> int:
|
|
|
107
130
|
print(f"No active tracks for repo '{repo_key}'.")
|
|
108
131
|
return 0
|
|
109
132
|
else:
|
|
110
|
-
|
|
133
|
+
try:
|
|
134
|
+
target = find_track_by_name(track_name, tracks, active_only=True,
|
|
135
|
+
repo=repo_qualifier)
|
|
136
|
+
except AmbiguousTrackError as e:
|
|
137
|
+
print(str(e))
|
|
138
|
+
return 1
|
|
111
139
|
if not target:
|
|
112
140
|
print(f"No active track matching '{track_name}'.")
|
|
113
141
|
return 1
|
|
114
142
|
targets = [target]
|
|
115
143
|
|
|
144
|
+
# Phase 1: parallel fetch of labeled issues for all tracks
|
|
145
|
+
work_items = [(track, _resolve_labels(track)) for track in targets if track.repo]
|
|
146
|
+
results: dict = {} # track.name → list[dict] or None (timeout/error)
|
|
147
|
+
|
|
148
|
+
total = len(work_items)
|
|
149
|
+
if total > 1:
|
|
150
|
+
# Parallel fetch when there are multiple tracks
|
|
151
|
+
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
152
|
+
submitted: list = []
|
|
153
|
+
for i, (track, labels) in enumerate(work_items, 1):
|
|
154
|
+
print(f" [{i}/{total}] fetching {track.repo} ({track.name})...", flush=True)
|
|
155
|
+
submitted.append((i, track, pool.submit(_fetch_labeled_issues, track.repo, labels)))
|
|
156
|
+
# Iterate in submit order for readable output; futures run in parallel
|
|
157
|
+
for i, track, future in submitted:
|
|
158
|
+
try:
|
|
159
|
+
results[track.name] = future.result()
|
|
160
|
+
print(f" [{i}/{total}] ✓ {track.name}")
|
|
161
|
+
except RuntimeError as e:
|
|
162
|
+
print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
|
|
163
|
+
results[track.name] = None
|
|
164
|
+
else:
|
|
165
|
+
# Single track: fetch directly (no thread overhead)
|
|
166
|
+
for i, (track, labels) in enumerate(work_items, 1):
|
|
167
|
+
print(f" [{i}/{total}] fetching {track.repo} ({track.name})...", flush=True)
|
|
168
|
+
try:
|
|
169
|
+
results[track.name] = _fetch_labeled_issues(track.repo, labels)
|
|
170
|
+
print(f" [{i}/{total}] ✓ {track.name}")
|
|
171
|
+
except RuntimeError as e:
|
|
172
|
+
print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
|
|
173
|
+
results[track.name] = None
|
|
174
|
+
|
|
175
|
+
# Phase 2a: index which fetched track(s) label each issue. Used to turn a
|
|
176
|
+
# bare FLAG (in a track's frontmatter, but it has lost that track's label)
|
|
177
|
+
# into a MOVE when the issue is now labeled for exactly one OTHER active
|
|
178
|
+
# track in the same repo.
|
|
179
|
+
labeled_index: dict = {} # issue number -> list[track]
|
|
180
|
+
for track in targets:
|
|
181
|
+
if not track.repo or results.get(track.name) is None:
|
|
182
|
+
continue
|
|
183
|
+
for num in {i["number"] for i in results[track.name]}:
|
|
184
|
+
labeled_index.setdefault(num, []).append(track)
|
|
185
|
+
|
|
186
|
+
# Phase 2b: detect cross-track moves (#163). An issue qualifies when it is
|
|
187
|
+
# in track A's frontmatter, no longer carries A's label, and is now labeled
|
|
188
|
+
# by exactly one OTHER active track B in the same repo. Ambiguous cases
|
|
189
|
+
# (two or more candidate targets) stay as plain FLAGs.
|
|
190
|
+
moved_out: dict = {} # src track name -> set(num)
|
|
191
|
+
moved_in: dict = {} # dst track name -> set(num)
|
|
192
|
+
move_dst: dict = {} # (src track name, num) -> dst track
|
|
193
|
+
for track in targets:
|
|
194
|
+
if not track.repo or results.get(track.name) is None:
|
|
195
|
+
continue
|
|
196
|
+
labeled_nums = {i["number"] for i in results[track.name]}
|
|
197
|
+
listed_nums = set(track.meta.get("github", {}).get("issues") or [])
|
|
198
|
+
for num in sorted(listed_nums - labeled_nums):
|
|
199
|
+
cands = [b for b in labeled_index.get(num, [])
|
|
200
|
+
if b is not track and b.repo == track.repo]
|
|
201
|
+
if len(cands) == 1:
|
|
202
|
+
dst = cands[0]
|
|
203
|
+
moved_out.setdefault(track.name, set()).add(num)
|
|
204
|
+
moved_in.setdefault(dst.name, set()).add(num)
|
|
205
|
+
move_dst[(track.name, num)] = dst
|
|
206
|
+
|
|
207
|
+
# Phase 2c: per-track diff, report, confirm. Membership changes accumulate
|
|
208
|
+
# in `final` (track name -> desired issue set); each affected track is
|
|
209
|
+
# written exactly ONCE at the end, so a move that touches two tracks never
|
|
210
|
+
# double-writes or clobbers a sibling's accepted ADDs. A move is governed by
|
|
211
|
+
# the confirmation on its SOURCE track (where the issue currently lives).
|
|
212
|
+
final: dict = {} # track name -> set(num)
|
|
213
|
+
affected: dict = {} # track name -> track (only those we may write)
|
|
214
|
+
|
|
215
|
+
def _final_for(t):
|
|
216
|
+
if t.name not in final:
|
|
217
|
+
final[t.name] = set(t.meta.get("github", {}).get("issues") or [])
|
|
218
|
+
affected[t.name] = t
|
|
219
|
+
return final[t.name]
|
|
220
|
+
|
|
116
221
|
any_changes = False
|
|
117
222
|
for track in targets:
|
|
118
223
|
slug = track.meta.get("track", track.name)
|
|
119
224
|
if not track.repo:
|
|
120
225
|
continue
|
|
121
226
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
labeled = _fetch_labeled_issues(track.repo, labels)
|
|
125
|
-
except RuntimeError as e:
|
|
126
|
-
print(f" ⚠ {slug}: {e}")
|
|
227
|
+
labeled = results.get(track.name)
|
|
228
|
+
if labeled is None:
|
|
127
229
|
continue
|
|
128
230
|
|
|
231
|
+
labels = _resolve_labels(track)
|
|
129
232
|
labeled_nums = {i["number"] for i in labeled}
|
|
130
233
|
listed_nums = set(track.meta.get("github", {}).get("issues") or [])
|
|
234
|
+
out_moves = sorted(moved_out.get(track.name, set()))
|
|
131
235
|
|
|
132
|
-
|
|
133
|
-
|
|
236
|
+
# MOVE issues are reported (and applied) as moves, not as ADD on the
|
|
237
|
+
# destination or FLAG on the source.
|
|
238
|
+
adds = sorted(labeled_nums - listed_nums - moved_in.get(track.name, set()))
|
|
239
|
+
flag_nums = sorted(listed_nums - labeled_nums - moved_out.get(track.name, set()))
|
|
134
240
|
|
|
135
|
-
if not adds and not flag_nums:
|
|
241
|
+
if not adds and not flag_nums and not out_moves:
|
|
136
242
|
continue
|
|
137
243
|
|
|
138
244
|
any_changes = True
|
|
@@ -144,6 +250,13 @@ def run(args: list[str]) -> int:
|
|
|
144
250
|
for num in adds:
|
|
145
251
|
i = issue_lookup[num]
|
|
146
252
|
print(f" #{num} ({i['state'].lower()}) {i['title']}")
|
|
253
|
+
if out_moves:
|
|
254
|
+
print(f" MOVE ({len(out_moves)}) — relabeled to another track in this repo:")
|
|
255
|
+
for num in out_moves:
|
|
256
|
+
dst = move_dst[(track.name, num)]
|
|
257
|
+
dst_slug = dst.meta.get("track", dst.name)
|
|
258
|
+
pub = " [dst PUBLIC]" if needs_confirm(dst.repo, cfg) else ""
|
|
259
|
+
print(f" #{num} {slug} → {dst_slug}{pub}")
|
|
147
260
|
if flag_nums:
|
|
148
261
|
print(f" FLAG ({len(flag_nums)}) — in frontmatter but missing every configured label:")
|
|
149
262
|
for num in flag_nums:
|
|
@@ -151,8 +264,8 @@ def run(args: list[str]) -> int:
|
|
|
151
264
|
|
|
152
265
|
if listed_nums and len(flag_nums) / len(listed_nums) > 0.5:
|
|
153
266
|
print(f"\n ⓘ {len(flag_nums)}/{len(listed_nums)} frontmatter issues lack the configured label(s).")
|
|
154
|
-
print(
|
|
155
|
-
print(
|
|
267
|
+
print(" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
|
|
268
|
+
print(" If you just want to update issue state in the body table, try:")
|
|
156
269
|
print(f" /work-plan refresh-md {slug}")
|
|
157
270
|
|
|
158
271
|
if draft:
|
|
@@ -160,12 +273,41 @@ def run(args: list[str]) -> int:
|
|
|
160
273
|
# Useful for sweep audits and scripted reports.
|
|
161
274
|
continue
|
|
162
275
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
276
|
+
if yes:
|
|
277
|
+
# Non-interactive: apply ADDs + MOVEs without prompting. All writes
|
|
278
|
+
# are local frontmatter — the read-only-GitHub contract is unchanged.
|
|
279
|
+
print(f"\n --yes: applying changes from {track.path.name}")
|
|
280
|
+
choice = "y"
|
|
281
|
+
else:
|
|
282
|
+
choice = prompt_input(f"\n Apply ADDs/MOVEs from {track.path.name}? [y/N]").lower()
|
|
283
|
+
if choice != "y":
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if adds:
|
|
287
|
+
_final_for(track).update(adds)
|
|
288
|
+
for num in out_moves:
|
|
289
|
+
dst = move_dst[(track.name, num)]
|
|
290
|
+
# Public-repo guard (#163): under --yes we never silently write
|
|
291
|
+
# membership into a PUBLIC/shared destination track — that move is
|
|
292
|
+
# skipped with a pointer to the gated `move` verb. Interactive runs
|
|
293
|
+
# treat the prompt above as the confirmation.
|
|
294
|
+
if yes and needs_confirm(dst.repo, cfg):
|
|
295
|
+
dst_slug = dst.meta.get("track", dst.name)
|
|
296
|
+
print(f" ⏭ skipped MOVE #{num} → {dst_slug} ({dst.repo} is PUBLIC; "
|
|
297
|
+
f"run `/work-plan move {num} {slug} {dst_slug} --confirm` instead)")
|
|
298
|
+
continue
|
|
299
|
+
_final_for(track).discard(num)
|
|
300
|
+
_final_for(dst).add(num)
|
|
301
|
+
|
|
302
|
+
# Write each affected track exactly once, only if its set actually changed.
|
|
303
|
+
for name, issues in final.items():
|
|
304
|
+
track = affected[name]
|
|
305
|
+
original = set(track.meta.get("github", {}).get("issues") or [])
|
|
306
|
+
if issues == original:
|
|
307
|
+
continue
|
|
308
|
+
track.meta.setdefault("github", {})["issues"] = sorted(issues)
|
|
309
|
+
write_file(track.path, track.meta, track.body)
|
|
310
|
+
print(f" ✓ Updated {track.path.name}")
|
|
169
311
|
|
|
170
312
|
if not any_changes:
|
|
171
313
|
print("All tracks in sync with configured labels.")
|