@stylusnexus/work-plan 2026.6.9-4 → 2026.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/hygiene.py +2 -0
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/reconcile.py +106 -17
- package/skills/work-plan/lib/prompts.py +34 -3
- package/skills/work-plan/lib/tracks.py +27 -0
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_prompts.py +121 -0
- package/skills/work-plan/tests/test_reconcile_move.py +154 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +19 -0
- package/skills/work-plan/work_plan.py +4 -4
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ The five essentials you'll use 80% of the time are:
|
|
|
51
51
|
| `/work-plan brief` | Morning. Multi-track snapshot — what's on your plate across every active track. Add `--repo=<key>` to scope to one project. |
|
|
52
52
|
| `/work-plan handoff <track>` | End of a work block. Captures what you touched. Use `--auto-next` for an algorithmic priority-sorted `next_up` (no LLM), `--set-next 1,2,3` for explicit numbers, or pair with Claude in chat for a curated pick. |
|
|
53
53
|
| `/work-plan orient <track>` | Switching context. ~15-line paste-block of priority / last session / next pick / git state — drop into a fresh Claude Code terminal. |
|
|
54
|
-
| `/work-plan reconcile <track> \| --all \| --repo=<key> [--draft]` | Track frontmatter membership drifted from GitHub labels. Use on label-driven tracks only — for hand-curated tracks, use `refresh-md` instead. `--draft` previews proposed ADDs/FLAGs without prompting
|
|
54
|
+
| `/work-plan reconcile <track> \| --all \| --repo=<key> [--draft] [--yes]` | Track frontmatter membership drifted from GitHub labels. Use on label-driven tracks only — for hand-curated tracks, use `refresh-md` instead. In an `--all`/`--repo` sweep it also moves issues relabeled from one track to another in the same repo. `--draft` previews proposed ADDs/MOVEs/FLAGs; `--yes` applies without prompting. `--repo=<key>` scopes the sweep to one repo. |
|
|
55
55
|
| `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Runs three steps: ① `refresh-md --all` (pull live GitHub state into every active track's status table), ② `reconcile --all` (sync frontmatter membership against GitHub labels), ③ `duplicates` (flag likely-duplicate issues). `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode. |
|
|
56
56
|
|
|
57
57
|
A dozen more subcommands cover slotting new issues into tracks, closing tracks (shipped/abandoned/parked), and one-time priority-label backfill. Three capabilities worth calling out explicitly:
|
|
@@ -493,7 +493,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
493
493
|
| `close <track> [--state=shipped\|parked\|abandoned] [--note=<text>]` | Mark track shipped, parked, or abandoned. Moves to `archive/<state>/` for shipped/abandoned. Pass `--state=` (and an optional `--note=`) to run without prompts. |
|
|
494
494
|
| `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Update issue STATE (open/closed, status labels) inside the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
|
|
495
495
|
| `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `duplicates`. With `--repo=<key>`, steps 1 and 2 scope to that repo and the global `duplicates` step is skipped. |
|
|
496
|
-
| `list [--all]` | List active tracks (or all including parked/archived). |
|
|
496
|
+
| `list [--all] [--sort=recent\|priority]` | List active tracks (or all including parked/archived). `--sort=recent` orders by `last_touched` (most recent first); `--sort=priority` orders by `launch_priority` (P0→P3) with recency as tiebreaker. Default keeps discovery order. |
|
|
497
497
|
| `init <path> [--priority=P0..P3] [--milestone=<m>]` | Add frontmatter to a brand-new track .md file (the file must already exist). Pass `--priority=`/`--milestone=` to skip the prompts. |
|
|
498
498
|
| `init-repo <key> --github=<slug> [--local=<path>]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required; `--local` is optional. |
|
|
499
499
|
| `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
|
|
@@ -502,7 +502,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
502
502
|
| `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). |
|
|
503
503
|
| `auto-triage [--repo=<key>] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
|
|
504
504
|
| `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
|
|
505
|
-
| `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). `--draft` previews ADDs/FLAGs without prompting or writing. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
|
|
505
|
+
| `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews ADDs/MOVEs/FLAGs without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
|
|
506
506
|
| `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
|
|
507
507
|
| `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). |
|
|
508
508
|
| `plan-status [--repo=<key>] [--json] [--stamp [--draft]] [--llm [--apply]] [--archive \| --issues] [--draft]` | Reach a verdict on every plan/spec doc in a repo by correlating its declared file-manifest against git + the filesystem: ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less / 🧳 foreign. Read-only by default. `--stamp` writes an idempotent status header into each doc (`--draft` previews); `--llm` runs a two-step AI verdict on prose/ambiguous docs; `--archive` moves dead plans to `archive/abandoned/` and `--issues` opens issues for partial plans (both gated, both honor `--draft`); `--json` for machine output. |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.10+a6052bf
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.10",
|
|
4
4
|
"description": "Track-aware daily work planning over GitHub issues. Shared tracks (git-synced .work-plan/ in each repo), AI clustering (group/auto-triage), VS Code viewer, Claude Code + Codex plugins. Pure Python stdlib.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"work-plan": "bin/work-plan"
|
|
@@ -3,7 +3,10 @@ from datetime import datetime
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
from lib.config import load_config, ConfigError
|
|
6
|
-
from lib.tracks import
|
|
6
|
+
from lib.tracks import (
|
|
7
|
+
discover_tracks, discover_archived_tracks, filter_tracks_by_repo,
|
|
8
|
+
priority_rank, recency_sort_key,
|
|
9
|
+
)
|
|
7
10
|
from lib.github_state import fetch_issues, extract_priority, short_milestone
|
|
8
11
|
from lib.prompts import parse_flags
|
|
9
12
|
from lib.git_state import (
|
|
@@ -193,11 +196,8 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
|
|
|
193
196
|
return gap_seconds_to_label(int(gs))
|
|
194
197
|
|
|
195
198
|
in_prog_rank = 0 if operational_status == "in-progress" else 1
|
|
196
|
-
pri_rank =
|
|
197
|
-
recency_key = (
|
|
198
|
-
-parse_iso_timestamp(meta["last_touched"]).timestamp()
|
|
199
|
-
if meta.get("last_touched") else 0
|
|
200
|
-
)
|
|
199
|
+
pri_rank = priority_rank(meta)
|
|
200
|
+
recency_key = recency_sort_key(meta)
|
|
201
201
|
|
|
202
202
|
return {
|
|
203
203
|
"name": meta.get("track", track.name),
|
|
@@ -78,6 +78,8 @@ def run(args: list[str]) -> int:
|
|
|
78
78
|
print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}")
|
|
79
79
|
print("=" * 60)
|
|
80
80
|
reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
|
|
81
|
+
if yes:
|
|
82
|
+
reconcile_args.append("--yes")
|
|
81
83
|
rc = reconcile.run(reconcile_args)
|
|
82
84
|
if rc != 0:
|
|
83
85
|
print(f"\n⚠ reconcile exited with code {rc}; continuing.")
|
|
@@ -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
|
|
@@ -12,8 +12,11 @@ For a given track:
|
|
|
12
12
|
of "anything closed looks unlabeled."
|
|
13
13
|
- Compare against frontmatter `github.issues`.
|
|
14
14
|
- Propose ADDS (labeled in GitHub but missing from frontmatter).
|
|
15
|
-
- Propose
|
|
16
|
-
|
|
15
|
+
- Propose MOVES (in track A's frontmatter, but now labeled for exactly one
|
|
16
|
+
other active track B in the same repo — a relabel; remove from A, add to B).
|
|
17
|
+
- Propose FLAGS (in frontmatter but no longer labeled, with no single move
|
|
18
|
+
target — possible orphan).
|
|
19
|
+
- User confirms before writing to the LOCAL frontmatter file(s).
|
|
17
20
|
|
|
18
21
|
READ-ONLY GITHUB CONTRACT
|
|
19
22
|
reconcile only READS GitHub via `gh issue list` and `gh pr list`. It NEVER
|
|
@@ -32,6 +35,7 @@ from lib.config import load_config, ConfigError
|
|
|
32
35
|
from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
|
|
33
36
|
from lib.frontmatter import write_file
|
|
34
37
|
from lib.prompts import parse_flags, prompt_input
|
|
38
|
+
from lib.write_guard import needs_confirm
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
PER_TRACK_TIMEOUT = 15 # seconds; each gh call gets this budget
|
|
@@ -86,17 +90,18 @@ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
|
|
|
86
90
|
|
|
87
91
|
|
|
88
92
|
def run(args: list[str]) -> int:
|
|
89
|
-
flags, positional = parse_flags(args, {"--all", "--draft", "--repo"})
|
|
93
|
+
flags, positional = parse_flags(args, {"--all", "--draft", "--repo", "--yes"})
|
|
90
94
|
do_all = flags.get("--all", False)
|
|
91
95
|
draft = flags.get("--draft", False)
|
|
96
|
+
yes = flags.get("--yes", False)
|
|
92
97
|
repo_key = flags.get("--repo")
|
|
93
98
|
if repo_key is True:
|
|
94
|
-
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
|
|
99
|
+
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
|
|
95
100
|
return 2
|
|
96
101
|
track_arg = positional[0] if positional else None
|
|
97
102
|
|
|
98
103
|
if not do_all and not track_arg and not repo_key:
|
|
99
|
-
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
|
|
104
|
+
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
|
|
100
105
|
return 2
|
|
101
106
|
|
|
102
107
|
track_name = track_arg
|
|
@@ -167,7 +172,52 @@ def run(args: list[str]) -> int:
|
|
|
167
172
|
print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
|
|
168
173
|
results[track.name] = None
|
|
169
174
|
|
|
170
|
-
# Phase
|
|
175
|
+
# Phase 2a: index which fetched track(s) label each issue. Used to turn a
|
|
176
|
+
# bare FLAG (in a track's frontmatter, but it has lost that track's label)
|
|
177
|
+
# into a MOVE when the issue is now labeled for exactly one OTHER active
|
|
178
|
+
# track in the same repo.
|
|
179
|
+
labeled_index: dict = {} # issue number -> list[track]
|
|
180
|
+
for track in targets:
|
|
181
|
+
if not track.repo or results.get(track.name) is None:
|
|
182
|
+
continue
|
|
183
|
+
for num in {i["number"] for i in results[track.name]}:
|
|
184
|
+
labeled_index.setdefault(num, []).append(track)
|
|
185
|
+
|
|
186
|
+
# Phase 2b: detect cross-track moves (#163). An issue qualifies when it is
|
|
187
|
+
# in track A's frontmatter, no longer carries A's label, and is now labeled
|
|
188
|
+
# by exactly one OTHER active track B in the same repo. Ambiguous cases
|
|
189
|
+
# (two or more candidate targets) stay as plain FLAGs.
|
|
190
|
+
moved_out: dict = {} # src track name -> set(num)
|
|
191
|
+
moved_in: dict = {} # dst track name -> set(num)
|
|
192
|
+
move_dst: dict = {} # (src track name, num) -> dst track
|
|
193
|
+
for track in targets:
|
|
194
|
+
if not track.repo or results.get(track.name) is None:
|
|
195
|
+
continue
|
|
196
|
+
labeled_nums = {i["number"] for i in results[track.name]}
|
|
197
|
+
listed_nums = set(track.meta.get("github", {}).get("issues") or [])
|
|
198
|
+
for num in sorted(listed_nums - labeled_nums):
|
|
199
|
+
cands = [b for b in labeled_index.get(num, [])
|
|
200
|
+
if b is not track and b.repo == track.repo]
|
|
201
|
+
if len(cands) == 1:
|
|
202
|
+
dst = cands[0]
|
|
203
|
+
moved_out.setdefault(track.name, set()).add(num)
|
|
204
|
+
moved_in.setdefault(dst.name, set()).add(num)
|
|
205
|
+
move_dst[(track.name, num)] = dst
|
|
206
|
+
|
|
207
|
+
# Phase 2c: per-track diff, report, confirm. Membership changes accumulate
|
|
208
|
+
# in `final` (track name -> desired issue set); each affected track is
|
|
209
|
+
# written exactly ONCE at the end, so a move that touches two tracks never
|
|
210
|
+
# double-writes or clobbers a sibling's accepted ADDs. A move is governed by
|
|
211
|
+
# the confirmation on its SOURCE track (where the issue currently lives).
|
|
212
|
+
final: dict = {} # track name -> set(num)
|
|
213
|
+
affected: dict = {} # track name -> track (only those we may write)
|
|
214
|
+
|
|
215
|
+
def _final_for(t):
|
|
216
|
+
if t.name not in final:
|
|
217
|
+
final[t.name] = set(t.meta.get("github", {}).get("issues") or [])
|
|
218
|
+
affected[t.name] = t
|
|
219
|
+
return final[t.name]
|
|
220
|
+
|
|
171
221
|
any_changes = False
|
|
172
222
|
for track in targets:
|
|
173
223
|
slug = track.meta.get("track", track.name)
|
|
@@ -181,11 +231,14 @@ def run(args: list[str]) -> int:
|
|
|
181
231
|
labels = _resolve_labels(track)
|
|
182
232
|
labeled_nums = {i["number"] for i in labeled}
|
|
183
233
|
listed_nums = set(track.meta.get("github", {}).get("issues") or [])
|
|
234
|
+
out_moves = sorted(moved_out.get(track.name, set()))
|
|
184
235
|
|
|
185
|
-
|
|
186
|
-
|
|
236
|
+
# MOVE issues are reported (and applied) as moves, not as ADD on the
|
|
237
|
+
# destination or FLAG on the source.
|
|
238
|
+
adds = sorted(labeled_nums - listed_nums - moved_in.get(track.name, set()))
|
|
239
|
+
flag_nums = sorted(listed_nums - labeled_nums - moved_out.get(track.name, set()))
|
|
187
240
|
|
|
188
|
-
if not adds and not flag_nums:
|
|
241
|
+
if not adds and not flag_nums and not out_moves:
|
|
189
242
|
continue
|
|
190
243
|
|
|
191
244
|
any_changes = True
|
|
@@ -197,6 +250,13 @@ def run(args: list[str]) -> int:
|
|
|
197
250
|
for num in adds:
|
|
198
251
|
i = issue_lookup[num]
|
|
199
252
|
print(f" #{num} ({i['state'].lower()}) {i['title']}")
|
|
253
|
+
if out_moves:
|
|
254
|
+
print(f" MOVE ({len(out_moves)}) — relabeled to another track in this repo:")
|
|
255
|
+
for num in out_moves:
|
|
256
|
+
dst = move_dst[(track.name, num)]
|
|
257
|
+
dst_slug = dst.meta.get("track", dst.name)
|
|
258
|
+
pub = " [dst PUBLIC]" if needs_confirm(dst.repo, cfg) else ""
|
|
259
|
+
print(f" #{num} {slug} → {dst_slug}{pub}")
|
|
200
260
|
if flag_nums:
|
|
201
261
|
print(f" FLAG ({len(flag_nums)}) — in frontmatter but missing every configured label:")
|
|
202
262
|
for num in flag_nums:
|
|
@@ -204,8 +264,8 @@ def run(args: list[str]) -> int:
|
|
|
204
264
|
|
|
205
265
|
if listed_nums and len(flag_nums) / len(listed_nums) > 0.5:
|
|
206
266
|
print(f"\n ⓘ {len(flag_nums)}/{len(listed_nums)} frontmatter issues lack the configured label(s).")
|
|
207
|
-
print(
|
|
208
|
-
print(
|
|
267
|
+
print(" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
|
|
268
|
+
print(" If you just want to update issue state in the body table, try:")
|
|
209
269
|
print(f" /work-plan refresh-md {slug}")
|
|
210
270
|
|
|
211
271
|
if draft:
|
|
@@ -213,12 +273,41 @@ def run(args: list[str]) -> int:
|
|
|
213
273
|
# Useful for sweep audits and scripted reports.
|
|
214
274
|
continue
|
|
215
275
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
276
|
+
if yes:
|
|
277
|
+
# Non-interactive: apply ADDs + MOVEs without prompting. All writes
|
|
278
|
+
# are local frontmatter — the read-only-GitHub contract is unchanged.
|
|
279
|
+
print(f"\n --yes: applying changes from {track.path.name}")
|
|
280
|
+
choice = "y"
|
|
281
|
+
else:
|
|
282
|
+
choice = prompt_input(f"\n Apply ADDs/MOVEs from {track.path.name}? [y/N]").lower()
|
|
283
|
+
if choice != "y":
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if adds:
|
|
287
|
+
_final_for(track).update(adds)
|
|
288
|
+
for num in out_moves:
|
|
289
|
+
dst = move_dst[(track.name, num)]
|
|
290
|
+
# Public-repo guard (#163): under --yes we never silently write
|
|
291
|
+
# membership into a PUBLIC/shared destination track — that move is
|
|
292
|
+
# skipped with a pointer to the gated `move` verb. Interactive runs
|
|
293
|
+
# treat the prompt above as the confirmation.
|
|
294
|
+
if yes and needs_confirm(dst.repo, cfg):
|
|
295
|
+
dst_slug = dst.meta.get("track", dst.name)
|
|
296
|
+
print(f" ⏭ skipped MOVE #{num} → {dst_slug} ({dst.repo} is PUBLIC; "
|
|
297
|
+
f"run `/work-plan move {num} {slug} {dst_slug} --confirm` instead)")
|
|
298
|
+
continue
|
|
299
|
+
_final_for(track).discard(num)
|
|
300
|
+
_final_for(dst).add(num)
|
|
301
|
+
|
|
302
|
+
# Write each affected track exactly once, only if its set actually changed.
|
|
303
|
+
for name, issues in final.items():
|
|
304
|
+
track = affected[name]
|
|
305
|
+
original = set(track.meta.get("github", {}).get("issues") or [])
|
|
306
|
+
if issues == original:
|
|
307
|
+
continue
|
|
308
|
+
track.meta.setdefault("github", {})["issues"] = sorted(issues)
|
|
309
|
+
write_file(track.path, track.meta, track.body)
|
|
310
|
+
print(f" ✓ Updated {track.path.name}")
|
|
222
311
|
|
|
223
312
|
if not any_changes:
|
|
224
313
|
print("All tracks in sync with configured labels.")
|
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
"""Shared CLI helpers: prompts and arg parsing."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _stdin_is_interactive() -> bool:
|
|
6
|
+
"""True only when stdin is a real terminal we can block on for a reply.
|
|
7
|
+
|
|
8
|
+
When the CLI is launched with stdin wired to a pipe or socket that stays
|
|
9
|
+
open but never delivers a line — e.g. the VS Code extension spawning
|
|
10
|
+
`work_plan.py` — `input()` blocks forever (no data, no EOF). A closed pipe
|
|
11
|
+
raises EOFError and is handled; an *idle open* one hangs. Guarding on
|
|
12
|
+
`isatty()` lets the prompt helpers fall back to their default instead of
|
|
13
|
+
deadlocking. Non-interactive callers should pass an explicit flag
|
|
14
|
+
(`--yes`, `--draft`) rather than rely on the prompt.
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
return bool(sys.stdin) and sys.stdin.isatty()
|
|
18
|
+
except (ValueError, AttributeError):
|
|
19
|
+
# stdin closed/detached, or replaced by an object without isatty.
|
|
20
|
+
return False
|
|
2
21
|
|
|
3
22
|
|
|
4
23
|
def prompt_input(message: str, default: str = "") -> str:
|
|
5
24
|
"""Print prompt and read a free-form line. Treats EOF (no stdin) as default.
|
|
6
25
|
|
|
7
|
-
Returns the stripped input, or `default` if EOF or
|
|
26
|
+
Returns the stripped input, or `default` if EOF, blank, or there is no
|
|
27
|
+
interactive terminal to read from.
|
|
8
28
|
"""
|
|
9
29
|
print(message)
|
|
30
|
+
if not _stdin_is_interactive():
|
|
31
|
+
print(f"(no interactive terminal — using default {default!r})")
|
|
32
|
+
return default
|
|
10
33
|
try:
|
|
11
34
|
line = input().strip()
|
|
12
35
|
except EOFError:
|
|
@@ -15,7 +38,12 @@ def prompt_input(message: str, default: str = "") -> str:
|
|
|
15
38
|
|
|
16
39
|
|
|
17
40
|
def prompt_lines() -> list[str]:
|
|
18
|
-
"""Read lines from stdin until blank line or EOF. Returns list of non-blank lines.
|
|
41
|
+
"""Read lines from stdin until blank line or EOF. Returns list of non-blank lines.
|
|
42
|
+
|
|
43
|
+
With no interactive terminal, returns an empty list rather than blocking.
|
|
44
|
+
"""
|
|
45
|
+
if not _stdin_is_interactive():
|
|
46
|
+
return []
|
|
19
47
|
out = []
|
|
20
48
|
try:
|
|
21
49
|
while True:
|
|
@@ -29,11 +57,14 @@ def prompt_lines() -> list[str]:
|
|
|
29
57
|
|
|
30
58
|
|
|
31
59
|
def prompt_yes_no(message: str = "Apply? [y/N]") -> bool:
|
|
32
|
-
"""Print prompt and read y/N. Treats EOF
|
|
60
|
+
"""Print prompt and read y/N. Treats EOF or no terminal as no.
|
|
33
61
|
|
|
34
62
|
Returns True only if user explicitly types 'y' (case-insensitive).
|
|
35
63
|
"""
|
|
36
64
|
print(message)
|
|
65
|
+
if not _stdin_is_interactive():
|
|
66
|
+
print("(no interactive terminal — defaulting to no)")
|
|
67
|
+
return False
|
|
37
68
|
try:
|
|
38
69
|
choice = input().strip().lower()
|
|
39
70
|
except EOFError:
|
|
@@ -10,6 +10,33 @@ from lib.config import (
|
|
|
10
10
|
resolve_local_path_for_folder,
|
|
11
11
|
is_valid_git_repo,
|
|
12
12
|
)
|
|
13
|
+
from lib.git_state import parse_iso_timestamp
|
|
14
|
+
|
|
15
|
+
_PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def priority_rank(meta: dict) -> int:
|
|
19
|
+
"""Rank a track's launch_priority for ascending sort: P0<P1<P2<P3<anything.
|
|
20
|
+
|
|
21
|
+
Unknown / missing values (e.g. "—" or absent) sort after all known ranks.
|
|
22
|
+
"""
|
|
23
|
+
return _PRIORITY_RANK.get(meta.get("launch_priority"), len(_PRIORITY_RANK))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def recency_sort_key(meta: dict) -> float:
|
|
27
|
+
"""Sort key for last_touched recency (most recent first when sorted ascending).
|
|
28
|
+
|
|
29
|
+
Returns the negative POSIX timestamp so that a plain ascending sort puts the
|
|
30
|
+
most-recently-touched track first. Tracks with no (or unparseable)
|
|
31
|
+
last_touched return +inf, sorting them LAST.
|
|
32
|
+
"""
|
|
33
|
+
raw = meta.get("last_touched")
|
|
34
|
+
if not raw:
|
|
35
|
+
return float("inf")
|
|
36
|
+
try:
|
|
37
|
+
return -parse_iso_timestamp(raw).timestamp()
|
|
38
|
+
except (ValueError, TypeError):
|
|
39
|
+
return float("inf")
|
|
13
40
|
|
|
14
41
|
|
|
15
42
|
@dataclass
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Tests for the `list --sort` flag (issue #181).
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- --sort=recent orders active tracks by last_touched descending.
|
|
5
|
+
- Tracks missing last_touched sort LAST under --sort=recent.
|
|
6
|
+
- --sort=priority orders P0→P3 with last_touched recency as tiebreaker;
|
|
7
|
+
tracks missing launch_priority sort after those that have it.
|
|
8
|
+
- Default (no --sort) preserves discovery (filesystem) order exactly.
|
|
9
|
+
- --all still appends the archived section, and works alongside --sort.
|
|
10
|
+
- An invalid --sort value (or bare --sort) returns rc 2.
|
|
11
|
+
"""
|
|
12
|
+
import io
|
|
13
|
+
import sys
|
|
14
|
+
import unittest
|
|
15
|
+
from contextlib import redirect_stdout
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import SimpleNamespace
|
|
18
|
+
from unittest.mock import patch
|
|
19
|
+
|
|
20
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
21
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
22
|
+
|
|
23
|
+
from commands import list_cmd
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Helpers
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _track(name, *, repo="ok/repo", status="active", priority=None, last_touched=None):
|
|
31
|
+
meta = {"track": name, "status": status}
|
|
32
|
+
if priority is not None:
|
|
33
|
+
meta["launch_priority"] = priority
|
|
34
|
+
if last_touched is not None:
|
|
35
|
+
meta["last_touched"] = last_touched
|
|
36
|
+
return SimpleNamespace(
|
|
37
|
+
name=name,
|
|
38
|
+
path=Path(f"/tmp/fake/{name}.md"),
|
|
39
|
+
body="# fake",
|
|
40
|
+
meta=meta,
|
|
41
|
+
has_frontmatter=True,
|
|
42
|
+
needs_init=False,
|
|
43
|
+
needs_filing=False,
|
|
44
|
+
repo=repo,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _drive(args, tracks, archived=None):
|
|
49
|
+
"""Run list_cmd.run(args) with config + discovery mocked. Returns (rc, output)."""
|
|
50
|
+
cfg = {"notes_root": "/tmp/fake-notes", "repos": {}}
|
|
51
|
+
with patch("commands.list_cmd.load_config", return_value=cfg), \
|
|
52
|
+
patch("commands.list_cmd.discover_tracks", return_value=tracks), \
|
|
53
|
+
patch("commands.list_cmd.discover_archived_tracks", return_value=archived or []):
|
|
54
|
+
buf = io.StringIO()
|
|
55
|
+
with redirect_stdout(buf):
|
|
56
|
+
rc = list_cmd.run(args)
|
|
57
|
+
return rc, buf.getvalue()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _order(output, names):
|
|
61
|
+
"""Return the names from `names` in the order they appear in output."""
|
|
62
|
+
positions = [(output.index(n), n) for n in names if n in output]
|
|
63
|
+
return [n for _, n in sorted(positions)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Test cases
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
class ListSortTest(unittest.TestCase):
|
|
71
|
+
|
|
72
|
+
def test_sort_recent_orders_by_last_touched_desc(self):
|
|
73
|
+
"""--sort=recent orders tracks most-recently-touched first."""
|
|
74
|
+
tracks = [
|
|
75
|
+
_track("old", last_touched="2026-01-01"),
|
|
76
|
+
_track("newest", last_touched="2026-06-01"),
|
|
77
|
+
_track("middle", last_touched="2026-03-15"),
|
|
78
|
+
]
|
|
79
|
+
rc, out = _drive(["--sort=recent"], tracks)
|
|
80
|
+
self.assertEqual(rc, 0)
|
|
81
|
+
self.assertEqual(_order(out, ["old", "newest", "middle"]),
|
|
82
|
+
["newest", "middle", "old"])
|
|
83
|
+
|
|
84
|
+
def test_sort_recent_missing_last_touched_sorts_last(self):
|
|
85
|
+
"""Tracks with no last_touched sort after those that have one."""
|
|
86
|
+
tracks = [
|
|
87
|
+
_track("nodate"),
|
|
88
|
+
_track("dated", last_touched="2026-05-01"),
|
|
89
|
+
]
|
|
90
|
+
rc, out = _drive(["--sort=recent"], tracks)
|
|
91
|
+
self.assertEqual(rc, 0)
|
|
92
|
+
self.assertEqual(_order(out, ["nodate", "dated"]), ["dated", "nodate"])
|
|
93
|
+
|
|
94
|
+
def test_sort_priority_orders_p0_to_p3_with_recency_tiebreak(self):
|
|
95
|
+
"""--sort=priority orders P0→P3; equal priority breaks by recency."""
|
|
96
|
+
tracks = [
|
|
97
|
+
_track("p2", priority="P2", last_touched="2026-01-01"),
|
|
98
|
+
_track("p0", priority="P0", last_touched="2026-01-01"),
|
|
99
|
+
_track("p1_old", priority="P1", last_touched="2026-01-01"),
|
|
100
|
+
_track("p1_new", priority="P1", last_touched="2026-06-01"),
|
|
101
|
+
]
|
|
102
|
+
rc, out = _drive(["--sort=priority"], tracks)
|
|
103
|
+
self.assertEqual(rc, 0)
|
|
104
|
+
# P0 first, then P1 (newest of the two first), then P2
|
|
105
|
+
self.assertEqual(
|
|
106
|
+
_order(out, ["p0", "p1_new", "p1_old", "p2"]),
|
|
107
|
+
["p0", "p1_new", "p1_old", "p2"],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def test_sort_priority_missing_priority_sorts_after_known(self):
|
|
111
|
+
"""Tracks missing launch_priority sort after those that have it."""
|
|
112
|
+
tracks = [
|
|
113
|
+
_track("none"),
|
|
114
|
+
_track("p3", priority="P3"),
|
|
115
|
+
_track("p0", priority="P0"),
|
|
116
|
+
]
|
|
117
|
+
rc, out = _drive(["--sort=priority"], tracks)
|
|
118
|
+
self.assertEqual(rc, 0)
|
|
119
|
+
self.assertEqual(_order(out, ["none", "p3", "p0"]), ["p0", "p3", "none"])
|
|
120
|
+
|
|
121
|
+
def test_default_preserves_discovery_order(self):
|
|
122
|
+
"""No --sort flag preserves the exact filesystem discovery order."""
|
|
123
|
+
tracks = [
|
|
124
|
+
_track("zebra", priority="P3", last_touched="2026-01-01"),
|
|
125
|
+
_track("alpha", priority="P0", last_touched="2026-06-01"),
|
|
126
|
+
_track("mango", priority="P1", last_touched="2026-03-01"),
|
|
127
|
+
]
|
|
128
|
+
rc, out = _drive([], tracks)
|
|
129
|
+
self.assertEqual(rc, 0)
|
|
130
|
+
# Discovery order is preserved despite priority/recency differences.
|
|
131
|
+
self.assertEqual(_order(out, ["zebra", "alpha", "mango"]),
|
|
132
|
+
["zebra", "alpha", "mango"])
|
|
133
|
+
|
|
134
|
+
def test_all_appends_archived_section_with_sort(self):
|
|
135
|
+
"""--all still appends the Archived section alongside --sort."""
|
|
136
|
+
tracks = [
|
|
137
|
+
_track("recent_active", last_touched="2026-06-01"),
|
|
138
|
+
_track("old_active", last_touched="2026-01-01"),
|
|
139
|
+
]
|
|
140
|
+
archived = [_track("done_track", status="shipped")]
|
|
141
|
+
rc, out = _drive(["--all", "--sort=recent"], tracks, archived=archived)
|
|
142
|
+
self.assertEqual(rc, 0)
|
|
143
|
+
self.assertIn("Archived:", out)
|
|
144
|
+
self.assertIn("done_track", out)
|
|
145
|
+
# Active section still recency-sorted, and archived comes after.
|
|
146
|
+
self.assertLess(out.index("recent_active"), out.index("old_active"))
|
|
147
|
+
self.assertLess(out.index("old_active"), out.index("Archived:"))
|
|
148
|
+
|
|
149
|
+
def test_invalid_sort_value_returns_rc2(self):
|
|
150
|
+
"""An unrecognized --sort value returns rc 2 (usage error)."""
|
|
151
|
+
rc, out = _drive(["--sort=bogus"], [_track("a")])
|
|
152
|
+
self.assertEqual(rc, 2)
|
|
153
|
+
self.assertIn("usage", out)
|
|
154
|
+
|
|
155
|
+
def test_bare_sort_flag_returns_rc2(self):
|
|
156
|
+
"""--sort with no value returns rc 2."""
|
|
157
|
+
rc, out = _drive(["--sort"], [_track("a")])
|
|
158
|
+
self.assertEqual(rc, 2)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
unittest.main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Non-interactive guard for the prompt helpers (regression test for #183).
|
|
2
|
+
|
|
3
|
+
When `work_plan.py` is launched with stdin wired to a pipe/socket that stays
|
|
4
|
+
open but never delivers a line (the VS Code extension does exactly this),
|
|
5
|
+
`input()` blocks forever — no data, no EOF. The fix makes prompt_input /
|
|
6
|
+
prompt_yes_no / prompt_lines fall back to their default when stdin is not a
|
|
7
|
+
TTY, and only call input() when it is.
|
|
8
|
+
|
|
9
|
+
These tests fake stdin's isatty() rather than touching the real terminal, and
|
|
10
|
+
assert input() is NOT called on the non-TTY path (so a regression that drops
|
|
11
|
+
the guard would deadlock under a real pipe — here it would call the patched
|
|
12
|
+
input and the assertion would fire instead of hanging).
|
|
13
|
+
"""
|
|
14
|
+
import io
|
|
15
|
+
import sys
|
|
16
|
+
import unittest
|
|
17
|
+
from contextlib import redirect_stdout
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from unittest import mock
|
|
20
|
+
|
|
21
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
23
|
+
|
|
24
|
+
from lib import prompts
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _FakeStdin:
|
|
28
|
+
def __init__(self, tty: bool):
|
|
29
|
+
self._tty = tty
|
|
30
|
+
|
|
31
|
+
def isatty(self) -> bool:
|
|
32
|
+
return self._tty
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NonInteractiveGuardTest(unittest.TestCase):
|
|
36
|
+
"""No TTY → return the default immediately, never call input()."""
|
|
37
|
+
|
|
38
|
+
def test_prompt_input_returns_default_without_reading(self):
|
|
39
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=False)), \
|
|
40
|
+
mock.patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
41
|
+
buf = io.StringIO()
|
|
42
|
+
with redirect_stdout(buf):
|
|
43
|
+
out = prompts.prompt_input("Apply? [y/N]", default="N")
|
|
44
|
+
self.assertEqual(out, "N")
|
|
45
|
+
|
|
46
|
+
def test_prompt_input_default_is_empty_string_by_default(self):
|
|
47
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=False)), \
|
|
48
|
+
mock.patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
49
|
+
buf = io.StringIO()
|
|
50
|
+
with redirect_stdout(buf):
|
|
51
|
+
out = prompts.prompt_input("anything")
|
|
52
|
+
self.assertEqual(out, "")
|
|
53
|
+
|
|
54
|
+
def test_prompt_yes_no_returns_false_without_reading(self):
|
|
55
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=False)), \
|
|
56
|
+
mock.patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
57
|
+
buf = io.StringIO()
|
|
58
|
+
with redirect_stdout(buf):
|
|
59
|
+
out = prompts.prompt_yes_no()
|
|
60
|
+
self.assertFalse(out)
|
|
61
|
+
|
|
62
|
+
def test_prompt_lines_returns_empty_without_reading(self):
|
|
63
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=False)), \
|
|
64
|
+
mock.patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
65
|
+
out = prompts.prompt_lines()
|
|
66
|
+
self.assertEqual(out, [])
|
|
67
|
+
|
|
68
|
+
def test_guard_false_when_stdin_is_none(self):
|
|
69
|
+
with mock.patch.object(prompts.sys, "stdin", None):
|
|
70
|
+
self.assertFalse(prompts._stdin_is_interactive())
|
|
71
|
+
|
|
72
|
+
def test_guard_false_when_isatty_raises(self):
|
|
73
|
+
class Broken:
|
|
74
|
+
def isatty(self):
|
|
75
|
+
raise ValueError("I/O operation on closed file")
|
|
76
|
+
with mock.patch.object(prompts.sys, "stdin", Broken()):
|
|
77
|
+
self.assertFalse(prompts._stdin_is_interactive())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InteractivePathStillReadsTest(unittest.TestCase):
|
|
81
|
+
"""With a TTY, the helpers still call input() and honour the reply."""
|
|
82
|
+
|
|
83
|
+
def test_prompt_input_reads_when_tty(self):
|
|
84
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
85
|
+
mock.patch("builtins.input", return_value=" hello "):
|
|
86
|
+
buf = io.StringIO()
|
|
87
|
+
with redirect_stdout(buf):
|
|
88
|
+
out = prompts.prompt_input("q")
|
|
89
|
+
self.assertEqual(out, "hello")
|
|
90
|
+
|
|
91
|
+
def test_prompt_input_blank_reply_falls_back_to_default(self):
|
|
92
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
93
|
+
mock.patch("builtins.input", return_value=" "):
|
|
94
|
+
buf = io.StringIO()
|
|
95
|
+
with redirect_stdout(buf):
|
|
96
|
+
out = prompts.prompt_input("q", default="def")
|
|
97
|
+
self.assertEqual(out, "def")
|
|
98
|
+
|
|
99
|
+
def test_prompt_yes_no_true_only_on_y(self):
|
|
100
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
101
|
+
mock.patch("builtins.input", return_value="Y"):
|
|
102
|
+
buf = io.StringIO()
|
|
103
|
+
with redirect_stdout(buf):
|
|
104
|
+
self.assertTrue(prompts.prompt_yes_no())
|
|
105
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
106
|
+
mock.patch("builtins.input", return_value="n"):
|
|
107
|
+
buf = io.StringIO()
|
|
108
|
+
with redirect_stdout(buf):
|
|
109
|
+
self.assertFalse(prompts.prompt_yes_no())
|
|
110
|
+
|
|
111
|
+
def test_prompt_input_eof_returns_default_when_tty(self):
|
|
112
|
+
with mock.patch.object(prompts.sys, "stdin", _FakeStdin(tty=True)), \
|
|
113
|
+
mock.patch("builtins.input", side_effect=EOFError):
|
|
114
|
+
buf = io.StringIO()
|
|
115
|
+
with redirect_stdout(buf):
|
|
116
|
+
out = prompts.prompt_input("q", default="d")
|
|
117
|
+
self.assertEqual(out, "d")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
unittest.main()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Cross-track auto-move detection in reconcile (#163).
|
|
2
|
+
|
|
3
|
+
When an issue sits in track A's frontmatter but is now labeled for exactly one
|
|
4
|
+
OTHER active track B in the same repo (a relabel), reconcile proposes a MOVE:
|
|
5
|
+
remove from A, add to B — instead of leaving it as a dangling FLAG on A and a
|
|
6
|
+
fresh ADD on B (which would duplicate it across both tracks).
|
|
7
|
+
|
|
8
|
+
All gh calls are mocked; tests run offline. needs_confirm is patched so the
|
|
9
|
+
public-repo gate is exercised without a real `gh repo view`.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from unittest.mock import MagicMock, patch
|
|
17
|
+
|
|
18
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
sys.path.insert(0, str(SKILL_ROOT))
|
|
20
|
+
|
|
21
|
+
from commands import reconcile
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _track(*, slug, repo="ok/ok", issues=None):
|
|
25
|
+
return SimpleNamespace(
|
|
26
|
+
name=slug,
|
|
27
|
+
path=Path(f"/tmp/fake/{slug}.md"),
|
|
28
|
+
body="# fake",
|
|
29
|
+
meta={"track": slug, "status": "active",
|
|
30
|
+
"github": {"repo": repo, "issues": list(issues or [])}},
|
|
31
|
+
has_frontmatter=True,
|
|
32
|
+
repo=repo,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Harness:
|
|
37
|
+
"""Drives reconcile --all over a set of tracks with a label→issues map.
|
|
38
|
+
|
|
39
|
+
`labeled` maps a GitHub label string to the list of issue dicts that
|
|
40
|
+
`gh issue/pr list --label <label>` should return.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, tracks, labeled, *, private=True):
|
|
44
|
+
self.tracks = tracks
|
|
45
|
+
self.labeled = labeled
|
|
46
|
+
self.private = private
|
|
47
|
+
self.writes = [] # (path_name, issues) per write_file call
|
|
48
|
+
|
|
49
|
+
def _fake_run(self, argv, *a, **kw):
|
|
50
|
+
out = []
|
|
51
|
+
if "--label" in argv and argv[1] == "issue": # only count issues once
|
|
52
|
+
lab = argv[argv.index("--label") + 1]
|
|
53
|
+
out = self.labeled.get(lab, [])
|
|
54
|
+
return MagicMock(returncode=0, stdout=json.dumps(out), stderr="")
|
|
55
|
+
|
|
56
|
+
def _fake_write(self, path, meta, body):
|
|
57
|
+
self.writes.append((path.name, list(meta.get("github", {}).get("issues") or [])))
|
|
58
|
+
|
|
59
|
+
def run(self, extra_args=None):
|
|
60
|
+
cfg = {"notes_root": "/tmp/n", "repos": {"ok": {"github": "ok/ok"}}}
|
|
61
|
+
with patch("commands.reconcile.subprocess.run", side_effect=self._fake_run), \
|
|
62
|
+
patch("commands.reconcile.load_config", return_value=cfg), \
|
|
63
|
+
patch("commands.reconcile.discover_tracks", return_value=self.tracks), \
|
|
64
|
+
patch("commands.reconcile.needs_confirm", return_value=not self.private), \
|
|
65
|
+
patch("commands.reconcile.write_file", side_effect=self._fake_write), \
|
|
66
|
+
patch("commands.reconcile.prompt_input", return_value="y"):
|
|
67
|
+
rc = reconcile.run(["--all"] + (extra_args or []))
|
|
68
|
+
return rc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AutoMoveTest(unittest.TestCase):
|
|
72
|
+
def test_relabel_moves_issue_from_a_to_b(self):
|
|
73
|
+
# #50 is in alpha's frontmatter but now carries only track/beta.
|
|
74
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
75
|
+
beta = _track(slug="beta", issues=[])
|
|
76
|
+
labeled = {
|
|
77
|
+
"track/alpha": [],
|
|
78
|
+
"track/beta": [{"number": 50, "title": "moved", "state": "OPEN"}],
|
|
79
|
+
}
|
|
80
|
+
h = _Harness([alpha, beta], labeled)
|
|
81
|
+
rc = h.run(extra_args=["--yes"])
|
|
82
|
+
self.assertEqual(rc, 0)
|
|
83
|
+
writes = dict(h.writes)
|
|
84
|
+
self.assertEqual(writes["alpha.md"], []) # removed from source
|
|
85
|
+
self.assertEqual(writes["beta.md"], [50]) # added to destination
|
|
86
|
+
self.assertEqual(len(h.writes), 2) # each side written once
|
|
87
|
+
|
|
88
|
+
def test_ambiguous_target_is_not_moved_out_of_source(self):
|
|
89
|
+
# #50 lost alpha's label and is labeled for BOTH beta and gamma →
|
|
90
|
+
# ambiguous target, so reconcile must NOT move it out of alpha. (beta
|
|
91
|
+
# and gamma each legitimately ADD it, since it carries both labels —
|
|
92
|
+
# that's normal membership-follows-labels behaviour, not a move.) The
|
|
93
|
+
# point of this test: alpha keeps #50, the move logic does not fire.
|
|
94
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
95
|
+
beta = _track(slug="beta", issues=[])
|
|
96
|
+
gamma = _track(slug="gamma", issues=[])
|
|
97
|
+
labeled = {
|
|
98
|
+
"track/alpha": [],
|
|
99
|
+
"track/beta": [{"number": 50, "title": "x", "state": "OPEN"}],
|
|
100
|
+
"track/gamma": [{"number": 50, "title": "x", "state": "OPEN"}],
|
|
101
|
+
}
|
|
102
|
+
h = _Harness([alpha, beta, gamma], labeled)
|
|
103
|
+
rc = h.run(extra_args=["--yes"])
|
|
104
|
+
self.assertEqual(rc, 0)
|
|
105
|
+
writes = dict(h.writes)
|
|
106
|
+
# alpha must NOT be rewritten — #50 stays (no unambiguous move target).
|
|
107
|
+
self.assertNotIn("alpha.md", writes)
|
|
108
|
+
# beta and gamma each ADD #50 (it is labeled for both).
|
|
109
|
+
self.assertEqual(writes.get("beta.md"), [50])
|
|
110
|
+
self.assertEqual(writes.get("gamma.md"), [50])
|
|
111
|
+
|
|
112
|
+
def test_draft_reports_move_but_writes_nothing(self):
|
|
113
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
114
|
+
beta = _track(slug="beta", issues=[])
|
|
115
|
+
labeled = {
|
|
116
|
+
"track/alpha": [],
|
|
117
|
+
"track/beta": [{"number": 50, "title": "moved", "state": "OPEN"}],
|
|
118
|
+
}
|
|
119
|
+
h = _Harness([alpha, beta], labeled)
|
|
120
|
+
rc = h.run(extra_args=["--draft"])
|
|
121
|
+
self.assertEqual(rc, 0)
|
|
122
|
+
self.assertEqual(h.writes, [])
|
|
123
|
+
|
|
124
|
+
def test_public_destination_skipped_under_yes(self):
|
|
125
|
+
# Destination is PUBLIC → under --yes the move is skipped (no silent
|
|
126
|
+
# membership write to a shared track); source is left untouched too.
|
|
127
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
128
|
+
beta = _track(slug="beta", issues=[])
|
|
129
|
+
labeled = {
|
|
130
|
+
"track/alpha": [],
|
|
131
|
+
"track/beta": [{"number": 50, "title": "moved", "state": "OPEN"}],
|
|
132
|
+
}
|
|
133
|
+
h = _Harness([alpha, beta], labeled, private=False)
|
|
134
|
+
rc = h.run(extra_args=["--yes"])
|
|
135
|
+
self.assertEqual(rc, 0)
|
|
136
|
+
self.assertEqual(h.writes, []) # nothing written when dst is public
|
|
137
|
+
|
|
138
|
+
def test_move_does_not_duplicate_as_add_on_destination(self):
|
|
139
|
+
# The destination must NOT also try to ADD #50 (which would be the
|
|
140
|
+
# naive behaviour); it arrives exactly once, via the move.
|
|
141
|
+
alpha = _track(slug="alpha", issues=[50])
|
|
142
|
+
beta = _track(slug="beta", issues=[])
|
|
143
|
+
labeled = {
|
|
144
|
+
"track/alpha": [],
|
|
145
|
+
"track/beta": [{"number": 50, "title": "moved", "state": "OPEN"}],
|
|
146
|
+
}
|
|
147
|
+
h = _Harness([alpha, beta], labeled)
|
|
148
|
+
h.run(extra_args=["--yes"])
|
|
149
|
+
writes = dict(h.writes)
|
|
150
|
+
self.assertEqual(writes["beta.md"].count(50), 1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
unittest.main()
|
|
@@ -142,6 +142,25 @@ class ReadOnlyContractTest(unittest.TestCase):
|
|
|
142
142
|
self._assert_read_only(captured)
|
|
143
143
|
mock_write.assert_called_once()
|
|
144
144
|
|
|
145
|
+
def test_yes_applies_without_prompt_and_writes_local_only(self):
|
|
146
|
+
# --yes (non-interactive, e.g. from the VS Code extension) applies the
|
|
147
|
+
# proposed ADDs without ever calling prompt_input, and the only write
|
|
148
|
+
# is the local frontmatter file — never gh. This is the #183 fix: a
|
|
149
|
+
# piped/no-TTY run must not hang on the prompt.
|
|
150
|
+
track = _fake_track(slug="epsilon", repo="ok/ok", issues=[5])
|
|
151
|
+
gh_response = [
|
|
152
|
+
{"number": 5, "title": "x", "state": "OPEN"},
|
|
153
|
+
{"number": 99, "title": "new", "state": "OPEN"},
|
|
154
|
+
]
|
|
155
|
+
rc, captured, mock_write, mock_prompt = self._drive(
|
|
156
|
+
track=track, gh_response=gh_response,
|
|
157
|
+
user_choice="n", extra_args=["--yes"],
|
|
158
|
+
)
|
|
159
|
+
self.assertEqual(rc, 0)
|
|
160
|
+
self._assert_read_only(captured)
|
|
161
|
+
mock_prompt.assert_not_called()
|
|
162
|
+
mock_write.assert_called_once()
|
|
163
|
+
|
|
145
164
|
def test_draft_skips_user_prompt_and_write(self):
|
|
146
165
|
# --draft prints the analysis but never prompts and never writes.
|
|
147
166
|
# Even with proposed ADDs (so the report path is exercised), the user
|
|
@@ -90,9 +90,9 @@ DESCRIPTIONS = [
|
|
|
90
90
|
"Update issue STATE (open/closed, status labels) inside the track body's status table. Does not change track membership.",
|
|
91
91
|
"Usually NOT needed directly: `handoff` already refreshes the body table for its own track, and `brief` reads GitHub live. Reach for this when a sibling track has drifted because you haven't `handoff`'d it lately. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo (also runs as part of weekly `hygiene`).",
|
|
92
92
|
"/work-plan refresh-md --repo=critforge"),
|
|
93
|
-
("list", "[--all]",
|
|
93
|
+
("list", "[--all] [--sort=recent|priority]",
|
|
94
94
|
"List active tracks (or all including parked/archived).",
|
|
95
|
-
"Quick scan of what tracks exist; --all to see archived.",
|
|
95
|
+
"Quick scan of what tracks exist; --all to see archived. --sort orders by last_touched recency or launch_priority.",
|
|
96
96
|
"/work-plan list --all"),
|
|
97
97
|
("init", "<path-to-md>",
|
|
98
98
|
"Add frontmatter to an existing track .md file.",
|
|
@@ -114,8 +114,8 @@ DESCRIPTIONS = [
|
|
|
114
114
|
"AI-assign untracked open issues to existing tracks. Step 1 (no --apply): fetches untracked issues + existing tracks, prints AI prompt. Step 2 (--apply): reads AI's JSON answers and slots each assignment into track frontmatter. Complements `group` (which creates new tracks); `auto-triage` assigns to tracks that already exist. --limit controls how many untracked issues are shown (default 100).",
|
|
115
115
|
"Periodically — when new issues have piled up outside the track model. Run /work-plan coverage first to confirm there's a gap worth triaging.",
|
|
116
116
|
"/work-plan auto-triage --repo=critforge"),
|
|
117
|
-
("reconcile", "<track> | --all | --repo=<key> [--draft]",
|
|
118
|
-
"Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing it against a GitHub label. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter. Read-only on GitHub. Add --draft to preview proposed ADDs/FLAGs without prompting or writing. NOT for hand-curated tracks — see `refresh-md` if you only want to update issue state.",
|
|
117
|
+
("reconcile", "<track> | --all | --repo=<key> [--draft] [--yes]",
|
|
118
|
+
"Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing it against a GitHub label. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter. Read-only on GitHub. In an --all/--repo sweep it also detects MOVEs — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new). Add --draft to preview proposed ADDs/MOVEs/FLAGs without prompting or writing; add --yes to apply without prompting (non-interactive, e.g. from the VS Code extension; PUBLIC-repo move destinations are skipped under --yes). NOT for hand-curated tracks — see `refresh-md` if you only want to update issue state.",
|
|
119
119
|
"WEEKLY hygiene on label-driven tracks — pulls labeled issues into their tracks, flags un-labeled ones. Use --repo=<key> to scope the sweep to one repo. Skip on hand-curated tracks (it'll propose dropping curated issues every run).",
|
|
120
120
|
"/work-plan reconcile --repo=critforge --draft"),
|
|
121
121
|
("duplicates", "[--min-similarity=0.7] [--limit=20] [--state=open] [--timeout=N]",
|