@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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. 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