@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 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 or writing. `--repo=<key>` scopes the sweep to one repo. |
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.09+f25e6e1
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.9-4",
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 discover_tracks, discover_archived_tracks, filter_tracks_by_repo
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 = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(meta.get("launch_priority", "P3"), 3)
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 discover_tracks, discover_archived_tracks
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
- show_all = "--all" in args
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
- flags = []
38
+ flags_out = []
25
39
  if t.needs_init:
26
- flags.append("NEEDS INIT")
40
+ flags_out.append("NEEDS INIT")
27
41
  if t.needs_filing:
28
- flags.append("NEEDS FILING")
29
- flag_str = f" [{', '.join(flags)}]" if flags else ""
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 FLAGS (in frontmatter but no longer labeled possible move out).
16
- - User confirms before writing to the LOCAL frontmatter file.
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 2: serial diff, report, and confirm (prompts must NOT be in threads)
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
- adds = sorted(labeled_nums - listed_nums)
186
- flag_nums = sorted(listed_nums - labeled_nums)
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(f" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
208
- print(f" If you just want to update issue state in the body table, try:")
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
- choice = prompt_input(f"\n Apply ADDs to {track.path.name}? [y/N/skip-flags]").lower()
217
- if choice == "y":
218
- new_issues = sorted(listed_nums | labeled_nums)
219
- track.meta.setdefault("github", {})["issues"] = new_issues
220
- write_file(track.path, track.meta, track.body)
221
- print(f" ✓ Updated {track.path.name} ({len(adds)} added)")
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 blank.
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 (no stdin) as no.
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]",