@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,124 @@
|
|
|
1
|
+
"""duplicates subcommand: find likely-duplicate GitHub issues by title similarity.
|
|
2
|
+
|
|
3
|
+
Uses difflib.SequenceMatcher (stdlib) on normalized titles. No deps.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from difflib import SequenceMatcher
|
|
9
|
+
|
|
10
|
+
from lib.config import load_config, ConfigError
|
|
11
|
+
|
|
12
|
+
# Common conventional-commit prefixes to strip before comparison
|
|
13
|
+
PREFIX_RE = re.compile(
|
|
14
|
+
r"^(feat|fix|chore|docs|test|refactor|perf|ci|spec|style|build|infra|epic|assets|fix-up)"
|
|
15
|
+
r"(\([^)]+\))?:\s*",
|
|
16
|
+
re.IGNORECASE,
|
|
17
|
+
)
|
|
18
|
+
WHITESPACE_RE = re.compile(r"\s+")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(args: list[str]) -> int:
|
|
22
|
+
repo_arg = next((a for a in args if a.startswith("--repo=")), None)
|
|
23
|
+
threshold_arg = next((a for a in args if a.startswith("--min-similarity=")), None)
|
|
24
|
+
limit_arg = next((a for a in args if a.startswith("--limit=")), None)
|
|
25
|
+
state_arg = next((a for a in args if a.startswith("--state=")), None)
|
|
26
|
+
timeout_arg = next((a for a in args if a.startswith("--timeout=")), None)
|
|
27
|
+
|
|
28
|
+
threshold = float(threshold_arg.split("=", 1)[1]) if threshold_arg else 0.70
|
|
29
|
+
limit = int(limit_arg.split("=", 1)[1]) if limit_arg else 20
|
|
30
|
+
state = state_arg.split("=", 1)[1] if state_arg else "open"
|
|
31
|
+
gh_timeout = int(timeout_arg.split("=", 1)[1]) if timeout_arg else 30
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
cfg = load_config()
|
|
35
|
+
except ConfigError as e:
|
|
36
|
+
print(f"ERROR: {e}")
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
repos = list(cfg["repos"].keys())
|
|
40
|
+
if repo_arg:
|
|
41
|
+
repo_folder = repo_arg.split("=", 1)[1]
|
|
42
|
+
if repo_folder not in cfg["repos"]:
|
|
43
|
+
print(f"ERROR: repo folder '{repo_folder}' not in config.yml.")
|
|
44
|
+
return 1
|
|
45
|
+
repos = [repo_folder]
|
|
46
|
+
elif len(repos) > 1:
|
|
47
|
+
print("Multiple repos in config. Specify with --repo=<folder-name>.")
|
|
48
|
+
return 1
|
|
49
|
+
|
|
50
|
+
folder = repos[0]
|
|
51
|
+
repo = cfg["repos"][folder]["github"]
|
|
52
|
+
|
|
53
|
+
print(f"Fetching {state} issues from {repo}...")
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
["gh", "issue", "list", "--repo", repo,
|
|
57
|
+
"--state", state, "--limit", "1000",
|
|
58
|
+
"--json", "number,title,url"],
|
|
59
|
+
capture_output=True, text=True,
|
|
60
|
+
timeout=gh_timeout,
|
|
61
|
+
)
|
|
62
|
+
except subprocess.TimeoutExpired:
|
|
63
|
+
print(f"ERROR: gh issue list timed out after {gh_timeout}s")
|
|
64
|
+
return 1
|
|
65
|
+
if proc.returncode != 0:
|
|
66
|
+
print(f"ERROR fetching issues: {proc.stderr}")
|
|
67
|
+
return 1
|
|
68
|
+
issues = json.loads(proc.stdout) if proc.stdout.strip() else []
|
|
69
|
+
if len(issues) < 2:
|
|
70
|
+
print("Not enough issues to compare.")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
print(f"Comparing {len(issues)} issues (threshold: {threshold:.0%}, limit: {limit})...")
|
|
74
|
+
|
|
75
|
+
normalized = [(i, _normalize(i["title"])) for i in issues]
|
|
76
|
+
|
|
77
|
+
# Pairwise similarity (O(n²) but fine for n<=1000)
|
|
78
|
+
pairs = []
|
|
79
|
+
total = len(normalized)
|
|
80
|
+
tick_interval = max(1, total // 10)
|
|
81
|
+
for idx_a in range(total):
|
|
82
|
+
if idx_a % tick_interval == 0:
|
|
83
|
+
print(f" ... {idx_a}/{total} compared", end="\r", flush=True)
|
|
84
|
+
a, norm_a = normalized[idx_a]
|
|
85
|
+
if len(norm_a) < 5:
|
|
86
|
+
continue
|
|
87
|
+
for idx_b in range(idx_a + 1, total):
|
|
88
|
+
b, norm_b = normalized[idx_b]
|
|
89
|
+
if len(norm_b) < 5:
|
|
90
|
+
continue
|
|
91
|
+
ratio = SequenceMatcher(None, norm_a, norm_b).ratio()
|
|
92
|
+
if ratio >= threshold:
|
|
93
|
+
pairs.append((ratio, a, b))
|
|
94
|
+
|
|
95
|
+
print() # clear the \r progress line
|
|
96
|
+
|
|
97
|
+
pairs.sort(key=lambda x: -x[0])
|
|
98
|
+
pairs = pairs[:limit]
|
|
99
|
+
|
|
100
|
+
if not pairs:
|
|
101
|
+
print(f"No pairs above {threshold:.0%} similarity.")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
print(f"\nLikely duplicates (top {len(pairs)}):\n")
|
|
105
|
+
for ratio, a, b in pairs:
|
|
106
|
+
print(f" {ratio:.0%} #{a['number']:5} {a['title']}")
|
|
107
|
+
print(f" #{b['number']:5} {b['title']}")
|
|
108
|
+
print(f" {a['url']}")
|
|
109
|
+
print(f" {b['url']}")
|
|
110
|
+
print()
|
|
111
|
+
|
|
112
|
+
print(f"\nReview these manually. To consolidate, close one and reference the other:")
|
|
113
|
+
print(f" gh issue close <newer> --comment 'Duplicate of #<older>' --repo {repo}")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _normalize(title: str) -> str:
|
|
118
|
+
"""Strip common prefixes + lowercase + collapse whitespace for comparison."""
|
|
119
|
+
s = title.strip()
|
|
120
|
+
# Remove conventional-commit prefix
|
|
121
|
+
s = PREFIX_RE.sub("", s)
|
|
122
|
+
s = s.lower()
|
|
123
|
+
s = WHITESPACE_RE.sub(" ", s)
|
|
124
|
+
return s
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""export subcommand — emit the viewer-ready JSON read surface."""
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from lib.config import load_config, ConfigError
|
|
5
|
+
from lib.tracks import discover_tracks
|
|
6
|
+
from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
|
|
7
|
+
from lib.export_model import build_export
|
|
8
|
+
from lib.prompts import parse_flags
|
|
9
|
+
|
|
10
|
+
def run(args: list[str]) -> int:
|
|
11
|
+
flags, _ = parse_flags(args, {"--json"})
|
|
12
|
+
if not flags.get("--json"):
|
|
13
|
+
print("usage: work-plan export --json"); return 2
|
|
14
|
+
try:
|
|
15
|
+
cfg = load_config()
|
|
16
|
+
except ConfigError as e:
|
|
17
|
+
print(json.dumps({"error": str(e)})); return 1
|
|
18
|
+
tracks = [t for t in discover_tracks(cfg) if t.has_frontmatter]
|
|
19
|
+
|
|
20
|
+
# Build repo_to_numbers: {repo: [number, ...]} deduped per repo, first-seen order.
|
|
21
|
+
repo_to_numbers: dict[str, list[int]] = {}
|
|
22
|
+
for t in tracks:
|
|
23
|
+
if not t.repo:
|
|
24
|
+
continue
|
|
25
|
+
nums = (t.meta.get("github", {}).get("issues")) or []
|
|
26
|
+
if not nums:
|
|
27
|
+
continue
|
|
28
|
+
seen_for_repo = repo_to_numbers.setdefault(t.repo, [])
|
|
29
|
+
seen_set = set(seen_for_repo)
|
|
30
|
+
for n in nums:
|
|
31
|
+
if n not in seen_set:
|
|
32
|
+
seen_for_repo.append(n)
|
|
33
|
+
seen_set.add(n)
|
|
34
|
+
|
|
35
|
+
# Bulk-fetch per repo (one gh call per repo) with per-issue fallback for misses.
|
|
36
|
+
issue_map = fetch_export_issues(repo_to_numbers)
|
|
37
|
+
|
|
38
|
+
# Reassemble per-track lists, preserving each track's declared issue order.
|
|
39
|
+
issues_by_track: dict[str, list] = {}
|
|
40
|
+
visibility: dict[str, object] = {}
|
|
41
|
+
for t in tracks:
|
|
42
|
+
nums = (t.meta.get("github", {}).get("issues")) or []
|
|
43
|
+
if t.repo and nums:
|
|
44
|
+
issues_by_track[t.name] = [
|
|
45
|
+
issue_map[(t.repo, n)]
|
|
46
|
+
for n in nums
|
|
47
|
+
if (t.repo, n) in issue_map
|
|
48
|
+
]
|
|
49
|
+
else:
|
|
50
|
+
issues_by_track[t.name] = []
|
|
51
|
+
if t.repo and t.repo not in visibility:
|
|
52
|
+
visibility[t.repo] = repo_visibility(t.repo)
|
|
53
|
+
|
|
54
|
+
# Compute untracked: open issues not referenced by any track, per repo.
|
|
55
|
+
# One `gh issue list` call per repo — bounded by the number of tracked repos
|
|
56
|
+
# (typically a handful), not by issue count, so a serial loop is fine.
|
|
57
|
+
untracked_by_repo: dict[str, list] = {}
|
|
58
|
+
for repo in repo_to_numbers:
|
|
59
|
+
tracked = set(repo_to_numbers[repo])
|
|
60
|
+
open_rows = fetch_open_issues(repo)
|
|
61
|
+
untracked_by_repo[repo] = [r for r in open_rows if r.get("number") not in tracked]
|
|
62
|
+
|
|
63
|
+
now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
64
|
+
print(json.dumps(
|
|
65
|
+
build_export(tracks, issues_by_track, visibility, now,
|
|
66
|
+
untracked_by_repo=untracked_by_repo),
|
|
67
|
+
indent=2,
|
|
68
|
+
))
|
|
69
|
+
return 0
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""group subcommand: AI-cluster GitHub issues into thematic track files.
|
|
2
|
+
|
|
3
|
+
Two-step:
|
|
4
|
+
1. CLI fetches issues by filter (--milestone / --label / --search), writes JSON
|
|
5
|
+
batch to ~/.claude/work-plan/cache/groups.json, prints clustering prompt.
|
|
6
|
+
2. Agent reads issues, produces JSON of clusters, saves to
|
|
7
|
+
~/.claude/work-plan/cache/groups.answers.json.
|
|
8
|
+
3. Run with --apply to create/update track files.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from lib.config import load_config, ConfigError, is_valid_git_repo
|
|
18
|
+
from lib.frontmatter import parse_file, write_file
|
|
19
|
+
from lib.notes_readme import seed_readme
|
|
20
|
+
from lib.scratch import cache_dir
|
|
21
|
+
from lib.write_guard import needs_confirm
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _batch_path() -> Path:
|
|
25
|
+
return cache_dir() / "groups.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _answers_path() -> Path:
|
|
29
|
+
return cache_dir() / "groups.answers.json"
|
|
30
|
+
|
|
31
|
+
PROMPT_TEMPLATE = """\
|
|
32
|
+
Cluster the GitHub issues below into thematic tracks. Each track represents a
|
|
33
|
+
coherent workstream (a feature area, subsystem, or focused initiative).
|
|
34
|
+
|
|
35
|
+
Return JSON: [
|
|
36
|
+
{
|
|
37
|
+
"slug": "kebab-case-track-name",
|
|
38
|
+
"name": "Human Readable Name",
|
|
39
|
+
"summary": "One-line description of what this track covers",
|
|
40
|
+
"issues": [4254, 4255, 4256]
|
|
41
|
+
},
|
|
42
|
+
...
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
Heuristics:
|
|
46
|
+
- 8-20 issues per cluster ideally; smaller clusters acceptable for orphan themes
|
|
47
|
+
- Aim for 8-15 clusters total (depends on input size; cluster less aggressively
|
|
48
|
+
when input is small)
|
|
49
|
+
- Slug is kebab-case, lowercase, derives from the theme not from any one issue
|
|
50
|
+
- Name is short, scannable (3-5 words)
|
|
51
|
+
- Issues that don't fit any cluster: put them in a "misc" cluster (avoid forcing)
|
|
52
|
+
- Cluster by feature area / subsystem / user-facing capability
|
|
53
|
+
- An issue can only appear in ONE cluster (no duplicates across clusters)
|
|
54
|
+
|
|
55
|
+
Issues:
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run(args: list[str]) -> int:
|
|
60
|
+
apply_mode = "--apply" in args
|
|
61
|
+
repo_arg = next((a for a in args if a.startswith("--repo=")), None)
|
|
62
|
+
milestone_arg = next((a for a in args if a.startswith("--milestone=")), None)
|
|
63
|
+
label_arg = next((a for a in args if a.startswith("--label=")), None)
|
|
64
|
+
state_arg = next((a for a in args if a.startswith("--state=")), None)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
cfg = load_config()
|
|
68
|
+
except ConfigError as e:
|
|
69
|
+
print(f"ERROR: {e}")
|
|
70
|
+
return 1
|
|
71
|
+
|
|
72
|
+
if apply_mode:
|
|
73
|
+
return _apply(cfg, args)
|
|
74
|
+
|
|
75
|
+
# Resolve repo
|
|
76
|
+
repos = list(cfg["repos"].keys())
|
|
77
|
+
if repo_arg:
|
|
78
|
+
repo_folder = repo_arg.split("=", 1)[1]
|
|
79
|
+
if repo_folder not in cfg["repos"]:
|
|
80
|
+
print(f"ERROR: repo folder '{repo_folder}' not in config.yml.")
|
|
81
|
+
return 1
|
|
82
|
+
repos = [repo_folder]
|
|
83
|
+
elif len(repos) > 1:
|
|
84
|
+
print("Multiple repos in config. Specify with --repo=<folder-name>.")
|
|
85
|
+
return 1
|
|
86
|
+
elif not repos:
|
|
87
|
+
print("ERROR: no repos configured in config.yml.")
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
folder = repos[0]
|
|
91
|
+
repo = cfg["repos"][folder]["github"]
|
|
92
|
+
|
|
93
|
+
# Build gh search query
|
|
94
|
+
state = state_arg.split("=", 1)[1] if state_arg else "open"
|
|
95
|
+
cmd = ["gh", "issue", "list", "--repo", repo,
|
|
96
|
+
"--state", state, "--limit", "500",
|
|
97
|
+
"--json", "number,title,milestone,labels,url,assignees,state"]
|
|
98
|
+
if milestone_arg:
|
|
99
|
+
cmd.extend(["--milestone", milestone_arg.split("=", 1)[1]])
|
|
100
|
+
if label_arg:
|
|
101
|
+
cmd.extend(["--label", label_arg.split("=", 1)[1]])
|
|
102
|
+
|
|
103
|
+
print(f"Fetching issues from {repo}...")
|
|
104
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
105
|
+
if proc.returncode != 0:
|
|
106
|
+
print(f"ERROR fetching issues: {proc.stderr}")
|
|
107
|
+
return 1
|
|
108
|
+
issues = json.loads(proc.stdout) if proc.stdout.strip() else []
|
|
109
|
+
if not issues:
|
|
110
|
+
print("No issues match the filter.")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
batch_path = _batch_path()
|
|
114
|
+
batch_path.write_text(json.dumps({
|
|
115
|
+
"repo": repo,
|
|
116
|
+
"folder": folder,
|
|
117
|
+
"milestone": milestone_arg.split("=", 1)[1] if milestone_arg else None,
|
|
118
|
+
"private": "--private" in args,
|
|
119
|
+
"issues": issues,
|
|
120
|
+
}, indent=2))
|
|
121
|
+
|
|
122
|
+
print(f"Wrote {len(issues)} issues to {batch_path}")
|
|
123
|
+
print()
|
|
124
|
+
print("=" * 60)
|
|
125
|
+
print(PROMPT_TEMPLATE)
|
|
126
|
+
for i in issues:
|
|
127
|
+
m = i.get("milestone", {})
|
|
128
|
+
m_title = m.get("title", "—") if m else "—"
|
|
129
|
+
labels = [l["name"] for l in i.get("labels", [])]
|
|
130
|
+
print(f"#{i['number']} [{m_title}] [{','.join(labels) or 'no-labels'}] {i['title']}")
|
|
131
|
+
print("=" * 60)
|
|
132
|
+
print()
|
|
133
|
+
print(f"After agent returns clusters JSON, save to {_answers_path()}")
|
|
134
|
+
print("Then run: python3 ~/.claude/skills/work-plan/work_plan.py group --apply")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _apply(cfg: dict, args: list[str] = None) -> int:
|
|
139
|
+
if args is None:
|
|
140
|
+
args = []
|
|
141
|
+
answers_path = _answers_path()
|
|
142
|
+
batch_path = _batch_path()
|
|
143
|
+
if not answers_path.exists():
|
|
144
|
+
print(f"ERROR: {answers_path} not found. Run without --apply first.")
|
|
145
|
+
return 1
|
|
146
|
+
if not batch_path.exists():
|
|
147
|
+
print(f"ERROR: {batch_path} not found.")
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
batch = json.loads(batch_path.read_text())
|
|
151
|
+
repo = batch["repo"]
|
|
152
|
+
folder = batch["folder"]
|
|
153
|
+
if folder not in cfg.get("repos", {}):
|
|
154
|
+
print(f"ERROR: batch folder '{folder}' not in config.yml repos.")
|
|
155
|
+
return 1
|
|
156
|
+
batch_milestone = batch.get("milestone") or "v1.0.0"
|
|
157
|
+
|
|
158
|
+
# --private: from current args OR stored in batch (so re-invocation is consistent)
|
|
159
|
+
use_private = "--private" in args or batch.get("private", False)
|
|
160
|
+
|
|
161
|
+
answers = json.loads(answers_path.read_text())
|
|
162
|
+
|
|
163
|
+
notes_root = Path(cfg["notes_root"])
|
|
164
|
+
|
|
165
|
+
# Determine track directory: shared (.work-plan/) or private (notes_root/folder/)
|
|
166
|
+
repo_entry = cfg["repos"].get(folder, {})
|
|
167
|
+
local_raw = repo_entry.get("local")
|
|
168
|
+
shared_dir = None
|
|
169
|
+
if not use_private and local_raw:
|
|
170
|
+
local_path = Path(local_raw).expanduser()
|
|
171
|
+
if is_valid_git_repo(local_path):
|
|
172
|
+
shared_dir = local_path / ".work-plan"
|
|
173
|
+
|
|
174
|
+
if shared_dir is not None:
|
|
175
|
+
track_dir = shared_dir
|
|
176
|
+
is_shared_route = True
|
|
177
|
+
else:
|
|
178
|
+
track_dir = notes_root / folder
|
|
179
|
+
is_shared_route = False
|
|
180
|
+
|
|
181
|
+
# Public-repo heads-up (non-blocking) — print once before processing
|
|
182
|
+
if is_shared_route and needs_confirm(repo, cfg):
|
|
183
|
+
print(
|
|
184
|
+
f"HEADS-UP: {repo} is PUBLIC (or visibility unknown) — shared tracks"
|
|
185
|
+
" will be committed publicly. Use --private to keep them local."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not track_dir.exists():
|
|
189
|
+
if is_shared_route:
|
|
190
|
+
track_dir.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
seed_readme(track_dir)
|
|
192
|
+
else:
|
|
193
|
+
print(f"ERROR: {track_dir} doesn't exist. Create it first.")
|
|
194
|
+
return 1
|
|
195
|
+
|
|
196
|
+
issues_by_num = {i["number"]: i for i in batch["issues"]}
|
|
197
|
+
|
|
198
|
+
print(f"Applying {len(answers)} clusters to {track_dir}/")
|
|
199
|
+
created = 0
|
|
200
|
+
updated = 0
|
|
201
|
+
for cluster in answers:
|
|
202
|
+
slug = _slugify(cluster["slug"])
|
|
203
|
+
name = cluster.get("name", slug)
|
|
204
|
+
summary = cluster.get("summary", "")
|
|
205
|
+
cluster_issues = sorted(set(cluster.get("issues") or []))
|
|
206
|
+
if not cluster_issues:
|
|
207
|
+
print(f" SKIP {slug}: no issues")
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
path = track_dir / f"{slug}.md"
|
|
211
|
+
if path.exists():
|
|
212
|
+
existing_meta, existing_body = parse_file(path)
|
|
213
|
+
if not existing_meta:
|
|
214
|
+
print(f" SKIP {slug}: file exists but has no frontmatter; use init first")
|
|
215
|
+
continue
|
|
216
|
+
existing_issues = list(existing_meta.get("github", {}).get("issues") or [])
|
|
217
|
+
merged = sorted(set(existing_issues) | set(cluster_issues))
|
|
218
|
+
existing_meta.setdefault("github", {})["issues"] = merged
|
|
219
|
+
existing_meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
220
|
+
write_file(path, existing_meta, existing_body)
|
|
221
|
+
print(f" ↻ {slug}.md — merged ({len(cluster_issues)} new, "
|
|
222
|
+
f"{len(merged)} total)")
|
|
223
|
+
updated += 1
|
|
224
|
+
else:
|
|
225
|
+
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
226
|
+
meta = {
|
|
227
|
+
"track": slug, "status": "active",
|
|
228
|
+
"launch_priority": "P3",
|
|
229
|
+
"milestone_alignment": batch_milestone,
|
|
230
|
+
"github": {"repo": repo, "issues": cluster_issues, "branches": []},
|
|
231
|
+
"related_tracks": [],
|
|
232
|
+
"last_touched": now, "last_handoff": now,
|
|
233
|
+
"next_up": [], "blockers": [],
|
|
234
|
+
}
|
|
235
|
+
body = _build_body(name, summary, cluster_issues, issues_by_num)
|
|
236
|
+
write_file(path, meta, body)
|
|
237
|
+
print(f" ✓ {slug}.md created ({len(cluster_issues)} issues)")
|
|
238
|
+
if is_shared_route:
|
|
239
|
+
print(" ↑ shared — commit + push to share with teammates.")
|
|
240
|
+
created += 1
|
|
241
|
+
|
|
242
|
+
print()
|
|
243
|
+
print(f"Done: {created} new track files, {updated} updated.")
|
|
244
|
+
print("Next: review priorities (P3 default — edit frontmatter or use slot),")
|
|
245
|
+
print(" then run /work-plan brief.")
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _slugify(s: str) -> str:
|
|
250
|
+
s = s.strip().lower()
|
|
251
|
+
s = re.sub(r"[^a-z0-9-]+", "-", s)
|
|
252
|
+
return s.strip("-") or "untitled"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _build_body(name: str, summary: str, issues: list[int],
|
|
256
|
+
issues_by_num: dict) -> str:
|
|
257
|
+
lines = [f"# {name}\n"]
|
|
258
|
+
if summary:
|
|
259
|
+
lines.append(summary + "\n")
|
|
260
|
+
lines.append("## Issues\n")
|
|
261
|
+
lines.append("| # | Title | Assignee | Status |")
|
|
262
|
+
lines.append("|---|---|---|---|")
|
|
263
|
+
for num in issues:
|
|
264
|
+
i = issues_by_num.get(num, {})
|
|
265
|
+
title = i.get("title", "")
|
|
266
|
+
assignees = i.get("assignees") or []
|
|
267
|
+
assignee_str = ", ".join(f"@{a['login']}" for a in assignees) if assignees else "—"
|
|
268
|
+
state = (i.get("state") or "OPEN").upper()
|
|
269
|
+
status_str = "✅ Shipped" if state == "CLOSED" else "🔲 Open"
|
|
270
|
+
lines.append(f"| #{num} | {title} | {assignee_str} | {status_str} |")
|
|
271
|
+
lines.append("")
|
|
272
|
+
return "\n".join(lines) + "\n"
|