@stylusnexus/work-plan 2026.6.11 → 2026.6.13

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.
Files changed (31) hide show
  1. package/README.md +26 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/commands/export.py +20 -2
  5. package/skills/work-plan/commands/group.py +5 -1
  6. package/skills/work-plan/commands/init_repo.py +84 -14
  7. package/skills/work-plan/commands/list_open_issues.py +52 -0
  8. package/skills/work-plan/commands/new_track.py +8 -2
  9. package/skills/work-plan/commands/plan_branch.py +314 -0
  10. package/skills/work-plan/commands/plan_status.py +76 -9
  11. package/skills/work-plan/commands/reconcile.py +49 -34
  12. package/skills/work-plan/commands/refresh_md.py +49 -1
  13. package/skills/work-plan/commands/remove_repo.py +69 -0
  14. package/skills/work-plan/lib/export_model.py +21 -4
  15. package/skills/work-plan/lib/git_state.py +22 -0
  16. package/skills/work-plan/lib/manifest.py +10 -0
  17. package/skills/work-plan/lib/plan_worktree.py +288 -0
  18. package/skills/work-plan/lib/tracks.py +6 -2
  19. package/skills/work-plan/lib/verdict.py +1 -0
  20. package/skills/work-plan/tests/test_export.py +40 -0
  21. package/skills/work-plan/tests/test_export_command.py +19 -0
  22. package/skills/work-plan/tests/test_init_repo.py +100 -1
  23. package/skills/work-plan/tests/test_list_open_issues.py +83 -0
  24. package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
  25. package/skills/work-plan/tests/test_plan_branch.py +279 -0
  26. package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
  27. package/skills/work-plan/tests/test_plan_worktree.py +378 -0
  28. package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
  29. package/skills/work-plan/tests/test_refresh_md.py +75 -0
  30. package/skills/work-plan/tests/test_remove_repo.py +77 -0
  31. package/skills/work-plan/work_plan.py +95 -6
package/README.md CHANGED
@@ -138,6 +138,22 @@ The CLI never auto-pushes. When you create or update a shared track, it prints a
138
138
  ↑ shared — commit + push to share with teammates.
139
139
  ```
140
140
 
141
+ ### Canonical plan branch (`plan-branch`)
142
+
143
+ Storing the plan as files *in* the repo makes it branch-scoped — but a track's status and priorities are facts about the **project**, not about a code branch. On a repo with `dev` + `main` + feature branches that means cross-branch plan divergence, merge conflicts on status-table edits, and planning churn polluting feature PRs and the deploy diff.
144
+
145
+ `plan-branch` pins the shared tier to **one canonical branch** per repo, read and written through a dedicated git worktree, so the plan lives off your code branches entirely — yet the CLI and VS Code viewer always show it from any checkout.
146
+
147
+ ```bash
148
+ /work-plan plan-branch init myproject # orphan branch `work-plan/plan` + skeleton (local only)
149
+ /work-plan plan-branch status myproject # exists? published? how many unpushed commits?
150
+ /work-plan plan-branch push myproject # share it — gated with a confirm token on PUBLIC repos
151
+ ```
152
+
153
+ The default branch is an **orphan** `work-plan/plan` (no shared history with your code, like `gh-pages`) — override with `--branch=<name>`. `init` is **local only**; a teammate who already published the branch is auto-**connected** instead of re-created. Once `plan_branch` is set, every shared-track write is committed onto that branch via the worktree (never your working branch), and `push` is the one deliberate step that shares it. `push --dry-run` previews exactly what would be exposed first.
154
+
155
+ > **Tip:** exclude the plan branch from CI by adding `!work-plan/**` to your workflow's `on: push` branch filter, so planning commits never trigger builds or deploys.
156
+
141
157
  **Opt out per-command:** pass `--private` to route a specific track to `notes_root` instead:
142
158
 
143
159
  ```bash
@@ -281,7 +297,7 @@ To install for **both** Claude Code AND Codex, run the installer twice with diff
281
297
 
282
298
  ### VS Code extension
283
299
 
284
- The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks), a Mermaid dependency graph (with focus toggle and repo-scoped full map), the Untracked bucket, cross-track dependency chips, per-issue move buttons, and full read/write (slot/close/edit/move/new-track/…) with a public-repo confirm modal.
300
+ The **Work Plan** extension is the visual face of the CLI — a sidebar tree (repos → tracks), a Mermaid dependency graph (with focus toggle and repo-scoped full map), the Untracked bucket, cross-track dependency chips, per-issue move buttons, **keyword issue search** (`%wildcard%` substitution, results in a dedicated tab), the daily-driver **Brief / Re-orient / Handoff** commands, an inline **active lens + sort** indicator under the view title, and full read/write (slot/close/edit/move/new-track/…) with a public-repo confirm modal.
285
301
 
286
302
  ![Work Plan VS Code extension — sidebar and dependency graph](https://raw.githubusercontent.com/stylusnexus/work-plan-toolkit/main/vscode/media/screenshots/dependency-graph.png)
287
303
 
@@ -495,15 +511,17 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
495
511
  | `orient [track]` (alias: `where-was-i`) | Read-only paste block. With a track name: ~15-line track summary (priority, last session, next pick, git state). With no track: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
496
512
  | `slot <issue-num> [track]` | A new GitHub issue should belong to a track — adds it to the track's `github.issues` list. Non-interactive flags: `--move`/`--no-move` (relocate the issue off its prior track, or leave it; default no-move), `--confirm=<token>` (public-repo gate, see below). |
497
513
  | `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. |
498
- | `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Sync issue STATE (open/closed, status labels) from GitHub into the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." For a **canonical** table it re-derives the whole block from live data, milestone-ordered (active milestone first; see `canonicalize`), so the table self-heals and stays grouped instead of decaying; narrative (non-canonical) tables are updated conservatively in place. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
514
+ | `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Sync issue STATE (open/closed, status labels) from GitHub into the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." For a **canonical** table it re-derives the whole block from live data, milestone-ordered (active milestone first; see `canonicalize`), so the table self-heals and stays grouped instead of decaying; narrative (non-canonical) tables are updated conservatively in place. If the live fetch comes back incomplete (GitHub timeout/permission error, or a frontmatter issue that no longer resolves), that track is **skipped and left untouched** rather than rewriting valid rows as `(not fetched)`, and the command exits nonzero so sweeps can flag the degraded run. `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
499
515
  | `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. |
500
516
  | `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. |
501
517
  | `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. |
502
- | `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. |
518
+ | `init-repo <key> --github=<slug> [--local=<path>] [--update [--clear-local]]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required for an add; `--local` is optional. `--update` on an existing key changes its local/github; `--update --clear-local` forgets the saved local path (keeps github + other fields). `--clear-local` and `--local` are mutually exclusive. |
519
+ | `remove-repo <key>` | Unregister a repo: delete its block from your config. **Config-only** — the notes folder, any tracks, and the local clone are left untouched (a notes folder or tracks that referenced it are now orphaned and can be removed by hand). Completes the add/update/remove trio with `init-repo`. |
503
520
  | `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. |
504
521
  | `rename-track <old-slug \| old@repo> <new-slug> [--repo=<key>] [--fix-refs] [--commit]` | Rename an active track's slug: moves its `.md` file and updates the frontmatter `track` field + `last_touched`. Validates `<new-slug>` like `new-track` and rejects a name already taken in the same repo/tier. For shared tracks, `--commit` stages + commits the move (otherwise it prints a "commit to share" hint). `--fix-refs` rewrites sibling tracks' `depends_on` that reference the old slug; without it they're just warned about. Archived tracks are immutable. Public-repo gated. |
505
522
  | `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
506
523
  | `notes-vcs <init\|enable\|disable\|status\|undo> [<sha>] [--no-enable] [--json]` | Opt-in **local** version control for the private `notes_root` tier — history/undo for tracks on your machine, never pushed. `init` git-inits `notes_root` as a personal repo (baseline commit of existing tracks) and turns on auto-commit (`--no-enable` skips that). For safety it **refuses** a `notes_root` that already has a git remote or is a repo work-plan didn't create. When on, every track-mutating command (`slot`/`group`/`handoff`/`close`/`set`/…) commits **only the files it changed** (pre-existing uncommitted edits are left alone) as an undoable commit. The shared tier is unaffected — it's versioned by its own repo. `status` shows whether `notes_root` is a repo, whether auto-commit is on, and the last commit (`--json` for the machine shape the VS Code viewer polls). `undo [<sha>]` reverts a commit (default HEAD) — reverses the last edit. |
524
+ | `plan-branch <init\|status\|push> <repo> [--branch=<name>] [--confirm=<token>] [--dry-run] [--json]` | Set up and share a repo's canonical **shared-tier** plan branch. The `.work-plan/` tier is pinned to ONE per-repo `plan_branch`, read/written through a dedicated git worktree, so planning never diverges across code branches or pollutes PR / deploy diffs. `init` creates that branch + a `.work-plan/` skeleton (default an **orphan** `work-plan/plan` — zero shared history with code, like `gh-pages`; override with `--branch`) and records `plan_branch` in config — or **connects** to a teammate's already-published branch if one exists. `init` is **local only** (no push). `status` reports whether the branch exists, is published to origin, and how many commits are unpushed (`--json` for the machine shape). `push` shares it: on a **public** repo it prints a confirm heads-up + token and exits (re-run with `--confirm=<token>`); `--dry-run` previews the commits that would push. Requires a repo registered via `init-repo` with a local clone path. |
507
525
  | `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
508
526
  | `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). |
509
527
  | `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). |
@@ -521,7 +539,11 @@ Every write verb the VS Code extension drives runs **without a TTY** — explici
521
539
 
522
540
  `needs_confirm` fails **closed** — unknown visibility prompts too. An all-private team can opt out of the *unknown-visibility* case (e.g. when a `gh` lookup flakes) by setting `assume_private_when_unknown: true` in `~/.claude/work-plan/config.yml`; **public repos always prompt regardless.**
523
541
 
524
- `export --json` is the viewer's read surface (schema 1): every frontmatter'd track plus an additive `untracked` list of open issues that no track references, per repo.
542
+ `export --json` is the viewer's read surface (schema 1): every frontmatter'd track plus an additive `untracked` list of open issues that no track references, per repo. It also emits a top-level `repos` list — every configured repo (`folder`/`repo`/`local`/`has_local`/`visibility`), tracked or not — so the viewer can show a freshly registered repo in the sidebar independent of whether it has any tracks yet.
543
+
544
+ `list-open-issues --repo=<owner/name> [--exclude=<csv>]` is a second viewer read surface: it emits a repo's **open** issues as JSON (`{repo, issues:[…]}`, the same per-issue shape as `export`). The extension's **Slot** command uses it to offer a pick-list instead of a typed number; `--exclude` drops the track's current issues so they don't reappear. Unlike `export`'s `untracked` (open issues in *no* track), this includes issues tracked by *other* tracks — they're valid slot targets. Read-only.
545
+
546
+ `plan-status --json` is the viewer's **Plans view** read surface: alongside each doc's verdict it now also emits `manifest_last_touched` (the most recent commit date across the plan's declared files), `stalled`, `lie_gap`, `unchecked_items`, and `stall_days`. The staleness window honors `stall_days:` in `~/.claude/work-plan/config.yml` and a `--stall-days=<n>` flag (precedence: flag → config → default 14). The viewer consumes these to flag plans whose declared-file build has gone cold — a `partial` plan with no recent commit on its manifest ("stalled") — and plans scored shipped whose own phase checkboxes are mostly unticked ("lie-gap").
525
547
 
526
548
  ## Version
527
549
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.06.11+8c21445
1
+ 2026.06.13+627d944
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.6.11",
3
+ "version": "2026.6.13",
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"
@@ -1,7 +1,7 @@
1
1
  """export subcommand — emit the viewer-ready JSON read surface."""
2
2
  import json
3
3
  from datetime import datetime
4
- from lib.config import load_config, ConfigError
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
7
  from lib.export_model import build_export
@@ -60,10 +60,28 @@ def run(args: list[str]) -> int:
60
60
  open_rows = fetch_open_issues(repo)
61
61
  untracked_by_repo[repo] = [r for r in open_rows if r.get("number") not in tracked]
62
62
 
63
+ # Every CONFIGURED repo, regardless of whether any track references it (#288).
64
+ # Lets the viewer show a registered-but-empty repo so the user can start
65
+ # adding tracks to it. visibility is filled here for repos no track covered.
66
+ config_repos = []
67
+ for folder, block in (cfg.get("repos") or {}).items():
68
+ slug = block.get("github") if isinstance(block, dict) else None
69
+ local = resolve_local_path_for_folder(folder, cfg)
70
+ if slug and slug not in visibility:
71
+ visibility[slug] = repo_visibility(slug)
72
+ config_repos.append({
73
+ "folder": folder,
74
+ "repo": slug,
75
+ "local": str(local) if local else None,
76
+ "has_local": bool(local and local.exists()),
77
+ "visibility": visibility.get(slug),
78
+ })
79
+
63
80
  now = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
64
81
  print(json.dumps(
65
82
  build_export(tracks, issues_by_track, visibility, now,
66
- untracked_by_repo=untracked_by_repo),
83
+ untracked_by_repo=untracked_by_repo,
84
+ config_repos=config_repos),
67
85
  indent=2,
68
86
  ))
69
87
  return 0
@@ -15,6 +15,7 @@ from datetime import datetime
15
15
  from pathlib import Path
16
16
 
17
17
  from lib.config import load_config, ConfigError, is_valid_git_repo
18
+ from lib.plan_worktree import shared_tier_dir
18
19
  from lib.frontmatter import parse_file, write_file
19
20
  from lib.notes_readme import seed_readme
20
21
  from lib.scratch import cache_dir
@@ -182,7 +183,10 @@ def _apply(cfg: dict, args: list[str] = None) -> int:
182
183
  if not use_private and local_raw:
183
184
  local_path = Path(local_raw).expanduser()
184
185
  if is_valid_git_repo(local_path):
185
- shared_dir = local_path / ".work-plan"
186
+ # Worktree-aware (#260): for a `plan_branch` repo this resolves to
187
+ # the worktree's .work-plan/; otherwise the working tree's. None when
188
+ # a plan_branch isn't bootstrapped yet → falls back to private tier.
189
+ shared_dir = shared_tier_dir(repo_entry)
186
190
 
187
191
  if shared_dir is not None:
188
192
  track_dir = shared_dir
@@ -50,10 +50,56 @@ def _report_shared_tracks(local_path: "Path | None") -> None:
50
50
  )
51
51
 
52
52
 
53
+ def _update_existing(key: str, github: str, local: "str | None", clear_local: bool = False) -> int:
54
+ """Update an already-registered repo's local (and github if it differs).
55
+
56
+ Does NOT recreate the notes folder / archive dirs — they already exist.
57
+ Uses the same env()-via-opaque-block yq pattern as a fresh add, setting only
58
+ the fields that change so other keys in the block are preserved.
59
+
60
+ clear_local sets `.repos.<key>.local = null` (forget a stale checkout path)
61
+ while keeping github + every other field. Mutually exclusive with `local`
62
+ (enforced in run() before this is called).
63
+ """
64
+ updates = {}
65
+ if clear_local:
66
+ # JSON null → YAML null; the * merge overwrites local with null, leaving
67
+ # github + other keys intact (same opaque-env discipline as below).
68
+ updates["local"] = None
69
+ elif local:
70
+ updates["local"] = local
71
+ if github:
72
+ updates["github"] = github
73
+ if not updates:
74
+ print(f"ℹ Nothing to update for repo '{key}' (no --local, --clear-local, or --github given).")
75
+ return 0
76
+
77
+ # `key` is validated against ^[a-z][a-z0-9-]*$ in run() before this is called,
78
+ # so it's safe in the yq path. Field values travel as an OPAQUE env value via
79
+ # env() (parsed as JSON), never interpolated — uniform with the add path.
80
+ env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
81
+ yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
82
+ try:
83
+ subprocess.run(
84
+ ["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
85
+ check=True, capture_output=True, text=True, env=env,
86
+ )
87
+ except subprocess.CalledProcessError as e:
88
+ print(f"ERROR: yq failed to update config: {e.stderr}")
89
+ return 1
90
+ if clear_local:
91
+ print(f"✓ Cleared local path for '{key}'")
92
+ elif local:
93
+ print(f"✓ Updated repo '{key}' local path → {local}")
94
+ else:
95
+ print(f"✓ Updated repo '{key}' in {DEFAULT_CONFIG_PATH}")
96
+ return 0
97
+
98
+
53
99
  def run(args: list[str]) -> int:
54
- flags, positional = parse_flags(args, {"--github", "--local"})
100
+ flags, positional = parse_flags(args, {"--github", "--local", "--update", "--clear-local"})
55
101
  if not positional:
56
- print("usage: work_plan.py init-repo <key> --github=<org/repo> [--local=<path>]")
102
+ print("usage: work_plan.py init-repo <key> --github=<org/repo> [--local=<path>] [--update [--clear-local]]")
57
103
  return 2
58
104
 
59
105
  key = positional[0]
@@ -61,13 +107,23 @@ def run(args: list[str]) -> int:
61
107
  print(f"ERROR: '{key}' is not a valid key. Use lowercase letters, digits, hyphens; must start with a letter.")
62
108
  return 2
63
109
 
64
- # --github is required; no prompt fallback
110
+ clear_local = bool(flags.get("--clear-local"))
111
+ local = flags.get("--local") or None
112
+
113
+ # --clear-local forgets the saved local path; pairing it with --local (which
114
+ # SETS a path) is contradictory.
115
+ if clear_local and local:
116
+ print("ERROR: --clear-local and --local are mutually exclusive.")
117
+ return 2
118
+
119
+ # --github is required for a fresh add / a github change, but --clear-local is
120
+ # a field-only edit on an existing block, so we don't force it there.
65
121
  github = flags.get("--github")
66
- if not github or "/" not in github:
67
- if not github:
68
- print("ERROR: --github is required (e.g. --github=org/repo).")
69
- else:
70
- print("ERROR: github slug must be in the form 'org/repo'.")
122
+ if github and "/" not in github:
123
+ print("ERROR: github slug must be in the form 'org/repo'.")
124
+ return 2
125
+ if not github and not clear_local:
126
+ print("ERROR: --github is required (e.g. --github=org/repo).")
71
127
  return 2
72
128
 
73
129
  try:
@@ -77,19 +133,33 @@ def run(args: list[str]) -> int:
77
133
  print("\nRun ./install.sh from the toolkit root to seed your config first.")
78
134
  return 1
79
135
 
80
- if key in cfg.get("repos", {}):
81
- print(f"ERROR: repo '{key}' already exists in {DEFAULT_CONFIG_PATH}.")
82
- print("Edit it manually, or pick a different key.")
83
- return 1
136
+ update = bool(flags.get("--update"))
137
+ existing = cfg.get("repos", {})
84
138
 
85
- # --local is optional; if absent, skip (no prompt)
86
- local = flags.get("--local") or None
139
+ # --clear-local is an update-only operation on an existing key.
140
+ if clear_local:
141
+ if not update:
142
+ print("ERROR: --clear-local requires --update (it edits an existing repo).")
143
+ return 2
144
+ if key not in existing:
145
+ print(f"ERROR: repo '{key}' not found in {DEFAULT_CONFIG_PATH} — nothing to clear.")
146
+ return 1
147
+ return _update_existing(key, github or "", None, clear_local=True)
148
+
149
+ # --local is optional; if absent, skip (no prompt). Validate it exists.
87
150
  local_path = None
88
151
  if local:
89
152
  local_path = Path(local).expanduser()
90
153
  if not local_path.exists():
91
154
  print(f"WARN: {local_path} does not exist. Saving anyway — fix later if wrong.")
92
155
 
156
+ if key in existing:
157
+ if not update:
158
+ print(f"ERROR: repo '{key}' already exists in {DEFAULT_CONFIG_PATH}.")
159
+ print("Pass --update to change its local path, or pick a different key.")
160
+ return 1
161
+ return _update_existing(key, github, local)
162
+
93
163
  notes_root = Path(cfg["notes_root"]).expanduser()
94
164
  if not notes_root.exists():
95
165
  print(f"ERROR: notes_root {notes_root} does not exist.")
@@ -0,0 +1,52 @@
1
+ """list-open-issues subcommand — emit a repo's open issues as JSON.
2
+
3
+ A read surface for the VS Code viewer's Slot command (#282): Slot adds an issue
4
+ that is typically NOT already in the track, so the per-track export can't supply
5
+ the candidate list. This fetches the repo's open issues live via `gh` and emits
6
+ them in the same `Issue` shape the export uses, so the viewer can offer a
7
+ pick-list instead of a free-typed number.
8
+
9
+ Read-only. Never writes anything. The viewer passes the track's current issue
10
+ numbers via --exclude so already-slotted issues are filtered out here.
11
+ """
12
+ import json
13
+
14
+ from lib.github_state import fetch_open_issues
15
+ from lib.export_model import normalize_issue
16
+ from lib.prompts import parse_flags
17
+
18
+
19
+ def run(args: list[str]) -> int:
20
+ flags, _ = parse_flags(args, {"--repo", "--exclude"})
21
+
22
+ repo = flags.get("--repo")
23
+ if not repo or repo is True:
24
+ print(json.dumps({"error": "list-open-issues requires --repo=<owner/name>"}))
25
+ return 2
26
+
27
+ exclude = _parse_exclude(flags.get("--exclude"))
28
+
29
+ # fetch_open_issues validates the slug and returns [] on any error/bad repo,
30
+ # so a malformed --repo yields an empty list rather than raising.
31
+ rows = fetch_open_issues(repo)
32
+ issues = [
33
+ normalize_issue(r) for r in rows
34
+ if r.get("number") not in exclude
35
+ ]
36
+ print(json.dumps({"repo": repo, "issues": issues}, indent=2))
37
+ return 0
38
+
39
+
40
+ def _parse_exclude(raw) -> set:
41
+ """Parse the --exclude CSV (e.g. "87,91,103") into a set of ints.
42
+
43
+ Tolerates blanks and non-numeric tokens (skipped), so a stray trailing
44
+ comma or empty value never errors. `True` (bare --exclude) → empty set."""
45
+ if not raw or raw is True:
46
+ return set()
47
+ out = set()
48
+ for tok in str(raw).split(","):
49
+ tok = tok.strip()
50
+ if tok.isdigit():
51
+ out.add(int(tok))
52
+ return out
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from typing import Optional
18
18
 
19
19
  from lib.config import load_config, ConfigError, is_valid_git_repo
20
+ from lib.plan_worktree import shared_tier_dir
20
21
  from lib.frontmatter import write_file
21
22
  from lib.prompts import parse_flags
22
23
  from lib.write_guard import needs_confirm, make_token, valid_token
@@ -150,11 +151,16 @@ def run(args: list[str]) -> int:
150
151
 
151
152
  shared_path: Optional[Path] = None
152
153
  if not use_private and folder in cfg.get("repos", {}):
153
- local_raw = cfg["repos"][folder].get("local")
154
+ repo_entry = cfg["repos"][folder]
155
+ local_raw = repo_entry.get("local")
154
156
  if local_raw:
155
157
  local_path = Path(local_raw).expanduser()
156
158
  if is_valid_git_repo(local_path):
157
- shared_path = local_path / ".work-plan" / f"{slug}.md"
159
+ # Worktree-aware (#260): plan_branch repos write into the
160
+ # worktree's .work-plan/; None → fall back to the private tier.
161
+ sd = shared_tier_dir(repo_entry)
162
+ if sd is not None:
163
+ shared_path = sd / f"{slug}.md"
158
164
 
159
165
  notes_root = Path(cfg["notes_root"]).expanduser()
160
166
  if shared_path is not None: