@stylusnexus/work-plan 2026.6.9 → 2026.6.10-2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -15
- package/VERSION +1 -1
- package/bin/work-plan +23 -0
- package/package.json +2 -2
- package/skills/work-plan/SKILL.md +41 -8
- package/skills/work-plan/commands/auto_triage.py +243 -0
- package/skills/work-plan/commands/batch_slot.py +184 -0
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/canonicalize.py +31 -62
- package/skills/work-plan/commands/close.py +21 -6
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +21 -8
- package/skills/work-plan/commands/group.py +86 -10
- package/skills/work-plan/commands/handoff.py +32 -11
- package/skills/work-plan/commands/hygiene.py +29 -3
- package/skills/work-plan/commands/init.py +49 -7
- package/skills/work-plan/commands/init_repo.py +51 -3
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/move.py +131 -0
- package/skills/work-plan/commands/new_track.py +107 -23
- package/skills/work-plan/commands/reconcile.py +175 -33
- package/skills/work-plan/commands/refresh_md.py +125 -43
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/commands/set_field.py +17 -7
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/slot.py +20 -5
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- package/skills/work-plan/commands/where_was_i.py +23 -5
- package/skills/work-plan/lib/config.py +6 -0
- package/skills/work-plan/lib/export_model.py +57 -2
- package/skills/work-plan/lib/frontmatter.py +12 -3
- package/skills/work-plan/lib/git_state.py +61 -52
- package/skills/work-plan/lib/github_state.py +100 -26
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +46 -4
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/lib/tracks.py +214 -19
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
- package/skills/work-plan/tests/test_auto_triage.py +351 -0
- package/skills/work-plan/tests/test_batch_slot.py +291 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_config.py +12 -12
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_export.py +204 -1
- package/skills/work-plan/tests/test_export_command.py +2 -2
- package/skills/work-plan/tests/test_github_state.py +55 -17
- package/skills/work-plan/tests/test_group_apply.py +411 -0
- package/skills/work-plan/tests/test_init_repo.py +140 -7
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_move.py +240 -0
- package/skills/work-plan/tests/test_new_track.py +176 -11
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
- package/skills/work-plan/tests/test_prompts.py +121 -0
- package/skills/work-plan/tests/test_reconcile_move.py +154 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
- package/skills/work-plan/tests/test_refresh_md.py +159 -61
- package/skills/work-plan/tests/test_rename_track.py +351 -0
- package/skills/work-plan/tests/test_repo_filter.py +6 -6
- package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
- package/skills/work-plan/tests/test_set_notes_root.py +6 -2
- package/skills/work-plan/tests/test_status_table.py +61 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +398 -4
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- package/skills/work-plan/work_plan.py +51 -26
- /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
|
@@ -14,9 +14,11 @@ import sys
|
|
|
14
14
|
from datetime import datetime
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
-
from lib.config import load_config, ConfigError
|
|
17
|
+
from lib.config import load_config, ConfigError, is_valid_git_repo
|
|
18
18
|
from lib.frontmatter import parse_file, write_file
|
|
19
|
+
from lib.notes_readme import seed_readme
|
|
19
20
|
from lib.scratch import cache_dir
|
|
21
|
+
from lib.write_guard import needs_confirm
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
def _batch_path() -> Path:
|
|
@@ -61,6 +63,15 @@ def run(args: list[str]) -> int:
|
|
|
61
63
|
label_arg = next((a for a in args if a.startswith("--label=")), None)
|
|
62
64
|
state_arg = next((a for a in args if a.startswith("--state=")), None)
|
|
63
65
|
|
|
66
|
+
limit = 100
|
|
67
|
+
for a in args:
|
|
68
|
+
if a.startswith("--limit="):
|
|
69
|
+
try:
|
|
70
|
+
limit = int(a.split("=", 1)[1])
|
|
71
|
+
except ValueError:
|
|
72
|
+
print("ERROR: --limit must be an integer.")
|
|
73
|
+
return 2
|
|
74
|
+
|
|
64
75
|
try:
|
|
65
76
|
cfg = load_config()
|
|
66
77
|
except ConfigError as e:
|
|
@@ -68,7 +79,7 @@ def run(args: list[str]) -> int:
|
|
|
68
79
|
return 1
|
|
69
80
|
|
|
70
81
|
if apply_mode:
|
|
71
|
-
return _apply(cfg)
|
|
82
|
+
return _apply(cfg, args)
|
|
72
83
|
|
|
73
84
|
# Resolve repo
|
|
74
85
|
repos = list(cfg["repos"].keys())
|
|
@@ -113,6 +124,7 @@ def run(args: list[str]) -> int:
|
|
|
113
124
|
"repo": repo,
|
|
114
125
|
"folder": folder,
|
|
115
126
|
"milestone": milestone_arg.split("=", 1)[1] if milestone_arg else None,
|
|
127
|
+
"private": "--private" in args,
|
|
116
128
|
"issues": issues,
|
|
117
129
|
}, indent=2))
|
|
118
130
|
|
|
@@ -120,11 +132,15 @@ def run(args: list[str]) -> int:
|
|
|
120
132
|
print()
|
|
121
133
|
print("=" * 60)
|
|
122
134
|
print(PROMPT_TEMPLATE)
|
|
123
|
-
|
|
135
|
+
shown = issues[:limit]
|
|
136
|
+
for i in shown:
|
|
124
137
|
m = i.get("milestone", {})
|
|
125
138
|
m_title = m.get("title", "—") if m else "—"
|
|
126
139
|
labels = [l["name"] for l in i.get("labels", [])]
|
|
127
140
|
print(f"#{i['number']} [{m_title}] [{','.join(labels) or 'no-labels'}] {i['title']}")
|
|
141
|
+
remainder = len(issues) - len(shown)
|
|
142
|
+
if remainder > 0:
|
|
143
|
+
print(f"… and {remainder} more issues (use --limit=N to show more)")
|
|
128
144
|
print("=" * 60)
|
|
129
145
|
print()
|
|
130
146
|
print(f"After agent returns clusters JSON, save to {_answers_path()}")
|
|
@@ -132,7 +148,9 @@ def run(args: list[str]) -> int:
|
|
|
132
148
|
return 0
|
|
133
149
|
|
|
134
150
|
|
|
135
|
-
def _apply(cfg: dict) -> int:
|
|
151
|
+
def _apply(cfg: dict, args: list[str] = None) -> int:
|
|
152
|
+
if args is None:
|
|
153
|
+
args = []
|
|
136
154
|
answers_path = _answers_path()
|
|
137
155
|
batch_path = _batch_path()
|
|
138
156
|
if not answers_path.exists():
|
|
@@ -149,13 +167,44 @@ def _apply(cfg: dict) -> int:
|
|
|
149
167
|
print(f"ERROR: batch folder '{folder}' not in config.yml repos.")
|
|
150
168
|
return 1
|
|
151
169
|
batch_milestone = batch.get("milestone") or "v1.0.0"
|
|
170
|
+
|
|
171
|
+
# --private: from current args OR stored in batch (so re-invocation is consistent)
|
|
172
|
+
use_private = "--private" in args or batch.get("private", False)
|
|
173
|
+
|
|
152
174
|
answers = json.loads(answers_path.read_text())
|
|
153
175
|
|
|
154
176
|
notes_root = Path(cfg["notes_root"])
|
|
155
|
-
|
|
177
|
+
|
|
178
|
+
# Determine track directory: shared (.work-plan/) or private (notes_root/folder/)
|
|
179
|
+
repo_entry = cfg["repos"].get(folder, {})
|
|
180
|
+
local_raw = repo_entry.get("local")
|
|
181
|
+
shared_dir = None
|
|
182
|
+
if not use_private and local_raw:
|
|
183
|
+
local_path = Path(local_raw).expanduser()
|
|
184
|
+
if is_valid_git_repo(local_path):
|
|
185
|
+
shared_dir = local_path / ".work-plan"
|
|
186
|
+
|
|
187
|
+
if shared_dir is not None:
|
|
188
|
+
track_dir = shared_dir
|
|
189
|
+
is_shared_route = True
|
|
190
|
+
else:
|
|
191
|
+
track_dir = notes_root / folder
|
|
192
|
+
is_shared_route = False
|
|
193
|
+
|
|
194
|
+
# Public-repo heads-up (non-blocking) — print once before processing
|
|
195
|
+
if is_shared_route and needs_confirm(repo, cfg):
|
|
196
|
+
print(
|
|
197
|
+
f"HEADS-UP: {repo} is PUBLIC (or visibility unknown) — shared tracks"
|
|
198
|
+
" will be committed publicly. Use --private to keep them local."
|
|
199
|
+
)
|
|
200
|
+
|
|
156
201
|
if not track_dir.exists():
|
|
157
|
-
|
|
158
|
-
|
|
202
|
+
if is_shared_route:
|
|
203
|
+
track_dir.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
seed_readme(track_dir)
|
|
205
|
+
else:
|
|
206
|
+
print(f"ERROR: {track_dir} doesn't exist. Create it first.")
|
|
207
|
+
return 1
|
|
159
208
|
|
|
160
209
|
issues_by_num = {i["number"]: i for i in batch["issues"]}
|
|
161
210
|
|
|
@@ -166,7 +215,9 @@ def _apply(cfg: dict) -> int:
|
|
|
166
215
|
slug = _slugify(cluster["slug"])
|
|
167
216
|
name = cluster.get("name", slug)
|
|
168
217
|
summary = cluster.get("summary", "")
|
|
169
|
-
cluster_issues =
|
|
218
|
+
cluster_issues = _sort_by_milestone(
|
|
219
|
+
sorted(set(cluster.get("issues") or [])), issues_by_num, batch_milestone,
|
|
220
|
+
)
|
|
170
221
|
if not cluster_issues:
|
|
171
222
|
print(f" SKIP {slug}: no issues")
|
|
172
223
|
continue
|
|
@@ -178,7 +229,10 @@ def _apply(cfg: dict) -> int:
|
|
|
178
229
|
print(f" SKIP {slug}: file exists but has no frontmatter; use init first")
|
|
179
230
|
continue
|
|
180
231
|
existing_issues = list(existing_meta.get("github", {}).get("issues") or [])
|
|
181
|
-
merged =
|
|
232
|
+
merged = _sort_by_milestone(
|
|
233
|
+
sorted(set(existing_issues) | set(cluster_issues)), issues_by_num,
|
|
234
|
+
existing_meta.get("milestone_alignment") or batch_milestone,
|
|
235
|
+
)
|
|
182
236
|
existing_meta.setdefault("github", {})["issues"] = merged
|
|
183
237
|
existing_meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
184
238
|
write_file(path, existing_meta, existing_body)
|
|
@@ -192,13 +246,15 @@ def _apply(cfg: dict) -> int:
|
|
|
192
246
|
"launch_priority": "P3",
|
|
193
247
|
"milestone_alignment": batch_milestone,
|
|
194
248
|
"github": {"repo": repo, "issues": cluster_issues, "branches": []},
|
|
195
|
-
"
|
|
249
|
+
"depends_on": [],
|
|
196
250
|
"last_touched": now, "last_handoff": now,
|
|
197
251
|
"next_up": [], "blockers": [],
|
|
198
252
|
}
|
|
199
253
|
body = _build_body(name, summary, cluster_issues, issues_by_num)
|
|
200
254
|
write_file(path, meta, body)
|
|
201
255
|
print(f" ✓ {slug}.md created ({len(cluster_issues)} issues)")
|
|
256
|
+
if is_shared_route:
|
|
257
|
+
print(" ↑ shared — commit + push to share with teammates.")
|
|
202
258
|
created += 1
|
|
203
259
|
|
|
204
260
|
print()
|
|
@@ -208,6 +264,26 @@ def _apply(cfg: dict) -> int:
|
|
|
208
264
|
return 0
|
|
209
265
|
|
|
210
266
|
|
|
267
|
+
def _sort_by_milestone(issue_nums, issues_by_num, milestone_alignment=None):
|
|
268
|
+
"""Return issue_nums sorted by milestone then number.
|
|
269
|
+
|
|
270
|
+
milestone_alignment issues come first, then other non-null milestones
|
|
271
|
+
(grouped by label), then null-milestone issues last. Graceful fallback:
|
|
272
|
+
if no milestone data is available, falls back to pure numeric sort.
|
|
273
|
+
"""
|
|
274
|
+
from lib.export_model import milestone_sort_key
|
|
275
|
+
from lib.github_state import short_milestone
|
|
276
|
+
|
|
277
|
+
norm = []
|
|
278
|
+
for num in issue_nums:
|
|
279
|
+
gh = issues_by_num.get(num, {})
|
|
280
|
+
ms = short_milestone(gh.get("milestone")) or None
|
|
281
|
+
norm.append({"number": num, "milestone": ms})
|
|
282
|
+
|
|
283
|
+
norm.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
|
|
284
|
+
return [i["number"] for i in norm]
|
|
285
|
+
|
|
286
|
+
|
|
211
287
|
def _slugify(s: str) -> str:
|
|
212
288
|
s = s.strip().lower()
|
|
213
289
|
s = re.sub(r"[^a-z0-9-]+", "-", s)
|
|
@@ -13,12 +13,13 @@ import subprocess
|
|
|
13
13
|
from datetime import datetime, timedelta
|
|
14
14
|
|
|
15
15
|
from lib.config import load_config, ConfigError
|
|
16
|
-
from lib.tracks import discover_tracks, find_track_by_name
|
|
16
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
17
17
|
from lib.frontmatter import write_file
|
|
18
18
|
from lib.session_log import append_session_log, SESSION_LOG_HEADER
|
|
19
19
|
from lib.git_state import (
|
|
20
20
|
has_uncommitted, current_branch, parse_iso_timestamp,
|
|
21
21
|
gap_seconds_to_label, uncommitted_file_count, commits_ahead,
|
|
22
|
+
is_safe_ref, GIT_TIMEOUT,
|
|
22
23
|
)
|
|
23
24
|
from lib.github_state import fetch_issues, state_to_status_label, extract_priority, short_milestone
|
|
24
25
|
from lib.status_table import update_row_status, sync_missing_rows, find_canonical_status_tables, ISSUE_NUM_RE
|
|
@@ -28,7 +29,7 @@ from lib.prompts import prompt_lines, parse_flags, prompt_input
|
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def run(args: list[str]) -> int:
|
|
31
|
-
flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next"})
|
|
32
|
+
flags, positional = parse_flags(args, {"--interactive", "-i", "--set-next", "--auto-next", "--repo"})
|
|
32
33
|
interactive = flags.get("--interactive", False) or flags.get("-i", False)
|
|
33
34
|
auto_next = flags.get("--auto-next", False)
|
|
34
35
|
|
|
@@ -54,6 +55,14 @@ def run(args: list[str]) -> int:
|
|
|
54
55
|
return 2
|
|
55
56
|
|
|
56
57
|
track_arg = positional[0] if positional else None
|
|
58
|
+
repo_qualifier = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
59
|
+
|
|
60
|
+
# Support <track>@<repo> syntax in positional
|
|
61
|
+
if track_arg:
|
|
62
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
63
|
+
track_arg = name_from_arg
|
|
64
|
+
if repo_from_arg:
|
|
65
|
+
repo_qualifier = repo_from_arg
|
|
57
66
|
|
|
58
67
|
try:
|
|
59
68
|
cfg = load_config()
|
|
@@ -62,7 +71,11 @@ def run(args: list[str]) -> int:
|
|
|
62
71
|
return 1
|
|
63
72
|
|
|
64
73
|
tracks = discover_tracks(cfg)
|
|
65
|
-
|
|
74
|
+
try:
|
|
75
|
+
track = _resolve_track(tracks, track_arg, repo_qualifier=repo_qualifier)
|
|
76
|
+
except AmbiguousTrackError as e:
|
|
77
|
+
print(str(e))
|
|
78
|
+
return 1
|
|
66
79
|
if not track:
|
|
67
80
|
return 1
|
|
68
81
|
|
|
@@ -254,9 +267,9 @@ def _check_next_up_collisions(track, proposed: list[int], cfg: dict) -> bool:
|
|
|
254
267
|
return answer in ("y", "yes")
|
|
255
268
|
|
|
256
269
|
|
|
257
|
-
def _resolve_track(tracks, track_arg):
|
|
270
|
+
def _resolve_track(tracks, track_arg, repo_qualifier=None):
|
|
258
271
|
if track_arg:
|
|
259
|
-
track = find_track_by_name(track_arg, tracks)
|
|
272
|
+
track = find_track_by_name(track_arg, tracks, repo=repo_qualifier)
|
|
260
273
|
if not track:
|
|
261
274
|
print(f"No track matching '{track_arg}'.")
|
|
262
275
|
return track
|
|
@@ -502,12 +515,20 @@ def _recent_commits(track, since_dt) -> list[dict]:
|
|
|
502
515
|
|
|
503
516
|
if branches:
|
|
504
517
|
for b in branches:
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
518
|
+
# A branch name from frontmatter is passed as a positional rev; a
|
|
519
|
+
# dash-led value (e.g. `--output=/path`) would be read by git as an
|
|
520
|
+
# option → arbitrary-file write. Reject before use (#192).
|
|
521
|
+
if not is_safe_ref(str(b)):
|
|
522
|
+
continue
|
|
523
|
+
try:
|
|
524
|
+
proc = subprocess.run(
|
|
525
|
+
["git", "-C", str(track.local_path), "log", b,
|
|
526
|
+
f"--since={since_iso}",
|
|
527
|
+
"--pretty=format:%H|%s|%cI"],
|
|
528
|
+
capture_output=True, text=True, timeout=GIT_TIMEOUT,
|
|
529
|
+
)
|
|
530
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
531
|
+
continue
|
|
511
532
|
if proc.returncode != 0 or not proc.stdout.strip():
|
|
512
533
|
continue
|
|
513
534
|
for line in proc.stdout.strip().split("\n"):
|
|
@@ -14,10 +14,14 @@ is per-repo, so:
|
|
|
14
14
|
that repo;
|
|
15
15
|
- when --repo is absent and config has multiple repos, it's skipped cleanly
|
|
16
16
|
(rather than letting duplicates exit non-zero on the ambiguous case).
|
|
17
|
+
|
|
18
|
+
Pass --timeout=N to set the gh subprocess timeout for the duplicates step
|
|
19
|
+
(default 30s).
|
|
17
20
|
"""
|
|
18
21
|
from commands import refresh_md, reconcile, duplicates
|
|
19
22
|
from lib.config import load_config, ConfigError
|
|
20
23
|
from lib.prompts import parse_flags
|
|
24
|
+
import time
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
def _resolve_repo_folder(repo_key: str, cfg: dict):
|
|
@@ -35,16 +39,28 @@ def _resolve_repo_folder(repo_key: str, cfg: dict):
|
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
def run(args: list[str]) -> int:
|
|
38
|
-
flags, _ = parse_flags(args, {"--yes", "--no-duplicates", "--repo"})
|
|
42
|
+
flags, _ = parse_flags(args, {"--yes", "--no-duplicates", "--repo", "--timeout"})
|
|
39
43
|
skip_dups = flags.get("--no-duplicates", False)
|
|
40
44
|
yes = flags.get("--yes", False)
|
|
41
45
|
repo_key = flags.get("--repo")
|
|
42
46
|
if repo_key is True:
|
|
43
|
-
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>]")
|
|
47
|
+
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]")
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
gh_timeout = None
|
|
51
|
+
raw_timeout = flags.get("--timeout")
|
|
52
|
+
if raw_timeout is not None and raw_timeout is not True:
|
|
53
|
+
try:
|
|
54
|
+
gh_timeout = int(raw_timeout)
|
|
55
|
+
except ValueError:
|
|
56
|
+
print(f"WARNING: invalid --timeout value '{raw_timeout}'; using default")
|
|
57
|
+
elif raw_timeout is True:
|
|
58
|
+
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]")
|
|
44
59
|
return 2
|
|
45
60
|
|
|
46
61
|
scope_label = f" --repo={repo_key}" if repo_key else " --all"
|
|
47
62
|
|
|
63
|
+
t0 = time.time()
|
|
48
64
|
print("=" * 60)
|
|
49
65
|
print(f"WEEKLY HYGIENE — step 1 of 3: refresh-md{scope_label}")
|
|
50
66
|
print("=" * 60)
|
|
@@ -54,21 +70,27 @@ def run(args: list[str]) -> int:
|
|
|
54
70
|
rc = refresh_md.run(refresh_args)
|
|
55
71
|
if rc != 0:
|
|
56
72
|
print(f"\n⚠ refresh-md exited with code {rc}; continuing.")
|
|
73
|
+
print(f" (step 1/3 done in {time.time() - t0:.1f}s)")
|
|
57
74
|
|
|
75
|
+
t1 = time.time()
|
|
58
76
|
print()
|
|
59
77
|
print("=" * 60)
|
|
60
78
|
print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}")
|
|
61
79
|
print("=" * 60)
|
|
62
80
|
reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
|
|
81
|
+
if yes:
|
|
82
|
+
reconcile_args.append("--yes")
|
|
63
83
|
rc = reconcile.run(reconcile_args)
|
|
64
84
|
if rc != 0:
|
|
65
85
|
print(f"\n⚠ reconcile exited with code {rc}; continuing.")
|
|
86
|
+
print(f" (step 2/3 done in {time.time() - t1:.1f}s)")
|
|
66
87
|
|
|
67
88
|
if skip_dups:
|
|
68
89
|
print()
|
|
69
90
|
print("(skipping duplicates per --no-duplicates)")
|
|
70
91
|
return 0
|
|
71
92
|
|
|
93
|
+
t2 = time.time()
|
|
72
94
|
print()
|
|
73
95
|
print("=" * 60)
|
|
74
96
|
print("WEEKLY HYGIENE — step 3 of 3: duplicates")
|
|
@@ -94,11 +116,15 @@ def run(args: list[str]) -> int:
|
|
|
94
116
|
return 0
|
|
95
117
|
# else: 0 or 1 repos → duplicates handles both (errors / single-repo auto-pick)
|
|
96
118
|
|
|
119
|
+
if gh_timeout is not None:
|
|
120
|
+
dupes_args.append(f"--timeout={gh_timeout}")
|
|
121
|
+
|
|
97
122
|
rc = duplicates.run(dupes_args)
|
|
98
123
|
if rc != 0:
|
|
99
124
|
print(f"\n⚠ duplicates exited with code {rc}.")
|
|
125
|
+
print(f" (step 3/3 done in {time.time() - t2:.1f}s)")
|
|
100
126
|
|
|
101
127
|
print()
|
|
102
|
-
print("✓ Weekly hygiene complete. Review the duplicate candidates above and "
|
|
128
|
+
print(f"✓ Weekly hygiene complete ({time.time() - t0:.1f}s total). Review the duplicate candidates above and "
|
|
103
129
|
"consolidate any real dupes via `gh issue close`.")
|
|
104
130
|
return 0
|
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import re
|
|
4
4
|
from datetime import datetime
|
|
5
5
|
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
6
7
|
|
|
7
8
|
from lib.config import load_config, ConfigError, resolve_github_for_folder
|
|
8
9
|
from lib.frontmatter import parse_file, write_file
|
|
@@ -12,6 +13,21 @@ from lib.write_guard import needs_confirm, make_token, valid_token
|
|
|
12
13
|
_VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
|
|
13
14
|
|
|
14
15
|
|
|
16
|
+
def _find_repo_for_shared_path(path: Path, cfg: dict) -> Optional[str]:
|
|
17
|
+
"""If path is inside a .work-plan/ dir, find the configured github repo for that clone."""
|
|
18
|
+
# Walk up the path looking for a .work-plan ancestor
|
|
19
|
+
for parent in path.parents:
|
|
20
|
+
if parent.name == ".work-plan":
|
|
21
|
+
clone_root = parent.parent
|
|
22
|
+
for folder, entry in cfg.get("repos", {}).items():
|
|
23
|
+
if entry.get("local"):
|
|
24
|
+
local = Path(entry["local"]).expanduser().resolve()
|
|
25
|
+
if local == clone_root.resolve():
|
|
26
|
+
return entry.get("github")
|
|
27
|
+
return None # In .work-plan/ but not registered
|
|
28
|
+
return None # Not in a .work-plan/
|
|
29
|
+
|
|
30
|
+
|
|
15
31
|
def run(args: list[str]) -> int:
|
|
16
32
|
flags, positional = parse_flags(args, {"--priority", "--milestone", "--confirm"})
|
|
17
33
|
|
|
@@ -37,13 +53,37 @@ def run(args: list[str]) -> int:
|
|
|
37
53
|
|
|
38
54
|
slug = re.sub(r"[^a-z0-9-]+", "-", path.stem.lower()).strip("-")
|
|
39
55
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
# Detect if this path is inside a .work-plan/ shared directory
|
|
57
|
+
is_shared = ".work-plan" in path.parts
|
|
58
|
+
tier = "shared" if is_shared else None
|
|
59
|
+
|
|
60
|
+
if is_shared:
|
|
61
|
+
repo = _find_repo_for_shared_path(path, cfg)
|
|
62
|
+
if repo is None:
|
|
63
|
+
print(
|
|
64
|
+
"ERROR: path is inside a .work-plan/ directory but its repo isn't"
|
|
65
|
+
" registered in config — run init-repo first"
|
|
66
|
+
)
|
|
67
|
+
return 1
|
|
45
68
|
folder = None
|
|
46
|
-
|
|
69
|
+
else:
|
|
70
|
+
# Containment guard (#195): a non-shared target MUST live under
|
|
71
|
+
# notes_root. Without this, `init /etc/anything` (any user-writable
|
|
72
|
+
# file with no frontmatter) would get frontmatter prepended via
|
|
73
|
+
# write_file, clobbering it. `path` is already resolved; resolve
|
|
74
|
+
# notes_root too so the comparison is symlink/relative-safe.
|
|
75
|
+
notes_root = Path(cfg["notes_root"]).expanduser().resolve()
|
|
76
|
+
try:
|
|
77
|
+
rel = path.relative_to(notes_root)
|
|
78
|
+
except ValueError:
|
|
79
|
+
print(
|
|
80
|
+
f"ERROR: {path} is not inside notes_root ({notes_root}) or a"
|
|
81
|
+
" registered .work-plan/ directory — refusing to write"
|
|
82
|
+
" frontmatter outside the tracked tree."
|
|
83
|
+
)
|
|
84
|
+
return 1
|
|
85
|
+
folder = rel.parts[0] if len(rel.parts) > 1 else None
|
|
86
|
+
repo = resolve_github_for_folder(folder, cfg) if folder else None
|
|
47
87
|
|
|
48
88
|
issue_nums = sorted(set(int(m) for m in re.findall(r"#(\d+)", body)))
|
|
49
89
|
|
|
@@ -79,6 +119,8 @@ def run(args: list[str]) -> int:
|
|
|
79
119
|
print(f"Initializing: {path.name}")
|
|
80
120
|
print(f" track: {slug}")
|
|
81
121
|
print(f" repo: {repo or '(unknown — will set TBD)'}")
|
|
122
|
+
if tier == "shared":
|
|
123
|
+
print(" tier: shared")
|
|
82
124
|
print(f" issues found in body: {issue_nums or '(none)'}")
|
|
83
125
|
|
|
84
126
|
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
@@ -87,7 +129,7 @@ def run(args: list[str]) -> int:
|
|
|
87
129
|
"launch_priority": priority,
|
|
88
130
|
"milestone_alignment": milestone,
|
|
89
131
|
"github": {"repo": repo or "TBD", "issues": issue_nums, "branches": []},
|
|
90
|
-
"
|
|
132
|
+
"depends_on": [],
|
|
91
133
|
"last_touched": now, "last_handoff": now,
|
|
92
134
|
"next_up": [], "blockers": [],
|
|
93
135
|
}
|
|
@@ -3,14 +3,53 @@
|
|
|
3
3
|
Non-interactive: --github is required; --local is optional (no prompts).
|
|
4
4
|
"""
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
7
8
|
import subprocess
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
|
-
from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH
|
|
11
|
+
from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH, is_valid_git_repo
|
|
11
12
|
from lib.prompts import parse_flags
|
|
12
13
|
|
|
13
14
|
|
|
15
|
+
def _count_shared_tracks(work_plan_dir: Path) -> int:
|
|
16
|
+
"""Count eligible .md files in a .work-plan/ directory.
|
|
17
|
+
|
|
18
|
+
Excludes: README.md, dotfiles, and anything inside archive/.
|
|
19
|
+
"""
|
|
20
|
+
count = 0
|
|
21
|
+
for p in work_plan_dir.iterdir():
|
|
22
|
+
if p.is_dir():
|
|
23
|
+
continue
|
|
24
|
+
if p.name.startswith("."):
|
|
25
|
+
continue
|
|
26
|
+
if p.name.lower() == "readme.md":
|
|
27
|
+
continue
|
|
28
|
+
if p.suffix == ".md":
|
|
29
|
+
count += 1
|
|
30
|
+
return count
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _report_shared_tracks(local_path: "Path | None") -> None:
|
|
34
|
+
"""Print a status line about shared tracks found in .work-plan/ (if any).
|
|
35
|
+
|
|
36
|
+
If local_path is None, not a valid git repo, or has no .work-plan/ dir,
|
|
37
|
+
prints the registration-only fallback message instead.
|
|
38
|
+
"""
|
|
39
|
+
if local_path is None or not is_valid_git_repo(local_path):
|
|
40
|
+
print()
|
|
41
|
+
print("ℹ No valid local clone provided — registered for future use.")
|
|
42
|
+
print(" Run 'work-plan init-repo <key> --local=<path>' to add the clone path later.")
|
|
43
|
+
return
|
|
44
|
+
work_plan_dir = local_path / ".work-plan"
|
|
45
|
+
if work_plan_dir.is_dir():
|
|
46
|
+
n = _count_shared_tracks(work_plan_dir)
|
|
47
|
+
print(
|
|
48
|
+
f"ℹ Found {n} shared track(s) in {work_plan_dir}/"
|
|
49
|
+
" — they'll appear after 'work-plan brief'."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
14
53
|
def run(args: list[str]) -> int:
|
|
15
54
|
flags, positional = parse_flags(args, {"--github", "--local"})
|
|
16
55
|
if not positional:
|
|
@@ -45,6 +84,7 @@ def run(args: list[str]) -> int:
|
|
|
45
84
|
|
|
46
85
|
# --local is optional; if absent, skip (no prompt)
|
|
47
86
|
local = flags.get("--local") or None
|
|
87
|
+
local_path = None
|
|
48
88
|
if local:
|
|
49
89
|
local_path = Path(local).expanduser()
|
|
50
90
|
if not local_path.exists():
|
|
@@ -67,15 +107,23 @@ def run(args: list[str]) -> int:
|
|
|
67
107
|
print(f" ├── archive/shipped/")
|
|
68
108
|
print(f" └── archive/abandoned/")
|
|
69
109
|
|
|
110
|
+
# Detect existing shared tracks in .work-plan/ inside the local clone
|
|
111
|
+
_report_shared_tracks(local_path)
|
|
112
|
+
|
|
70
113
|
repo_block = {"github": github}
|
|
71
114
|
if local:
|
|
72
115
|
repo_block["local"] = local
|
|
73
116
|
|
|
74
|
-
|
|
117
|
+
# `key` is validated against ^[a-z][a-z0-9-]*$ above, so it's safe in the yq
|
|
118
|
+
# path. The repo block is passed as an OPAQUE env value via env() (parsed as
|
|
119
|
+
# YAML/JSON) rather than interpolated into the expression — uniform with the
|
|
120
|
+
# strenv() hardening in set-notes-root (#196).
|
|
121
|
+
env = {**os.environ, "WP_REPO_BLOCK": json.dumps(repo_block)}
|
|
122
|
+
yq_expr = f".repos.{key} = env(WP_REPO_BLOCK)"
|
|
75
123
|
try:
|
|
76
124
|
subprocess.run(
|
|
77
125
|
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
78
|
-
check=True, capture_output=True, text=True,
|
|
126
|
+
check=True, capture_output=True, text=True, env=env,
|
|
79
127
|
)
|
|
80
128
|
except subprocess.CalledProcessError as e:
|
|
81
129
|
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
"""list subcommand."""
|
|
2
2
|
from lib.config import load_config, ConfigError
|
|
3
|
-
from lib.tracks import
|
|
3
|
+
from lib.tracks import (
|
|
4
|
+
discover_tracks, discover_archived_tracks,
|
|
5
|
+
priority_rank, recency_sort_key,
|
|
6
|
+
)
|
|
7
|
+
from lib.prompts import parse_flags
|
|
8
|
+
|
|
9
|
+
_VALID_SORTS = ("recent", "priority")
|
|
4
10
|
|
|
5
11
|
|
|
6
12
|
def run(args: list[str]) -> int:
|
|
7
|
-
|
|
13
|
+
flags, _ = parse_flags(args, {"--all", "--sort"})
|
|
14
|
+
show_all = "--all" in flags
|
|
15
|
+
sort_mode = flags.get("--sort")
|
|
16
|
+
if sort_mode is True or (sort_mode and sort_mode not in _VALID_SORTS):
|
|
17
|
+
print(f"usage: work_plan.py list [--all] [--sort={'|'.join(_VALID_SORTS)}]")
|
|
18
|
+
return 2
|
|
19
|
+
|
|
8
20
|
try:
|
|
9
21
|
cfg = load_config()
|
|
10
22
|
except ConfigError as e:
|
|
@@ -16,17 +28,19 @@ def run(args: list[str]) -> int:
|
|
|
16
28
|
print(f"No tracks found under {cfg['notes_root']}")
|
|
17
29
|
return 0
|
|
18
30
|
|
|
31
|
+
tracks = _sort_tracks(tracks, sort_mode)
|
|
32
|
+
|
|
19
33
|
print(f"Tracks under {cfg['notes_root']}:\n")
|
|
20
34
|
for t in tracks:
|
|
21
35
|
status = t.meta.get("status", "(no frontmatter)")
|
|
22
36
|
priority = t.meta.get("launch_priority", "—")
|
|
23
37
|
repo = t.repo or "(no repo)"
|
|
24
|
-
|
|
38
|
+
flags_out = []
|
|
25
39
|
if t.needs_init:
|
|
26
|
-
|
|
40
|
+
flags_out.append("NEEDS INIT")
|
|
27
41
|
if t.needs_filing:
|
|
28
|
-
|
|
29
|
-
flag_str = f" [{', '.join(
|
|
42
|
+
flags_out.append("NEEDS FILING")
|
|
43
|
+
flag_str = f" [{', '.join(flags_out)}]" if flags_out else ""
|
|
30
44
|
print(f" {t.name:30} {status:14} {priority:3} {repo}{flag_str}")
|
|
31
45
|
|
|
32
46
|
if show_all:
|
|
@@ -37,3 +51,17 @@ def run(args: list[str]) -> int:
|
|
|
37
51
|
end_state = a.meta.get("status", "?")
|
|
38
52
|
print(f" {a.name:30} {end_state:14} {a.repo or '(no repo)'}")
|
|
39
53
|
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _sort_tracks(tracks: list, sort_mode):
|
|
57
|
+
"""Order active tracks per --sort. None preserves discovery order.
|
|
58
|
+
|
|
59
|
+
- "recent": by last_touched descending (missing last_touched sorts last).
|
|
60
|
+
- "priority": by launch_priority ascending (P0→P3, then missing/other),
|
|
61
|
+
with last_touched recency as tiebreaker.
|
|
62
|
+
"""
|
|
63
|
+
if sort_mode == "recent":
|
|
64
|
+
return sorted(tracks, key=lambda t: recency_sort_key(t.meta))
|
|
65
|
+
if sort_mode == "priority":
|
|
66
|
+
return sorted(tracks, key=lambda t: (priority_rank(t.meta), recency_sort_key(t.meta)))
|
|
67
|
+
return tracks
|