@stylusnexus/work-plan 2026.6.9-1
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/LICENSE +21 -0
- package/README.md +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +88 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +42 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +248 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -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_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +169 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_group_apply.py +348 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- package/skills/work-plan/work_plan.py +220 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""set subcommand — guarded edit of a track's frontmatter scalar/list fields."""
|
|
2
|
+
import json
|
|
3
|
+
from lib.config import load_config, ConfigError
|
|
4
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
5
|
+
from lib.frontmatter import write_file
|
|
6
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
7
|
+
from lib.prompts import parse_flags
|
|
8
|
+
|
|
9
|
+
ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up"}
|
|
10
|
+
LIST_FIELDS = {"blockers", "next_up"}
|
|
11
|
+
STATUSES = {"active", "in-progress", "blocked", "parked", "shipped", "abandoned"}
|
|
12
|
+
|
|
13
|
+
def run(args: list[str]) -> int:
|
|
14
|
+
# Confirm token is passed as --confirm=<token> (equals form: parse_flags only
|
|
15
|
+
# understands --key=value or bare --key, so a space-separated token would be
|
|
16
|
+
# mis-read as a positional). The VS Code extension invokes the equals form.
|
|
17
|
+
flags, positional = parse_flags(args, {"--confirm", "--repo"})
|
|
18
|
+
if len(positional) < 2:
|
|
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
|
|
25
|
+
parsed = {}
|
|
26
|
+
for a in assignments:
|
|
27
|
+
if "=" not in a:
|
|
28
|
+
print(f"ERROR: bad assignment {a!r} (expected field=value)"); return 2
|
|
29
|
+
k, v = a.split("=", 1)
|
|
30
|
+
if k not in ALLOWED:
|
|
31
|
+
print(f"ERROR: field {k!r} not settable (allowed: {sorted(ALLOWED)})"); return 2
|
|
32
|
+
if k in LIST_FIELDS:
|
|
33
|
+
try:
|
|
34
|
+
parsed[k] = [int(x) for x in v.split(",") if x.strip()] if v.strip() else []
|
|
35
|
+
except ValueError:
|
|
36
|
+
print(f"ERROR: {k} takes comma-separated integers (got {v!r})"); return 2
|
|
37
|
+
elif k == "status" and v not in STATUSES:
|
|
38
|
+
print(f"ERROR: status {v!r} invalid (allowed: {sorted(STATUSES)})"); return 2
|
|
39
|
+
else:
|
|
40
|
+
parsed[k] = v
|
|
41
|
+
try:
|
|
42
|
+
cfg = load_config()
|
|
43
|
+
except ConfigError as e:
|
|
44
|
+
print(f"ERROR: {e}"); return 1
|
|
45
|
+
try:
|
|
46
|
+
track = find_track_by_name(name, discover_tracks(cfg), repo=repo_qualifier)
|
|
47
|
+
except AmbiguousTrackError as e:
|
|
48
|
+
print(str(e)); return 1
|
|
49
|
+
if not track:
|
|
50
|
+
print(f"No track matching {name!r}."); return 1
|
|
51
|
+
# Public-repo confirm gate (the extension surfaces this as a modal).
|
|
52
|
+
confirm = flags.get("--confirm")
|
|
53
|
+
if track.repo and needs_confirm(track.repo, cfg) and not (isinstance(confirm, str) and valid_token(confirm, track.repo, track.name)):
|
|
54
|
+
print(json.dumps({"needs_confirm": True,
|
|
55
|
+
"reason": f"{track.repo} is PUBLIC (or visibility unknown); edit will be written there.",
|
|
56
|
+
"token": make_token(track.repo, track.name)}))
|
|
57
|
+
return 0
|
|
58
|
+
track.meta.update(parsed)
|
|
59
|
+
write_file(track.path, track.meta, track.body)
|
|
60
|
+
print(f"✓ set {', '.join(parsed)} on {track.name}")
|
|
61
|
+
return 0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""set-notes-root subcommand — non-interactively relocate notes_root in config.
|
|
2
|
+
|
|
3
|
+
Called by the VS Code viewer's cold-start onboarding when the user picks a
|
|
4
|
+
folder. Config writes stay in the CLI (the engine), not the extension.
|
|
5
|
+
|
|
6
|
+
Usage: set-notes-root <path>
|
|
7
|
+
"""
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from lib.config import load_config, DEFAULT_CONFIG_PATH
|
|
12
|
+
from lib.prompts import parse_flags
|
|
13
|
+
from lib.tracks import discover_tracks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(args: list[str]) -> int:
|
|
17
|
+
_, positional = parse_flags(args, set())
|
|
18
|
+
|
|
19
|
+
if not positional:
|
|
20
|
+
print("usage: work_plan.py set-notes-root <path>")
|
|
21
|
+
return 2
|
|
22
|
+
|
|
23
|
+
new_root = Path(positional[0]).expanduser().resolve()
|
|
24
|
+
|
|
25
|
+
cfg = load_config()
|
|
26
|
+
current_root = Path(cfg["notes_root"]).expanduser().resolve()
|
|
27
|
+
|
|
28
|
+
# Orphan warning (informational only — no moves, no prompt)
|
|
29
|
+
if new_root != current_root:
|
|
30
|
+
tracks = discover_tracks(cfg)
|
|
31
|
+
if tracks:
|
|
32
|
+
print(
|
|
33
|
+
f"WARN: {len(tracks)} track(s) exist under {current_root}. "
|
|
34
|
+
"They will NOT be moved — move them manually to the new location "
|
|
35
|
+
"before using the viewer, or they won't appear."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Ensure the target directory exists
|
|
39
|
+
new_root.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# Write the new notes_root into config via yq (mikefarah/yq)
|
|
42
|
+
yq_expr = f'.notes_root = "{new_root}"'
|
|
43
|
+
try:
|
|
44
|
+
subprocess.run(
|
|
45
|
+
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
46
|
+
check=True, capture_output=True, text=True,
|
|
47
|
+
)
|
|
48
|
+
except subprocess.CalledProcessError as e:
|
|
49
|
+
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
50
|
+
return 1
|
|
51
|
+
|
|
52
|
+
print(f"✓ notes_root set to {new_root}")
|
|
53
|
+
return 0
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""slot subcommand."""
|
|
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, prompt_input
|
|
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`. Lets slot offer a move when GitHub labels
|
|
15
|
+
moved an issue across tracks but the old frontmatter still claims it."""
|
|
16
|
+
owners = []
|
|
17
|
+
for t in tracks:
|
|
18
|
+
if not t.has_frontmatter or t.name == target_name or t.repo != repo:
|
|
19
|
+
continue
|
|
20
|
+
if t.meta.get("status") not in ("active", "in-progress", "blocked"):
|
|
21
|
+
continue
|
|
22
|
+
if issue_num in (t.meta.get("github", {}).get("issues") or []):
|
|
23
|
+
owners.append(t)
|
|
24
|
+
return owners
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(args: list[str]) -> int:
|
|
28
|
+
# --confirm uses equals form: --confirm=<token>
|
|
29
|
+
# --move / --no-move are bare flags
|
|
30
|
+
# --repo uses equals form: --repo=<key>
|
|
31
|
+
flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo"})
|
|
32
|
+
if not positional:
|
|
33
|
+
print("usage: work_plan.py slot <issue-num> [track | track@repo] [--repo=<key>]")
|
|
34
|
+
return 2
|
|
35
|
+
try:
|
|
36
|
+
issue_num = int(positional[0])
|
|
37
|
+
except ValueError:
|
|
38
|
+
print(f"ERROR: '{positional[0]}' is not an issue number.")
|
|
39
|
+
return 2
|
|
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
|
|
50
|
+
|
|
51
|
+
if "--move" in flags and "--no-move" in flags:
|
|
52
|
+
print("ERROR: --move and --no-move are mutually exclusive.")
|
|
53
|
+
return 2
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
cfg = load_config()
|
|
57
|
+
except ConfigError as e:
|
|
58
|
+
print(f"ERROR: {e}")
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
tracks = discover_tracks(cfg)
|
|
62
|
+
active = [t for t in tracks if t.has_frontmatter
|
|
63
|
+
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
64
|
+
|
|
65
|
+
if target_name:
|
|
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
|
|
72
|
+
if not target:
|
|
73
|
+
print(f"No active track matching '{target_name}'.")
|
|
74
|
+
return 1
|
|
75
|
+
else:
|
|
76
|
+
print("Active tracks:")
|
|
77
|
+
for i, t in enumerate(active, 1):
|
|
78
|
+
print(f" [{i}] {t.name} ({t.meta.get('launch_priority','P3')}, "
|
|
79
|
+
f"{t.meta.get('milestone_alignment','—')})")
|
|
80
|
+
choice = prompt_input("\nSlot into which? (number or name):")
|
|
81
|
+
if not choice:
|
|
82
|
+
print("No selection. Cancelled.")
|
|
83
|
+
return 1
|
|
84
|
+
if choice.isdigit():
|
|
85
|
+
idx = int(choice) - 1
|
|
86
|
+
if not (0 <= idx < len(active)):
|
|
87
|
+
print("Out of range.")
|
|
88
|
+
return 1
|
|
89
|
+
target = active[idx]
|
|
90
|
+
else:
|
|
91
|
+
matching = [t for t in active if t.name == choice or t.meta.get("track") == choice]
|
|
92
|
+
if not matching:
|
|
93
|
+
print(f"No active track matching '{choice}'.")
|
|
94
|
+
return 1
|
|
95
|
+
target = matching[0]
|
|
96
|
+
|
|
97
|
+
issues = list(target.meta.get("github", {}).get("issues") or [])
|
|
98
|
+
if issue_num in issues:
|
|
99
|
+
print(f"#{issue_num} already in track '{target.name}'.")
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
# Public-repo confirm gate (the extension surfaces this as a modal).
|
|
103
|
+
# Placed after target resolution and the "already in track" no-op so we
|
|
104
|
+
# don't gate a no-op write.
|
|
105
|
+
confirm = flags.get("--confirm")
|
|
106
|
+
if target.repo and needs_confirm(target.repo, cfg) and not (
|
|
107
|
+
isinstance(confirm, str) and valid_token(confirm, target.repo, target.name)
|
|
108
|
+
):
|
|
109
|
+
print(json.dumps({
|
|
110
|
+
"needs_confirm": True,
|
|
111
|
+
"reason": (
|
|
112
|
+
f"{target.repo} is PUBLIC (or visibility unknown); "
|
|
113
|
+
f"slotting #{issue_num} will be written there."
|
|
114
|
+
),
|
|
115
|
+
"token": make_token(target.repo, target.name),
|
|
116
|
+
}))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
# Determine move behavior from flags.
|
|
120
|
+
# --move: remove issue from prior owners.
|
|
121
|
+
# Default / --no-move: add-only; print a note naming prior owners.
|
|
122
|
+
do_move = "--move" in flags
|
|
123
|
+
|
|
124
|
+
sources = _find_prior_owners(issue_num, target.repo, target.name, tracks)
|
|
125
|
+
|
|
126
|
+
issues.append(issue_num)
|
|
127
|
+
target.meta.setdefault("github", {})["issues"] = sorted(issues)
|
|
128
|
+
|
|
129
|
+
proc = subprocess.run(
|
|
130
|
+
["gh", "issue", "view", str(issue_num),
|
|
131
|
+
"--repo", target.repo, "--json", "milestone"],
|
|
132
|
+
capture_output=True, text=True,
|
|
133
|
+
)
|
|
134
|
+
if proc.returncode == 0:
|
|
135
|
+
info = json.loads(proc.stdout)
|
|
136
|
+
m = info.get("milestone", {})
|
|
137
|
+
if m and m.get("title") and m["title"] != target.meta.get("milestone_alignment"):
|
|
138
|
+
print(f"⚠ #{issue_num} is on milestone '{m['title']}', "
|
|
139
|
+
f"track '{target.name}' aligned to '{target.meta.get('milestone_alignment')}'.")
|
|
140
|
+
|
|
141
|
+
if sources and do_move:
|
|
142
|
+
for src in sources:
|
|
143
|
+
src_issues = [n for n in (src.meta.get("github", {}).get("issues") or [])
|
|
144
|
+
if n != issue_num]
|
|
145
|
+
src.meta.setdefault("github", {})["issues"] = src_issues
|
|
146
|
+
write_file(src.path, src.meta, src.body)
|
|
147
|
+
print(f" ✓ Removed #{issue_num} from '{src.name}'.")
|
|
148
|
+
elif sources and not do_move:
|
|
149
|
+
names = ", ".join(f"'{t.name}'" for t in sources)
|
|
150
|
+
print(f"ℹ #{issue_num} still listed in {names} — re-run with --move to relocate.")
|
|
151
|
+
|
|
152
|
+
write_file(target.path, target.meta, target.body)
|
|
153
|
+
print(f"✓ Slotted #{issue_num} into '{target.name}'.")
|
|
154
|
+
return 0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""suggest-priorities subcommand: prepare batch for AI labeling.
|
|
2
|
+
|
|
3
|
+
Two-step:
|
|
4
|
+
1. CLI fetches all unlabeled open issues, writes JSON batch + prints prompt
|
|
5
|
+
2. Agent fills priorities into ~/.claude/work-plan/cache/priorities.answers.json
|
|
6
|
+
3. Run with --apply to apply via gh
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from lib.config import load_config, ConfigError
|
|
14
|
+
from lib.scratch import cache_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _batch_path() -> Path:
|
|
18
|
+
return cache_dir() / "priorities.json"
|
|
19
|
+
PROMPT_TEMPLATE = """\
|
|
20
|
+
For each GitHub issue below, suggest a priority label (P0/P1/P2/P3) based on
|
|
21
|
+
title, milestone, and labels. Return JSON: [{"number": N, "priority": "P0"}, ...]
|
|
22
|
+
|
|
23
|
+
Heuristics:
|
|
24
|
+
- P0: launch-critical bugs/features tagged for v0.4.0 or v1.0.0 with urgent verbs (blocks, breaks, must)
|
|
25
|
+
- P1: important but not blocking; v0.4.0/v1.0.0 features
|
|
26
|
+
- P2: should ship eventually; v1.0.0 nice-to-haves, v2.0.0 features
|
|
27
|
+
- P3: backlog; long-tail polish, parked work
|
|
28
|
+
|
|
29
|
+
Skip issues with insufficient signal. Output ONLY valid JSON.
|
|
30
|
+
|
|
31
|
+
Issues:
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run(args: list[str]) -> int:
|
|
36
|
+
apply_mode = "--apply" in args
|
|
37
|
+
repo_arg = next((a for a in args if a.startswith("--repo=")), None)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
cfg = load_config()
|
|
41
|
+
except ConfigError as e:
|
|
42
|
+
print(f"ERROR: {e}")
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
if apply_mode:
|
|
46
|
+
return _apply(cfg)
|
|
47
|
+
|
|
48
|
+
repos = list(cfg["repos"].keys())
|
|
49
|
+
if repo_arg:
|
|
50
|
+
repo_folder = repo_arg.split("=", 1)[1]
|
|
51
|
+
repos = [repo_folder]
|
|
52
|
+
elif len(repos) > 1:
|
|
53
|
+
print("Multiple repos in config. Specify with --repo=<folder-name>.")
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
folder = repos[0]
|
|
57
|
+
repo = cfg["repos"][folder]["github"]
|
|
58
|
+
print(f"Fetching unlabeled issues in {repo}...")
|
|
59
|
+
|
|
60
|
+
proc = subprocess.run(
|
|
61
|
+
["gh", "issue", "list", "--repo", repo,
|
|
62
|
+
"--state", "open", "--limit", "100",
|
|
63
|
+
"--json", "number,title,milestone,labels,url"],
|
|
64
|
+
capture_output=True, text=True,
|
|
65
|
+
)
|
|
66
|
+
if proc.returncode != 0:
|
|
67
|
+
print(f"ERROR fetching issues: {proc.stderr}")
|
|
68
|
+
return 1
|
|
69
|
+
all_issues = json.loads(proc.stdout) if proc.stdout.strip() else []
|
|
70
|
+
|
|
71
|
+
unlabeled = [
|
|
72
|
+
i for i in all_issues
|
|
73
|
+
if not any(l["name"].startswith("priority/") for l in i.get("labels", []))
|
|
74
|
+
]
|
|
75
|
+
if not unlabeled:
|
|
76
|
+
print("All open issues already have priority labels.")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
batch_path = _batch_path()
|
|
80
|
+
batch_path.write_text(json.dumps({"repo": repo, "issues": unlabeled}, indent=2))
|
|
81
|
+
print(f"Wrote {len(unlabeled)} issues to {batch_path}")
|
|
82
|
+
print()
|
|
83
|
+
print("=" * 60)
|
|
84
|
+
print(PROMPT_TEMPLATE)
|
|
85
|
+
for i in unlabeled:
|
|
86
|
+
m = i.get("milestone", {})
|
|
87
|
+
m_title = m.get("title", "—") if m else "—"
|
|
88
|
+
labels = [l["name"] for l in i.get("labels", [])]
|
|
89
|
+
print(f"#{i['number']} [{m_title}] [{','.join(labels) or 'no-labels'}] {i['title']}")
|
|
90
|
+
print("=" * 60)
|
|
91
|
+
print()
|
|
92
|
+
print(f"After agent returns JSON, save to {batch_path.with_suffix('.answers.json')}")
|
|
93
|
+
print(f"Then run: python3 ~/.claude/skills/work-plan/work_plan.py suggest-priorities --apply")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _apply(cfg: dict) -> int:
|
|
98
|
+
batch_path = _batch_path()
|
|
99
|
+
answers_path = batch_path.with_suffix(".answers.json")
|
|
100
|
+
if not answers_path.exists():
|
|
101
|
+
print(f"ERROR: {answers_path} not found. Run without --apply first.")
|
|
102
|
+
return 1
|
|
103
|
+
if not batch_path.exists():
|
|
104
|
+
print(f"ERROR: {batch_path} not found.")
|
|
105
|
+
return 1
|
|
106
|
+
batch = json.loads(batch_path.read_text())
|
|
107
|
+
repo = batch["repo"]
|
|
108
|
+
allowed_repos = {entry.get("github") for entry in cfg.get("repos", {}).values()}
|
|
109
|
+
if repo not in allowed_repos:
|
|
110
|
+
print(f"ERROR: batch repo '{repo}' not in config.yml repos.")
|
|
111
|
+
return 1
|
|
112
|
+
answers = json.loads(answers_path.read_text())
|
|
113
|
+
|
|
114
|
+
print(f"Applying {len(answers)} priority labels to {repo}...")
|
|
115
|
+
for ans in answers:
|
|
116
|
+
num = ans["number"]
|
|
117
|
+
priority = ans["priority"]
|
|
118
|
+
if priority not in ("P0", "P1", "P2", "P3"):
|
|
119
|
+
print(f" SKIP #{num}: invalid priority '{priority}'")
|
|
120
|
+
continue
|
|
121
|
+
proc = subprocess.run(
|
|
122
|
+
["gh", "issue", "edit", str(num),
|
|
123
|
+
"--repo", repo,
|
|
124
|
+
"--add-label", f"priority/{priority}"],
|
|
125
|
+
capture_output=True, text=True,
|
|
126
|
+
)
|
|
127
|
+
if proc.returncode == 0:
|
|
128
|
+
print(f" ✓ #{num} → priority/{priority}")
|
|
129
|
+
else:
|
|
130
|
+
print(f" ✗ #{num}: {proc.stderr.strip()}")
|
|
131
|
+
print("Done.")
|
|
132
|
+
return 0
|