@stylusnexus/work-plan 2026.6.13-2 → 2026.6.14

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
@@ -53,6 +53,7 @@ The five essentials you'll use 80% of the time are:
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
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
+ | `/work-plan in-progress <n> [--clear]` | Starting or stopping active work on an issue. Adds (or removes with `--clear`) the `work-plan:in-progress` label on GitHub. Repo-resolved from the issue number, or pass `--repo=<key\|slug>` to disambiguate. `brief`/`orient`/the VS Code viewer also detect in-progress automatically from a hot `feat/<n>-`/`fix/<n>-` branch. |
56
57
 
57
58
  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:
58
59
 
@@ -111,7 +112,9 @@ flowchart TB
111
112
 
112
113
  > **When should I run `refresh-md`?** Any time you close or merge issues and want the track body to reflect the new state. `handoff` rewrites the status table for one track on every run, but `brief` reads GitHub live without writing anything back — so a track you haven't `handoff`'d recently stays stale on disk. `refresh-md <track>` (or **Sync Issue States from GitHub** in VS Code) fixes that on-demand; `hygiene` sweeps all tracks weekly.
113
114
 
114
- > **GitHub access is read-only by default, with two explicit, opt-in write actions.** Issue *data* always comes from read-only `gh` calls (`gh issue list`, `gh issue view`), and every routine write (frontmatter, status table, session log) goes to your local markdown files only. The two GitHub-*mutating* actions are both opt-in and gated: `plan-status --issues` **creates** a GitHub issue per partial plan (`gh issue create`, prompts before opening), and `close-issue` (#305) **closes** an issue via `gh issue close` — for the common case where a PR merged to `dev` left its issue OPEN (GitHub auto-closes only from the default branch), with the VS Code viewer firing a mandatory "Close on GitHub? — cannot be undone" modal on every close. Nothing else touches GitHub state.
115
+ > **`brief` and `orient` annotate blocked issues with `⊘ blocked by #N`** read-only, surfaced from GitHub's native dependency edges, nothing is written back. Cross-repo blockers show as `owner/repo#N`; same-repo duplicates of manually-declared blockers are deduplicated.
116
+
117
+ > **GitHub access is read-only by default, with three explicit, opt-in write actions.** Issue *data* always comes from read-only `gh` calls (`gh issue list`, `gh issue view`), and every routine write (frontmatter, status table, session log) goes to your local markdown files only. The three GitHub-*mutating* actions are all opt-in and gated: `plan-status --issues` **creates** a GitHub issue per partial plan (`gh issue create`, prompts before opening); `close-issue` (#305) **closes** an issue via `gh issue close` — for the common case where a PR merged to `dev` left its issue OPEN (GitHub auto-closes only from the default branch), with the VS Code viewer firing a mandatory "Close on GitHub? — cannot be undone" modal on every close; and `in-progress` (#271) **adds or removes** the `work-plan:in-progress` label on an issue, public-repo gated via the confirm-token flow. Nothing else touches GitHub state.
115
118
 
116
119
  ## Shared tracks
117
120
 
@@ -490,7 +493,7 @@ The bundled `notes/` folder stays empty until you run `/work-plan init-repo <key
490
493
  ## Security & data handling
491
494
 
492
495
  - **No credentials stored.** All GitHub access goes through your existing `gh auth`. This toolkit never reads, writes, or stores GitHub tokens.
493
- - **Writes are local by default; every remote/GitHub write is opt-in and gated.** The skill writes to `~/.claude/skills/work-plan/`, `~/.claude/skills/repo-activity-summary/`, `~/.claude/commands/work-plan.md`, `~/.claude/work-plan/config.yml`, and your `notes_root`. Repo-confined writes: the `plan-status` action flags (`--stamp` writes a status header into discovered plan docs; `--archive` `git mv`s dead plans into `archive/abandoned/`; all honor `--draft` and prompt), and the frontmatter-only plan writers `plan-confirm` (`verdict_override`), `plan-ack` (`acknowledged`), `plan-baseline` (`verdict_baseline`) — each writes one key into a plan doc's **YAML frontmatter only** (never its body/checkboxes/manifest), public-repo gated. **GitHub-mutating** (opt-in, gated): `plan-status --issues` *creates* an issue per partial plan, and `close-issue` *closes* an issue (`gh issue close`). **Remote git push** (opt-in, public-repo gated): `plan-branch push` and `push-track` publish the shared plan branch. Nothing else.
496
+ - **Writes are local by default; every remote/GitHub write is opt-in and gated.** The skill writes to `~/.claude/skills/work-plan/`, `~/.claude/skills/repo-activity-summary/`, `~/.claude/commands/work-plan.md`, `~/.claude/work-plan/config.yml`, and your `notes_root`. Repo-confined writes: the `plan-status` action flags (`--stamp` writes a status header into discovered plan docs; `--archive` `git mv`s dead plans into `archive/abandoned/`; all honor `--draft` and prompt), and the frontmatter-only plan writers `plan-confirm` (`verdict_override`), `plan-ack` (`acknowledged`), `plan-baseline` (`verdict_baseline`) — each writes one key into a plan doc's **YAML frontmatter only** (never its body/checkboxes/manifest), public-repo gated. **GitHub-mutating** (opt-in, gated): `plan-status --issues` *creates* an issue per partial plan; `close-issue` *closes* an issue (`gh issue close`); and `in-progress` *adds or removes* the `work-plan:in-progress` label on an issue. **Remote git push** (opt-in, public-repo gated): `plan-branch push` and `push-track` publish the shared plan branch. Nothing else.
494
497
  - **No telemetry, no network calls beyond `gh`.** All GitHub operations go through `gh` (your authenticated session); no direct HTTP requests are made.
495
498
  - **AI subcommands (`group`, `suggest-priorities`) send issue titles to Claude** via Claude Code's existing integration. Body content, code, and PR contents are NOT sent. If your repo is private and you're cautious about what reaches the model, skip these subcommands.
496
499
  - **`init-repo` writes to your config via `yq -i`.** Inputs are JSON-encoded before being passed to `yq`, so a maliciously crafted `--github=` value can't break out of the YAML edit.
@@ -534,6 +537,8 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
534
537
  | `plan-confirm --repo=<key> --verdict=shipped\|partial\|dead [--clear] [--confirm=<token>] -- <rel>` | Affirm a **human** verdict on ONE plan/spec doc by writing `verdict_override` into its YAML **frontmatter only** (never the body, checkboxes, manifest, or status banner). `plan-status` then pins that verdict over the mechanical one and silences the "shipped but boxes unchecked" lie-gap. Use when a genuinely-shipped plan is flagged only because its phase checkboxes were never ticked. `<rel>` is the repo-relative doc path. Public-repo gated (`--confirm=<token>`); `--clear` removes the override. |
535
538
  | `plan-ack --repo=<key> [--clear] [--confirm=<token>] -- <rel>` | Persist a **durable acknowledgment** into ONE plan/spec doc's YAML **frontmatter only** (`acknowledged: true`) — a "stop flagging this" that's committed with the repo and shared with teammates, unlike the VS Code viewer's per-machine `workspaceState` ack. `plan-status` reads it back (emits `acknowledged`) and demotes the doc. `<rel>` is the repo-relative doc path. Public-repo gated (`--confirm=<token>`); `--clear` removes it. |
536
539
  | `plan-baseline --repo=<key> [--clear] [--confirm=<token>] -- <rel>` | Stamp the **current computed verdict** into ONE plan/spec doc's YAML **frontmatter only** (`verdict_baseline`) as a drift tripwire. `plan-status` then flags **drift** (emits `verdict_drift`) when the live verdict later diverges from the baseline — catching a once-shipped plan that silently **regressed** (its declared files were deleted/moved), the third "started, then drifted off" signal beyond stalled + lie-gap. The value is computed authoritatively (not taken from the caller); a human `verdict_override` suppresses drift. Public-repo gated; `--clear` removes it. |
540
+ | `close-issue --repo=<key\|slug> [--reason=completed\|not_planned] [--comment=<text>] -- <number>` | ⚠️ A GitHub-mutating command — closes a GitHub issue via `gh issue close`. PRs merged to `dev` don't auto-close issues (GitHub auto-closes only from the default branch), so done-but-OPEN issues pile up; this closes one explicitly. `--reason` maps to GitHub's completed/not-planned; `--comment` posts a closing note. The VS Code viewer gates this behind a mandatory "Close on GitHub? — cannot be undone" modal. |
541
+ | `in-progress <n> [--clear] [--repo=<key\|slug>] [--confirm=<token>]` | ⚠️ A GitHub-mutating command — marks a tracked issue in-progress by adding the `work-plan:in-progress` label (or removes it with `--clear`). Repo-resolved from the issue number; pass `--repo` to disambiguate. The label is auto-created on first use. Public-repo gated (`--confirm=<token>`). Note: `brief`/`orient`/the VS Code viewer also derive in-progress automatically from a hot `feat/<n>-`/`fix/<n>-` branch — the label is for issues with no hot branch yet. |
537
542
 
538
543
  Run `python3 ~/.claude/skills/work-plan/work_plan.py --help` for the full list with examples.
539
544
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.13+22f59a8
1
+ 2026.06.14+ef58902
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.13-2",
3
+ "version": "2026.6.14",
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"
@@ -12,7 +12,9 @@ from lib.prompts import parse_flags
12
12
  from lib.git_state import (
13
13
  parse_iso_timestamp, gap_seconds_to_label,
14
14
  branch_in_progress, commits_ahead, uncommitted_file_count, current_branch,
15
+ hot_issue_numbers,
15
16
  )
17
+ from lib.in_progress import issue_in_progress
16
18
  from lib.closure import compute_signals, is_closure_ready
17
19
  from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
18
20
  from lib.next_up import suggest_next_up
@@ -127,6 +129,7 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
127
129
 
128
130
  next_up_items = []
129
131
  next_up_closed_count = 0
132
+ hot_nums = hot_issue_numbers(local) if local else set()
130
133
  for num in next_up_nums:
131
134
  i = issues_by_num.get(num)
132
135
  if not i:
@@ -135,11 +138,20 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
135
138
  if state in ("CLOSED", "MERGED"):
136
139
  next_up_closed_count += 1
137
140
  continue
141
+ manual_blockers = set(meta.get("blockers") or [])
142
+ blocked_disp = []
143
+ for e in (i.get("blocked_by") or []):
144
+ same_repo = e.get("repo") == repo
145
+ if same_repo and e.get("number") in manual_blockers:
146
+ continue
147
+ blocked_disp.append(f"#{e['number']}" if same_repo else f"{e['repo']}#{e['number']}")
138
148
  next_up_items.append({
139
149
  "number": num, "title": i.get("title", ""),
140
150
  "priority": extract_priority(i.get("labels", [])),
141
151
  "state": state.lower() or "open",
142
152
  "milestone": short_milestone(i.get("milestone")),
153
+ "in_progress": issue_in_progress(i, hot_nums),
154
+ "blocked_by_display": blocked_disp,
143
155
  })
144
156
 
145
157
  branch_names = meta.get("github", {}).get("branches") or []
@@ -1,7 +1,7 @@
1
1
  """close-issue — close a GitHub issue via `gh`, optionally with a comment (#305).
2
2
 
3
- ⚠️ This is the toolkit's FIRST and ONLY GitHub-mutating command. Everything else
4
- is read-only on GitHub. PRs merged to `dev` don't auto-close issues (GitHub only
3
+ ⚠️ A GitHub-mutating command (the others: `in-progress`, and `plan-status --issues`).
4
+ Most of the toolkit is read-only on GitHub. PRs merged to `dev` don't auto-close issues (GitHub only
5
5
  auto-closes from the default branch, `main`), so done-but-OPEN issues pile up;
6
6
  this closes one explicitly.
7
7
 
@@ -4,6 +4,7 @@ from datetime import datetime, date
4
4
  from lib.config import load_config, ConfigError, resolve_local_path_for_folder
5
5
  from lib.tracks import discover_tracks
6
6
  from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
7
+ from lib.git_state import hot_issue_numbers
7
8
  from lib.export_model import build_export
8
9
  from lib.prompts import parse_flags
9
10
  from lib import doc_discovery
@@ -75,18 +76,19 @@ def run(args: list[str]) -> int:
75
76
  issue_map = fetch_export_issues(repo_to_numbers)
76
77
 
77
78
  # Reassemble per-track lists, preserving each track's declared issue order.
78
- issues_by_track: dict[str, list] = {}
79
+ # Keyed by (repo, name) so same-named tracks in different repos don't collide.
80
+ issues_by_track: dict[tuple, list] = {}
79
81
  visibility: dict[str, object] = {}
80
82
  for t in tracks:
81
83
  nums = (t.meta.get("github", {}).get("issues")) or []
82
84
  if t.repo and nums:
83
- issues_by_track[t.name] = [
85
+ issues_by_track[(t.repo, t.name)] = [
84
86
  issue_map[(t.repo, n)]
85
87
  for n in nums
86
88
  if (t.repo, n) in issue_map
87
89
  ]
88
90
  else:
89
- issues_by_track[t.name] = []
91
+ issues_by_track[(t.repo, t.name)] = []
90
92
  if t.repo and t.repo not in visibility:
91
93
  visibility[t.repo] = repo_visibility(t.repo)
92
94
 
@@ -127,12 +129,24 @@ def run(args: list[str]) -> int:
127
129
  if badge is not None:
128
130
  plan_by_track[t.name] = badge
129
131
 
132
+ # Per-track branch heat, keyed (repo, name) — track names collide across repos.
133
+ hot_by_track: dict = {}
134
+ for t in tracks:
135
+ if not t.repo:
136
+ continue
137
+ local = resolve_local_path_for_folder(t.folder, cfg) if t.folder else None
138
+ if local and local.exists():
139
+ nums = hot_issue_numbers(local)
140
+ if nums:
141
+ hot_by_track[(t.repo, t.name)] = nums
142
+
130
143
  now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
131
144
  print(json.dumps(
132
145
  build_export(tracks, issues_by_track, visibility, now,
133
146
  untracked_by_repo=untracked_by_repo,
134
147
  config_repos=config_repos,
135
- plan_by_track=plan_by_track),
148
+ plan_by_track=plan_by_track,
149
+ hot_by_track=hot_by_track),
136
150
  indent=2,
137
151
  ))
138
152
  return 0
@@ -0,0 +1,110 @@
1
+ """in-progress — mark/clear a tracked GitHub issue as in-progress via a label (#271).
2
+
3
+ Writes the work-plan:in-progress label through `gh` (the toolkit's 2nd mutating
4
+ command). Repo targeting is REQUIRED: issue numbers are repo-scoped, so we resolve
5
+ <n> to a unique (repo, n) from the tracked set — rejecting ambiguity — or take an
6
+ explicit --repo. Public-repo writes go behind the same confirm-token gate `set` uses.
7
+
8
+ Usage:
9
+ work_plan.py in-progress <n> [--clear] [--repo=<key|slug>] [--confirm=<token>]
10
+ """
11
+ import json
12
+ import sys
13
+
14
+ from lib.config import load_config, ConfigError, resolve_github_for_folder
15
+ from lib.tracks import discover_tracks
16
+ from lib.github_state import set_issue_in_progress
17
+ from lib.write_guard import needs_confirm, make_token, valid_token
18
+ from lib.prompts import parse_flags
19
+
20
+ KNOWN = {"--clear", "--repo", "--confirm"}
21
+
22
+
23
+ def _tracked_repos_for(number, cfg):
24
+ """Return the distinct repo slugs that list `number` in their frontmatter."""
25
+ repos = []
26
+ for t in discover_tracks(cfg):
27
+ if not t.has_frontmatter or not t.repo:
28
+ continue
29
+ if number in ((t.meta.get("github", {}) or {}).get("issues") or []):
30
+ if t.repo not in repos:
31
+ repos.append(t.repo)
32
+ return repos
33
+
34
+
35
+ def _resolve_repo(number, repo_flag, cfg):
36
+ """Resolve a unique github slug for `number`.
37
+
38
+ With --repo: a slug (owner/name) is used directly; a config key is resolved.
39
+ The resolved slug is then validated: if the issue IS tracked somewhere and
40
+ the slug is NOT among those tracked repos, the call is rejected to guard
41
+ against typos labelling the wrong repo. If the issue is tracked nowhere,
42
+ --repo is the only targeting option and is accepted as explicit intent.
43
+ Without --repo: search tracked frontmatter for the distinct repos listing
44
+ `number`. Returns (slug, None) on success, or (None, error_message).
45
+ """
46
+ if isinstance(repo_flag, str) and repo_flag:
47
+ slug = repo_flag if "/" in repo_flag else resolve_github_for_folder(repo_flag, cfg)
48
+ if not slug:
49
+ return (None, f"could not resolve a github slug for --repo={repo_flag!r}.")
50
+ tracked = _tracked_repos_for(number, cfg)
51
+ if tracked and slug not in tracked:
52
+ return (None,
53
+ f"issue #{number} is tracked in {tracked}, not {slug!r} — "
54
+ f"refusing to label the wrong repo "
55
+ f"(drop --repo to use the tracked one, or slot it into a track "
56
+ f"in {slug} first).")
57
+ return (slug, None)
58
+ repos = _tracked_repos_for(number, cfg)
59
+ if not repos:
60
+ return (None, f"issue #{number} is not in any tracked repo — pass --repo=<key|slug>.")
61
+ if len(repos) > 1:
62
+ return (None, f"issue #{number} is ambiguous across repos {repos} — "
63
+ f"pass --repo=<slug> to disambiguate.")
64
+ return (repos[0], None)
65
+
66
+
67
+ def run(args: list) -> int:
68
+ flags, positional = parse_flags(args, KNOWN)
69
+ if not positional:
70
+ print("usage: work_plan.py in-progress <n> [--clear] [--repo=<key|slug>]",
71
+ file=sys.stderr)
72
+ return 2
73
+ try:
74
+ number = int(positional[0])
75
+ except (TypeError, ValueError):
76
+ print(f"ERROR: issue number must be an integer (got {positional[0]!r}).",
77
+ file=sys.stderr)
78
+ return 2
79
+ clear = bool(flags.get("--clear"))
80
+ repo_flag = flags.get("--repo")
81
+ try:
82
+ cfg = load_config()
83
+ except ConfigError as e:
84
+ print(f"ERROR: {e}", file=sys.stderr)
85
+ return 1
86
+
87
+ slug, problem = _resolve_repo(number, repo_flag, cfg)
88
+ if not slug:
89
+ print(f"ERROR: {problem}", file=sys.stderr)
90
+ return 1
91
+
92
+ confirm = flags.get("--confirm")
93
+ if needs_confirm(slug, cfg) and not (
94
+ isinstance(confirm, str) and valid_token(confirm, slug, str(number))
95
+ ):
96
+ print(json.dumps({
97
+ "needs_confirm": True,
98
+ "reason": f"{slug} is PUBLIC (or visibility unknown); the in-progress "
99
+ f"label will be written there.",
100
+ "token": make_token(slug, str(number)),
101
+ }))
102
+ return 0
103
+
104
+ ok, message = set_issue_in_progress(slug, number, clear=clear)
105
+ if not ok:
106
+ print(f"ERROR: failed to update {slug}#{number}: {message}", file=sys.stderr)
107
+ return 1
108
+ verb = "cleared in-progress on" if clear else "marked in-progress"
109
+ print(f"✓ {verb} {slug}#{number}.")
110
+ return 0
@@ -31,7 +31,9 @@ from lib.github_state import fetch_issues, short_milestone
31
31
  from lib.git_state import (
32
32
  parse_iso_timestamp,
33
33
  current_branch, uncommitted_file_count, commits_ahead,
34
+ hot_issue_numbers,
34
35
  )
36
+ from lib.in_progress import issue_in_progress
35
37
  from lib.new_issues import build_slug_labels, find_new_issues_for_tracks
36
38
 
37
39
 
@@ -122,6 +124,8 @@ def _orient_track(track) -> int:
122
124
  titles_by_num: dict[int, str] = {}
123
125
  states_by_num: dict[int, str] = {}
124
126
  milestones_by_num: dict[int, str] = {}
127
+ inprog_by_num: dict = {}
128
+ blocked_by_num: dict = {}
125
129
  if track.repo and next_up:
126
130
  wanted = next_up[:4]
127
131
  fetched = fetch_issues(track.repo, wanted)
@@ -129,6 +133,18 @@ def _orient_track(track) -> int:
129
133
  titles_by_num[i["number"]] = i.get("title", "")
130
134
  states_by_num[i["number"]] = (i.get("state") or "").upper()
131
135
  milestones_by_num[i["number"]] = short_milestone(i.get("milestone"))
136
+ hot = hot_issue_numbers(track.local_path) if track.local_path else set()
137
+ manual_blockers = set(track.meta.get("blockers") or [])
138
+ for i in fetched:
139
+ inprog_by_num[i["number"]] = issue_in_progress(i, hot)
140
+ disp = []
141
+ for e in (i.get("blocked_by") or []):
142
+ same = e.get("repo") == track.repo
143
+ if same and e.get("number") in manual_blockers:
144
+ continue
145
+ disp.append(f"#{e['number']}" if same else f"{e['repo']}#{e['number']}")
146
+ if disp:
147
+ blocked_by_num[i["number"]] = disp
132
148
 
133
149
  print(_top_rule(slug))
134
150
  print(f"Priority: {priority} · Milestone: {milestone} · Repo: {repo}")
@@ -150,7 +166,9 @@ def _orient_track(track) -> int:
150
166
  pick_title = titles_by_num.get(pick_num, "")
151
167
  pick_suffix = _state_suffix(states_by_num.get(pick_num))
152
168
  pick_ms = _milestone_prefix(milestones_by_num.get(pick_num))
153
- print(f"Next pick: #{pick_num} {pick_ms}{pick_title}{pick_suffix}".rstrip())
169
+ print(f"Next pick: #{pick_num} {pick_ms}{pick_title}{pick_suffix}"
170
+ f"{_inprog_suffix(inprog_by_num.get(pick_num, False))}"
171
+ f"{_blocked_suffix(blocked_by_num.get(pick_num))}".rstrip())
154
172
  if _is_closed(states_by_num.get(pick_num)):
155
173
  print(f" ⚠ next_up:[0] has shipped — run `/work-plan handoff {slug}` to rotate")
156
174
  rest = next_up[1:4]
@@ -161,7 +179,9 @@ def _orient_track(track) -> int:
161
179
  title = titles_by_num.get(num, "")
162
180
  suffix = _state_suffix(states_by_num.get(num))
163
181
  ms = _milestone_prefix(milestones_by_num.get(num))
164
- print(f" #{num} {ms}{title}{suffix}".rstrip())
182
+ print(f" #{num} {ms}{title}{suffix}"
183
+ f"{_inprog_suffix(inprog_by_num.get(num, False))}"
184
+ f"{_blocked_suffix(blocked_by_num.get(num))}".rstrip())
165
185
  else:
166
186
  print("Next pick: (none set — run `/work-plan handoff` to set one)")
167
187
 
@@ -235,6 +255,14 @@ def _state_suffix(state: Optional[str]) -> str:
235
255
  return " (closed)" if _is_closed(state) else ""
236
256
 
237
257
 
258
+ def _inprog_suffix(flag: bool) -> str:
259
+ return " ▶ in-progress" if flag else ""
260
+
261
+
262
+ def _blocked_suffix(disp: Optional[list]) -> str:
263
+ return (" ⊘ blocked by " + ", ".join(disp)) if disp else ""
264
+
265
+
238
266
  def _milestone_prefix(ms: Optional[str]) -> str:
239
267
  return f"[{ms}] " if ms else ""
240
268
 
@@ -52,10 +52,22 @@ def group_issues_by_milestone(issues, milestone_alignment=None):
52
52
  return groups
53
53
 
54
54
 
55
- def normalize_issue(i: dict) -> dict:
55
+ def normalize_issue(i: dict, in_progress: bool = False,
56
+ in_progress_label: bool = False,
57
+ blocked_by=None, blocking=None) -> dict:
56
58
  """Reshape a raw gh issue row into the viewer's `Issue` shape
57
- ({number,title,state,assignee,milestone}). Shared by the export and the
58
- `list-open-issues` command (#282) so both emit an identical issue surface."""
59
+ ({number,title,state,assignee,milestone,in_progress,in_progress_label,
60
+ blocked_by,blocking}).
61
+ Shared by the export and the `list-open-issues` command (#282) so both
62
+ emit an identical issue surface.
63
+
64
+ `in_progress` is the UNION signal (hot branch OR label) — used by the
65
+ badge. `in_progress_label` reflects LABEL presence only — used by the
66
+ toggle button so it accurately shows Mark/Clear for the label, not the
67
+ union.
68
+ `blocked_by` / `blocking` are lists of cross-issue dependency refs
69
+ (#257); default to [] when absent.
70
+ """
59
71
  state = (i.get("state") or "OPEN").lower()
60
72
  return {
61
73
  "number": i.get("number"),
@@ -63,16 +75,35 @@ def normalize_issue(i: dict) -> dict:
63
75
  "state": "closed" if state in ("closed", "merged") else "open",
64
76
  "assignee": (format_assignees(i) if i.get("assignees") else "—"),
65
77
  "milestone": short_milestone(i.get("milestone")) or None,
78
+ "in_progress": bool(in_progress),
79
+ "in_progress_label": bool(in_progress_label),
80
+ "blocked_by": list(blocked_by or []),
81
+ "blocking": list(blocking or []),
66
82
  }
67
83
 
68
84
 
69
85
  def build_export(tracks, issues_by_track, visibility, now: str,
70
86
  untracked_by_repo=None, config_repos=None,
71
- plan_by_track=None) -> dict:
87
+ plan_by_track=None, hot_by_track=None) -> dict:
72
88
  plan_by_track = plan_by_track or {}
89
+ hot_by_track = hot_by_track or {}
73
90
  out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
74
91
  for t in tracks:
75
- issues = [normalize_issue(i) for i in issues_by_track.get(t.name, [])]
92
+ from lib.in_progress import issue_in_progress, IN_PROGRESS_LABEL
93
+ hot = hot_by_track.get((t.repo, t.name), set())
94
+ raw = issues_by_track.get((t.repo, t.name), [])
95
+ issues = [
96
+ normalize_issue(
97
+ i,
98
+ in_progress=issue_in_progress(i, hot),
99
+ in_progress_label=IN_PROGRESS_LABEL in {
100
+ l.get("name") for l in (i.get("labels") or [])
101
+ },
102
+ blocked_by=i.get("blocked_by"),
103
+ blocking=i.get("blocking"),
104
+ )
105
+ for i in raw
106
+ ]
76
107
  milestone_alignment = t.meta.get("milestone_alignment")
77
108
  issues.sort(key=lambda i: milestone_sort_key(i, milestone_alignment))
78
109
  opened = sum(1 for i in issues if i["state"] == "open")
@@ -1,4 +1,5 @@
1
1
  """Local git queries + time helpers."""
2
+ import re
2
3
  import subprocess
3
4
  from datetime import date, datetime, timedelta
4
5
  from pathlib import Path
@@ -131,6 +132,37 @@ def branch_in_progress(branch_name: str, repo_path: Path) -> bool:
131
132
  return _has_recent_commits(branch_name, repo_path, hours=24)
132
133
 
133
134
 
135
+ # Maps a conventional branch name to its issue number. Anchored at start and
136
+ # requires a trailing '-' so `feat/2710-x` captures 2710, never the `271`
137
+ # substring. Only feat/ and fix/ — `work-plan/plan` (#260) carries no issue
138
+ # number, and there is no `plan/<n>-` convention.
139
+ _BRANCH_ISSUE_RE = re.compile(r"^(?:feat|fix)/(\d+)-")
140
+
141
+
142
+ def hot_issue_numbers(repo_path: Path) -> set:
143
+ """Issue numbers with a 'hot' branch in `repo_path`.
144
+
145
+ Enumerates local branches with `git branch --format=%(refname:short)` (the
146
+ --format is load-bearing: plain `git branch` prefixes lines with ` `/`* `/`+ `,
147
+ which would defeat the anchored regex), maps each `feat/<n>-`/`fix/<n>-` name
148
+ to <n>, and keeps those whose branch is `branch_in_progress`.
149
+
150
+ Failure contract: if the enumeration call fails -> empty set. A per-branch heat
151
+ check that fails collapses to cold (that branch is simply not added). Never raises.
152
+ """
153
+ if not repo_path or not Path(repo_path).exists():
154
+ return set()
155
+ proc = _git(repo_path, "branch", "--format=%(refname:short)", "--list")
156
+ if proc is None or proc.returncode != 0:
157
+ return set()
158
+ out = set()
159
+ for line in proc.stdout.splitlines():
160
+ m = _BRANCH_ISSUE_RE.match(line.strip())
161
+ if m and branch_in_progress(line.strip(), repo_path):
162
+ out.add(int(m.group(1)))
163
+ return out
164
+
165
+
134
166
  def last_commit_date(branch_name: str, repo_path: Path) -> Optional[datetime]:
135
167
  """Most recent commit timestamp on branch (naive)."""
136
168
  if not repo_path or not Path(repo_path).exists():
@@ -5,6 +5,8 @@ import subprocess
5
5
  from concurrent.futures import ThreadPoolExecutor
6
6
  from typing import Iterable, Optional
7
7
 
8
+ from lib.in_progress import IN_PROGRESS_LABEL
9
+
8
10
  PRIORITY_LABELS = ("priority/P0", "priority/P1", "priority/P2", "priority/P3")
9
11
  DEFAULT_PRIORITY = "P3"
10
12
 
@@ -29,8 +31,9 @@ def _valid_repo(repo: str) -> bool:
29
31
 
30
32
 
31
33
  def close_issue(repo: str, number: int, reason=None, comment=None) -> tuple:
32
- """Close a GitHub issue via `gh issue close` — the toolkit's ONLY
33
- GitHub-mutating call (#305). Everything else here is read-only.
34
+ """Close a GitHub issue via `gh issue close` — one of the toolkit's
35
+ GitHub-mutating calls (also `set_issue_in_progress`, `create_issue`).
36
+ Everything else here is read-only.
34
37
 
35
38
  Returns (ok, message). `reason` ∈ {completed, not_planned} maps to
36
39
  `--reason`; `comment` (if given) posts a closing comment. The issue number
@@ -54,6 +57,36 @@ def close_issue(repo: str, number: int, reason=None, comment=None) -> tuple:
54
57
  return (True, (proc.stdout or f"closed #{number}").strip())
55
58
 
56
59
 
60
+ def set_issue_in_progress(repo: str, number: int, clear: bool = False) -> tuple:
61
+ """Add or remove the work-plan:in-progress label on a GitHub issue (#271).
62
+
63
+ The toolkit's second GitHub-mutating call (close_issue is the first). On add,
64
+ the label is created first (`--force` is idempotent: updates color/description
65
+ if it already exists) so `--add-label` can't fail on a missing label. Both gh
66
+ calls are --repo-qualified — issue numbers are repo-scoped. Returns (ok, message);
67
+ never raises. number->str for argv, repo validated owner/name, so neither injects.
68
+ """
69
+ if not _valid_repo(repo):
70
+ return (False, f"invalid repo '{repo}'")
71
+ try:
72
+ if not clear:
73
+ create = ["gh", "label", "create", IN_PROGRESS_LABEL, "--repo", repo,
74
+ "--color", "FBCA04",
75
+ "--description", "Actively being worked (work-plan)", "--force"]
76
+ proc = subprocess.run(create, capture_output=True, text=True, timeout=GH_TIMEOUT)
77
+ if proc.returncode != 0:
78
+ return (False, (proc.stderr or proc.stdout or "gh label create failed").strip())
79
+ flag = "--remove-label" if clear else "--add-label"
80
+ edit = ["gh", "issue", "edit", str(int(number)), "--repo", repo, flag, IN_PROGRESS_LABEL]
81
+ proc = subprocess.run(edit, capture_output=True, text=True, timeout=GH_TIMEOUT)
82
+ except Exception as e:
83
+ return (False, f"gh in-progress write failed: {e}")
84
+ if proc.returncode != 0:
85
+ return (False, (proc.stderr or proc.stdout or "gh issue edit failed").strip())
86
+ verb = "cleared" if clear else "marked"
87
+ return (True, (proc.stdout or f"{verb} #{number} in-progress").strip())
88
+
89
+
57
90
  def gh_auth_status() -> dict:
58
91
  """Probe `gh` authentication so callers can fast-fail instead of silently
59
92
  degrading (#auth). Returns:
@@ -161,7 +194,8 @@ def _normalize_gql_node(node) -> Optional[dict]:
161
194
  expect (labels as [{name}], assignees as [{login}], milestone as {title}|None).
162
195
  None for a null node.
163
196
  On success returns a dict with keys: number, title, state, labels, milestone,
164
- closedAt, body, url, updatedAt, assignees."""
197
+ closedAt, body, url, updatedAt, assignees, blocked_by, blocking,
198
+ deps_truncated."""
165
199
  if not node:
166
200
  return None
167
201
  labels = [{"name": l.get("name")} for l in
@@ -169,6 +203,20 @@ def _normalize_gql_node(node) -> Optional[dict]:
169
203
  assignees = [{"login": a.get("login")} for a in
170
204
  ((node.get("assignees") or {}).get("nodes") or []) if a.get("login")]
171
205
  ms = node.get("milestone")
206
+
207
+ def _deps(key):
208
+ conn = node.get(key) or {}
209
+ nodes = conn.get("nodes") or []
210
+ open_edges = [{"number": n.get("number"),
211
+ "repo": (n.get("repository") or {}).get("nameWithOwner"),
212
+ "title": n.get("title", "")}
213
+ for n in nodes if (n.get("state") or "").upper() == "OPEN"]
214
+ truncated = (conn.get("totalCount") or 0) > len(nodes)
215
+ return open_edges, truncated
216
+ blocked_by, _bb_trunc = _deps("blockedBy")
217
+ blocking, _bl_trunc = _deps("blocking")
218
+ deps_truncated = _bb_trunc or _bl_trunc
219
+
172
220
  return {
173
221
  "number": node.get("number"),
174
222
  "title": node.get("title", ""),
@@ -180,6 +228,9 @@ def _normalize_gql_node(node) -> Optional[dict]:
180
228
  "url": node.get("url", ""),
181
229
  "updatedAt": node.get("updatedAt"),
182
230
  "assignees": assignees,
231
+ "blocked_by": blocked_by,
232
+ "blocking": blocking,
233
+ "deps_truncated": deps_truncated,
183
234
  }
184
235
 
185
236
 
@@ -187,7 +238,7 @@ def _normalize_gql_node(node) -> Optional[dict]:
187
238
  # Kept as a module-level constant so _gql_query can parameterize at the call site.
188
239
  _GQL_FIELDS_FULL = (
189
240
  "number title state"
190
- " labels(first: 20) { nodes { name } }"
241
+ " labels(first: 50) { nodes { name } }"
191
242
  " milestone { title }"
192
243
  " closedAt body url updatedAt"
193
244
  " assignees(first: 10) { nodes { login } }"
@@ -195,19 +246,33 @@ _GQL_FIELDS_FULL = (
195
246
 
196
247
  _GQL_FIELDS_LEAN = (
197
248
  "number title state"
249
+ " labels(first: 50) { nodes { name } }"
198
250
  " assignees(first: 10) { nodes { login } }"
199
251
  " milestone { title }"
200
252
  )
201
253
 
254
+ # Issue dependency edges (#257). Issue-ONLY: PullRequest has no blockedBy/blocking,
255
+ # and _gql_query shares the base field set across both fragments — so these are
256
+ # appended only to the `... on Issue` fragment. No server-side state filter exists
257
+ # (the connection takes only orderBy + cursor args), so OPEN-filtering is done in
258
+ # _normalize_gql_node; totalCount detects first:50 truncation (confirmed live field).
259
+ _GQL_ISSUE_DEPS = (
260
+ " blockedBy(first: 50) { totalCount nodes { number state title repository { nameWithOwner } } }"
261
+ " blocking(first: 50) { totalCount nodes { number state title repository { nameWithOwner } } }"
262
+ )
263
+
202
264
 
203
265
  def _gql_query(owner: str, name: str, numbers: list,
204
266
  fields: str = _GQL_FIELDS_LEAN) -> str:
205
267
  """Build a batched GraphQL query for issueOrPullRequest nodes.
206
268
  `fields` selects the GQL field set; _GQL_FIELDS_LEAN for export, _GQL_FIELDS_FULL
207
- for fetch_issues (which needs labels, closedAt, body, url, updatedAt)."""
269
+ for fetch_issues (which needs labels, closedAt, body, url, updatedAt).
270
+ _GQL_ISSUE_DEPS is appended to the Issue fragment only — PullRequest does not
271
+ expose blockedBy/blocking fields."""
208
272
  aliases = "\n".join(
209
273
  f' i{n}: issueOrPullRequest(number: {int(n)}) {{ '
210
- f'... on Issue {{ {fields} }} ... on PullRequest {{ {fields} }} }}'
274
+ f'... on Issue {{ {fields}{_GQL_ISSUE_DEPS} }} '
275
+ f'... on PullRequest {{ {fields} }} }}'
211
276
  for n in numbers
212
277
  )
213
278
  return f'query {{ repository(owner: "{owner}", name: "{name}") {{\n{aliases}\n}} }}'
@@ -0,0 +1,23 @@
1
+ """Join the two issue-level in-progress signals into one boolean (#271).
2
+
3
+ GitHub is canonical; nothing here is cached. `hot_nums` comes from live git
4
+ (lib.git_state.hot_issue_numbers); the label is read from a live `gh` fetch.
5
+ """
6
+
7
+ IN_PROGRESS_LABEL = "work-plan:in-progress"
8
+
9
+
10
+ def issue_in_progress(issue_row: dict, hot_nums) -> bool:
11
+ """True iff the issue is OPEN and (its number is hot OR it carries the label).
12
+
13
+ Closed/merged always returns False (closed wins). `issue_row` is a fetched
14
+ gh issue dict ({number, state, labels:[{name}]}); `hot_nums` is a set of ints.
15
+ """
16
+ state = (issue_row.get("state") or "OPEN").upper()
17
+ if state != "OPEN":
18
+ return False
19
+ number = issue_row.get("number")
20
+ if number in hot_nums:
21
+ return True
22
+ names = {l.get("name") for l in (issue_row.get("labels") or [])}
23
+ return IN_PROGRESS_LABEL in names
@@ -36,6 +36,11 @@ def render_track_row(t: dict) -> str:
36
36
  if t["next_up"]:
37
37
  for idx, item in enumerate(t["next_up"]):
38
38
  bits = [item["priority"], item["state"]]
39
+ if item.get("in_progress"):
40
+ bits.append("▶ in-progress")
41
+ blocked = item.get("blocked_by_display") or []
42
+ if blocked:
43
+ bits.append("⊘ blocked by " + ", ".join(blocked))
39
44
  if item.get("milestone"):
40
45
  bits.append(item["milestone"])
41
46
  label = f"#{item['number']} {item['title']} ({', '.join(bits)})"
@@ -1,5 +1,5 @@
1
- """close-issue (#305): the toolkit's only GitHub-mutating command. Offline
2
- the gh subprocess is mocked."""
1
+ """close-issue (#305): a GitHub-mutating command (also in-progress, plan-status
2
+ --issues). Offline — the gh subprocess is mocked."""
3
3
  import io
4
4
  import json
5
5
  import sys