@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
|
@@ -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
|
|
|
@@ -85,14 +135,30 @@ def run(args: list[str]) -> int:
|
|
|
85
135
|
milestone = milestone_flag if isinstance(milestone_flag, str) else "v1.0.0"
|
|
86
136
|
|
|
87
137
|
# ------------------------------------------------------------------
|
|
88
|
-
#
|
|
138
|
+
# Determine target path: shared (.work-plan/) or private (notes_root/)
|
|
139
|
+
# Shared route: repo is registered, has a local path, and it's a valid git repo.
|
|
140
|
+
# --private overrides to force the private (notes_root) route.
|
|
89
141
|
# ------------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
142
|
+
use_private = "--private" in flags
|
|
143
|
+
|
|
144
|
+
shared_path: Optional[Path] = None
|
|
145
|
+
if not use_private and folder in cfg.get("repos", {}):
|
|
146
|
+
local_raw = cfg["repos"][folder].get("local")
|
|
147
|
+
if local_raw:
|
|
148
|
+
local_path = Path(local_raw).expanduser()
|
|
149
|
+
if is_valid_git_repo(local_path):
|
|
150
|
+
shared_path = local_path / ".work-plan" / f"{slug}.md"
|
|
94
151
|
|
|
95
|
-
|
|
152
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
153
|
+
if shared_path is not None:
|
|
154
|
+
path = shared_path
|
|
155
|
+
is_shared = True
|
|
156
|
+
else:
|
|
157
|
+
if not notes_root.exists():
|
|
158
|
+
print(f"ERROR: notes_root {notes_root} does not exist.")
|
|
159
|
+
return 1
|
|
160
|
+
path = notes_root / folder / f"{slug}.md"
|
|
161
|
+
is_shared = False
|
|
96
162
|
|
|
97
163
|
if path.exists():
|
|
98
164
|
print(f"ERROR: track '{slug}' already exists at {path}")
|
|
@@ -115,13 +181,6 @@ def run(args: list[str]) -> int:
|
|
|
115
181
|
}))
|
|
116
182
|
return 0
|
|
117
183
|
|
|
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
184
|
# ------------------------------------------------------------------
|
|
126
185
|
# Create folder if missing, then write the track file
|
|
127
186
|
# ------------------------------------------------------------------
|
|
@@ -134,15 +193,33 @@ def run(args: list[str]) -> int:
|
|
|
134
193
|
"launch_priority": priority,
|
|
135
194
|
"milestone_alignment": milestone,
|
|
136
195
|
"github": {"repo": github, "issues": [], "branches": []},
|
|
137
|
-
"
|
|
196
|
+
"depends_on": [],
|
|
138
197
|
"last_touched": now,
|
|
139
198
|
"last_handoff": now,
|
|
140
199
|
"next_up": [],
|
|
141
200
|
"blockers": [],
|
|
142
201
|
}
|
|
202
|
+
if is_shared:
|
|
203
|
+
meta["tier"] = "shared"
|
|
204
|
+
|
|
143
205
|
body = f"# {slug}\n"
|
|
144
206
|
write_file(path, meta, body)
|
|
145
207
|
|
|
146
|
-
|
|
147
|
-
|
|
208
|
+
if is_shared:
|
|
209
|
+
print(f"✓ Created shared track '{slug}' for {github} at {path}")
|
|
210
|
+
else:
|
|
211
|
+
rel = path.relative_to(notes_root)
|
|
212
|
+
print(f"✓ Created track '{slug}' for {github} at {rel}")
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# --commit: stage + commit the track file to the shared repo (non-fatal)
|
|
216
|
+
# Only meaningful for shared tracks; warn and skip for private.
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
want_commit = "--commit" in flags
|
|
219
|
+
if want_commit:
|
|
220
|
+
if is_shared:
|
|
221
|
+
_git_commit_track(path, slug)
|
|
222
|
+
else:
|
|
223
|
+
print("⚠ --commit ignored: track is private (not in a git repo)")
|
|
224
|
+
|
|
148
225
|
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.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""refresh-md subcommand."""
|
|
2
2
|
from lib.config import load_config, ConfigError
|
|
3
|
-
from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo
|
|
3
|
+
from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
|
|
4
4
|
from lib.github_state import fetch_issues, state_to_status_label
|
|
5
5
|
from lib.frontmatter import write_file
|
|
6
6
|
from lib.status_table import find_all_status_tables, find_canonical_status_tables, sync_missing_rows, ISSUE_NUM_RE
|
|
@@ -15,12 +15,20 @@ def run(args: list[str]) -> int:
|
|
|
15
15
|
if repo_key is True:
|
|
16
16
|
print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
|
|
17
17
|
return 2
|
|
18
|
-
|
|
18
|
+
track_arg = positional[0] if positional else None
|
|
19
19
|
|
|
20
|
-
if not do_all and not
|
|
20
|
+
if not do_all and not track_arg and not repo_key:
|
|
21
21
|
print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
|
|
22
22
|
return 2
|
|
23
23
|
|
|
24
|
+
track_name = track_arg
|
|
25
|
+
repo_qualifier = repo_key
|
|
26
|
+
if track_arg:
|
|
27
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
28
|
+
track_name = name_from_arg
|
|
29
|
+
if repo_from_arg:
|
|
30
|
+
repo_qualifier = repo_from_arg
|
|
31
|
+
|
|
24
32
|
try:
|
|
25
33
|
cfg = load_config()
|
|
26
34
|
except ConfigError as e:
|
|
@@ -28,7 +36,7 @@ def run(args: list[str]) -> int:
|
|
|
28
36
|
return 1
|
|
29
37
|
|
|
30
38
|
tracks = discover_tracks(cfg)
|
|
31
|
-
if do_all or repo_key:
|
|
39
|
+
if do_all or (repo_key and not track_arg):
|
|
32
40
|
targets = [t for t in tracks if t.has_frontmatter
|
|
33
41
|
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
34
42
|
if repo_key:
|
|
@@ -41,7 +49,11 @@ def run(args: list[str]) -> int:
|
|
|
41
49
|
return 0
|
|
42
50
|
return _refresh_many(targets, yes)
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
try:
|
|
53
|
+
track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
|
|
54
|
+
except AmbiguousTrackError as e:
|
|
55
|
+
print(str(e))
|
|
56
|
+
return 1
|
|
45
57
|
if not track:
|
|
46
58
|
print(f"No track matching '{track_name}'.")
|
|
47
59
|
return 1
|
|
@@ -52,7 +64,8 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
52
64
|
"""Refresh one or more tracks. Computes proposed updates, then asks one
|
|
53
65
|
confirmation (or applies all if --yes)."""
|
|
54
66
|
pending = []
|
|
55
|
-
for track in tracks:
|
|
67
|
+
for i, track in enumerate(tracks, 1):
|
|
68
|
+
print(f" [{i}/{len(tracks)}] {track.path.name}...", flush=True)
|
|
56
69
|
canonical = find_canonical_status_tables(track.body)
|
|
57
70
|
all_tables = find_all_status_tables(track.body)
|
|
58
71
|
tables = canonical if canonical else all_tables
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"""set subcommand — guarded edit of a track's frontmatter scalar/list fields."""
|
|
2
2
|
import json
|
|
3
3
|
from lib.config import load_config, ConfigError
|
|
4
|
-
from lib.tracks import discover_tracks, find_track_by_name
|
|
4
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
5
5
|
from lib.frontmatter import write_file
|
|
6
6
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
7
7
|
from lib.prompts import parse_flags
|
|
8
8
|
|
|
9
|
-
ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up"}
|
|
9
|
+
ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up", "depends_on"}
|
|
10
10
|
LIST_FIELDS = {"blockers", "next_up"}
|
|
11
11
|
STATUSES = {"active", "in-progress", "blocked", "parked", "shipped", "abandoned"}
|
|
12
12
|
|
|
@@ -14,10 +14,14 @@ def run(args: list[str]) -> int:
|
|
|
14
14
|
# Confirm token is passed as --confirm=<token> (equals form: parse_flags only
|
|
15
15
|
# understands --key=value or bare --key, so a space-separated token would be
|
|
16
16
|
# mis-read as a positional). The VS Code extension invokes the equals form.
|
|
17
|
-
flags, positional = parse_flags(args, {"--confirm"})
|
|
17
|
+
flags, positional = parse_flags(args, {"--confirm", "--repo"})
|
|
18
18
|
if len(positional) < 2:
|
|
19
|
-
print("usage: work_plan.py set <track> field=value [field=value …] [--confirm=<token>]"); return 2
|
|
20
|
-
|
|
19
|
+
print("usage: work_plan.py set <track> field=value [field=value …] [--confirm=<token>] [--repo=<key>]"); return 2
|
|
20
|
+
track_arg, assignments = positional[0], positional[1:]
|
|
21
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
22
|
+
name = name_from_arg
|
|
23
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
24
|
+
repo_qualifier = repo_from_arg or repo_flag
|
|
21
25
|
parsed = {}
|
|
22
26
|
for a in assignments:
|
|
23
27
|
if "=" not in a:
|
|
@@ -25,7 +29,10 @@ def run(args: list[str]) -> int:
|
|
|
25
29
|
k, v = a.split("=", 1)
|
|
26
30
|
if k not in ALLOWED:
|
|
27
31
|
print(f"ERROR: field {k!r} not settable (allowed: {sorted(ALLOWED)})"); return 2
|
|
28
|
-
if k
|
|
32
|
+
if k == "depends_on":
|
|
33
|
+
# Comma-separated track slugs (strings, not issue numbers).
|
|
34
|
+
parsed[k] = [x.strip() for x in v.split(",") if x.strip()] if v.strip() else []
|
|
35
|
+
elif k in LIST_FIELDS:
|
|
29
36
|
try:
|
|
30
37
|
parsed[k] = [int(x) for x in v.split(",") if x.strip()] if v.strip() else []
|
|
31
38
|
except ValueError:
|
|
@@ -38,7 +45,10 @@ def run(args: list[str]) -> int:
|
|
|
38
45
|
cfg = load_config()
|
|
39
46
|
except ConfigError as e:
|
|
40
47
|
print(f"ERROR: {e}"); return 1
|
|
41
|
-
|
|
48
|
+
try:
|
|
49
|
+
track = find_track_by_name(name, discover_tracks(cfg), repo=repo_qualifier)
|
|
50
|
+
except AmbiguousTrackError as e:
|
|
51
|
+
print(str(e)); return 1
|
|
42
52
|
if not track:
|
|
43
53
|
print(f"No track matching {name!r}."); return 1
|
|
44
54
|
# Public-repo confirm gate (the extension surfaces this as a modal).
|
|
@@ -3,7 +3,7 @@ import json
|
|
|
3
3
|
import subprocess
|
|
4
4
|
|
|
5
5
|
from lib.config import load_config, ConfigError
|
|
6
|
-
from lib.tracks import discover_tracks, find_track_by_name
|
|
6
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
7
7
|
from lib.frontmatter import write_file
|
|
8
8
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
9
9
|
from lib.prompts import parse_flags, prompt_input
|
|
@@ -27,16 +27,26 @@ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
|
|
|
27
27
|
def run(args: list[str]) -> int:
|
|
28
28
|
# --confirm uses equals form: --confirm=<token>
|
|
29
29
|
# --move / --no-move are bare flags
|
|
30
|
-
|
|
30
|
+
# --repo uses equals form: --repo=<key>
|
|
31
|
+
flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo"})
|
|
31
32
|
if not positional:
|
|
32
|
-
print("usage: work_plan.py slot <issue-num> [track
|
|
33
|
+
print("usage: work_plan.py slot <issue-num> [track | track@repo] [--repo=<key>]")
|
|
33
34
|
return 2
|
|
34
35
|
try:
|
|
35
36
|
issue_num = int(positional[0])
|
|
36
37
|
except ValueError:
|
|
37
38
|
print(f"ERROR: '{positional[0]}' is not an issue number.")
|
|
38
39
|
return 2
|
|
39
|
-
|
|
40
|
+
target_arg = positional[1] if len(positional) > 1 else None
|
|
41
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
42
|
+
|
|
43
|
+
target_name = target_arg
|
|
44
|
+
repo_qualifier = repo_flag
|
|
45
|
+
if target_arg:
|
|
46
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(target_arg)
|
|
47
|
+
target_name = name_from_arg
|
|
48
|
+
if repo_from_arg:
|
|
49
|
+
repo_qualifier = repo_from_arg
|
|
40
50
|
|
|
41
51
|
if "--move" in flags and "--no-move" in flags:
|
|
42
52
|
print("ERROR: --move and --no-move are mutually exclusive.")
|
|
@@ -53,7 +63,12 @@ def run(args: list[str]) -> int:
|
|
|
53
63
|
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
54
64
|
|
|
55
65
|
if target_name:
|
|
56
|
-
|
|
66
|
+
try:
|
|
67
|
+
target = find_track_by_name(target_name, tracks, active_only=True,
|
|
68
|
+
repo=repo_qualifier)
|
|
69
|
+
except AmbiguousTrackError as e:
|
|
70
|
+
print(str(e))
|
|
71
|
+
return 1
|
|
57
72
|
if not target:
|
|
58
73
|
print(f"No active track matching '{target_name}'.")
|
|
59
74
|
return 1
|