@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
@@ -0,0 +1,314 @@
1
+ """plan-branch subcommand — bootstrap + share a repo's canonical plan branch (#260).
2
+
3
+ The shared (`.work-plan/`) tier is pinned to ONE per-repo `plan_branch`, read and
4
+ written through a dedicated git worktree (Phases 1+2). This command sets that up
5
+ and shares it:
6
+
7
+ init <repo> Create the plan branch + `.work-plan/` skeleton for <repo>, or
8
+ connect to a teammate's already-published one, and record
9
+ `plan_branch` in config. LOCAL ONLY — no network push. Default
10
+ branch is an ORPHAN `work-plan/plan` (zero shared history with
11
+ code, like gh-pages); override with --branch=<name>.
12
+ status <repo> Report the configured plan_branch: does it exist, is it
13
+ published to origin, how many local commits are unpushed.
14
+ Add --json for the machine shape.
15
+ push <repo> Push the plan branch to origin to share it. This is the exposure
16
+ point: on a PUBLIC repo it prints a confirm heads-up + token and
17
+ exits; re-run with --confirm=<token>. --dry-run previews the
18
+ commits that would be pushed without pushing.
19
+
20
+ Usage:
21
+ plan-branch <init|status|push> <repo> [--branch=<name>] [--confirm=<token>]
22
+ [--dry-run] [--json]
23
+ """
24
+ import json
25
+ import os
26
+ import re
27
+ import subprocess
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ from lib.config import (
32
+ load_config, ConfigError, DEFAULT_CONFIG_PATH, is_valid_git_repo,
33
+ )
34
+ from lib.git_state import is_safe_ref
35
+ from lib.notes_readme import seed_readme
36
+ from lib.prompts import parse_flags
37
+ from lib.write_guard import needs_confirm, make_token, valid_token
38
+ from lib import plan_worktree as pw
39
+
40
+ _ACTIONS = ("init", "status", "push")
41
+ _DEFAULT_BRANCH = "work-plan/plan"
42
+ # A git refname segment: starts alnum, then alnum / . _ - and / separators. We
43
+ # additionally reject `..`, leading/trailing `/`, and `//` below.
44
+ _BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
45
+ _KEY_RE = re.compile(r"^[A-Za-z0-9._-]+$") # safe for the yq config path
46
+
47
+
48
+ def _valid_branch(name: str) -> bool:
49
+ return (
50
+ is_safe_ref(name)
51
+ and bool(_BRANCH_RE.fullmatch(name))
52
+ and ".." not in name
53
+ and "//" not in name
54
+ and not name.startswith("/")
55
+ and not name.endswith("/")
56
+ and not name.endswith(".lock")
57
+ )
58
+
59
+
60
+ def _set_plan_branch(key: str, branch: str) -> bool:
61
+ """Persist .repos.<key>.plan_branch=<branch> into config via yq. The branch
62
+ travels as an opaque env value (strenv), never interpolated. `key` is the
63
+ config repo key (validated by the caller). Returns True on success."""
64
+ env = {**os.environ, "WP_PLAN_BRANCH": branch}
65
+ expr = f".repos.{key}.plan_branch = strenv(WP_PLAN_BRANCH)"
66
+ try:
67
+ subprocess.run(
68
+ ["yq", "-i", expr, str(DEFAULT_CONFIG_PATH)],
69
+ check=True, capture_output=True, text=True, env=env, timeout=20,
70
+ )
71
+ return True
72
+ except subprocess.CalledProcessError as e:
73
+ print(f"ERROR: yq failed to update config: {e.stderr}")
74
+ return False
75
+ except (OSError, subprocess.TimeoutExpired) as e:
76
+ # yq missing / hung — degrade cleanly rather than crash with a traceback.
77
+ print(f"ERROR: could not run yq to update config: {e}")
78
+ return False
79
+
80
+
81
+ def _resolve_repo(cfg: dict, repo_arg: Optional[str]):
82
+ """Return (key, entry, github, local_path) for a CONFIGURED repo, or print an
83
+ error and return None. plan-branch writes into a repo's config entry, so the
84
+ repo must be registered (init-repo) and have a local clone path."""
85
+ repos = cfg.get("repos") or {}
86
+ if repo_arg is None:
87
+ if len(repos) == 1:
88
+ key = next(iter(repos))
89
+ else:
90
+ print("ERROR: specify which repo — e.g. `plan-branch init <key>`. "
91
+ f"Configured: {', '.join(repos) or '(none)'}.")
92
+ return None
93
+ elif repo_arg in repos:
94
+ key = repo_arg
95
+ else:
96
+ print(f"ERROR: '{repo_arg}' is not a configured repo. Register it first "
97
+ "with `init-repo <key> --github=org/repo --local=<path>`.")
98
+ return None
99
+
100
+ if not _KEY_RE.fullmatch(key):
101
+ print(f"ERROR: repo key '{key}' has unexpected characters; refusing to "
102
+ "edit config for it.")
103
+ return None
104
+ entry = repos[key] or {}
105
+ github = entry.get("github")
106
+ local_raw = entry.get("local")
107
+ if not local_raw:
108
+ print(f"ERROR: repo '{key}' has no local clone path in config. Add one "
109
+ f"with `init-repo {key} --github={github or 'org/repo'} --local=<path>`.")
110
+ return None
111
+ local_path = Path(local_raw).expanduser()
112
+ if not is_valid_git_repo(local_path):
113
+ print(f"ERROR: {local_path} is not a git repository.")
114
+ return None
115
+ return key, entry, github, local_path
116
+
117
+
118
+ def _do_init(cfg, key, entry, github, local_path, flags) -> int:
119
+ raw = flags.get("--branch")
120
+ branch = raw if isinstance(raw, str) and raw else _DEFAULT_BRANCH
121
+ if not _valid_branch(branch):
122
+ print(f"ERROR: '{branch}' is not a valid branch name.")
123
+ return 2
124
+
125
+ existing = entry.get("plan_branch")
126
+ if existing and existing != branch:
127
+ print(f"ERROR: repo '{key}' already has plan_branch '{existing}'. "
128
+ "Refusing to silently switch it — edit config to change.")
129
+ return 1
130
+
131
+ # Fetch so the connect-vs-create decision sees a teammate's published branch.
132
+ pw.fetch_branch(local_path, branch)
133
+
134
+ if pw._branch_exists(local_path, branch):
135
+ # Connect: a branch already exists (local or origin) — reuse it.
136
+ wt = pw.ensure_worktree(local_path, branch)
137
+ if wt is None:
138
+ print(f"ERROR: branch '{branch}' exists but its worktree could not be "
139
+ "created. Resolve any conflicting worktree and retry.")
140
+ return 1
141
+ # The branch already carries its own .work-plan/ — connecting just wires
142
+ # it up; no seeding (and no write into a possibly-absent dir).
143
+ print(f"✓ Connected repo '{key}' to existing plan branch '{branch}'.")
144
+ published = pw.is_published(local_path, branch)
145
+ print(f" Source: {'origin (a teammate published it)' if published else 'local'}.")
146
+ else:
147
+ # Create a fresh orphan branch with only .work-plan/.
148
+ dest = pw.create_orphan_worktree(local_path, branch)
149
+ if dest is None:
150
+ print(f"ERROR: could not create the plan worktree for '{branch}'. "
151
+ "Is there a stale worktree at the cache path, or no commits in "
152
+ "the repo yet?")
153
+ return 1
154
+ seed_readme(dest / ".work-plan")
155
+ paths = pw.dirty_work_plan_paths(dest)
156
+ sha = pw.commit_shared_tier(
157
+ dest, f"work-plan: initialize plan branch {branch}", paths)
158
+ if sha is None:
159
+ print(f"ERROR: created the worktree but the initial commit failed.")
160
+ return 1
161
+ print(f"✓ Created orphan branch '{branch}' for '{key}' ({sha}, local only).")
162
+ print(" It holds only plan data — no shared history with your code, so "
163
+ "it won't appear in pull requests or deploys.")
164
+
165
+ if not _set_plan_branch(key, branch):
166
+ return 1
167
+ print(f"✓ Recorded plan_branch '{branch}' in config for '{key}'.")
168
+ print()
169
+ print("Next:")
170
+ print(f" • Add a shared track: /work-plan new-track {key} <slug>")
171
+ print(f" • Share the branch: /work-plan plan-branch push {key}")
172
+ if github and needs_confirm(github, cfg):
173
+ print(f" ⚠ {github} is public — `push` will make the plan visible to anyone.")
174
+ return 0
175
+
176
+
177
+ def _do_status(cfg, key, entry, github, local_path, flags) -> int:
178
+ branch = entry.get("plan_branch")
179
+ want_json = "--json" in flags
180
+ if not branch:
181
+ if want_json:
182
+ print(json.dumps({"repo": key, "plan_branch": None,
183
+ "configured": False}))
184
+ else:
185
+ print(f"repo '{key}': no plan_branch configured.")
186
+ print(f" Run `plan-branch init {key}` to set one up.")
187
+ return 0
188
+
189
+ pw.fetch_branch(local_path, branch) # best-effort: accurate published/unpushed
190
+ local_exists = pw.local_branch_exists(local_path, branch)
191
+ published = pw.is_published(local_path, branch)
192
+ unpushed = pw.unpushed_oneline(local_path, branch)
193
+
194
+ if want_json:
195
+ print(json.dumps({
196
+ "repo": key, "plan_branch": branch, "configured": True,
197
+ "local_exists": local_exists, "published": published,
198
+ "unpushed_count": len(unpushed),
199
+ }))
200
+ return 0
201
+
202
+ print(f"repo '{key}': plan_branch '{branch}'")
203
+ print(f" local branch: {'✓ present' if local_exists else '✗ missing (run init)'}")
204
+ print(f" published: {'✓ on origin' if published else '✗ local only — not shared yet'}")
205
+ if unpushed:
206
+ print(f" unpushed: {len(unpushed)} commit(s) — run "
207
+ f"`plan-branch push {key}` to share:")
208
+ for line in unpushed[:10]:
209
+ print(f" {line}")
210
+ if len(unpushed) > 10:
211
+ print(f" … and {len(unpushed) - 10} more")
212
+ else:
213
+ print(" unpushed: none — origin is up to date.")
214
+ return 0
215
+
216
+
217
+ def _do_push(cfg, key, entry, github, local_path, flags) -> int:
218
+ branch = entry.get("plan_branch")
219
+ if not branch:
220
+ print(f"ERROR: repo '{key}' has no plan_branch. Run `plan-branch init "
221
+ f"{key}` first.")
222
+ return 1
223
+ if not pw.local_branch_exists(local_path, branch):
224
+ print(f"ERROR: plan branch '{branch}' doesn't exist locally. Run "
225
+ f"`plan-branch init {key}` first.")
226
+ return 1
227
+
228
+ pw.fetch_branch(local_path, branch)
229
+ commits = pw.unpushed_oneline(local_path, branch)
230
+
231
+ if "--dry-run" in flags:
232
+ if not commits:
233
+ print(f"Nothing to push — origin/{branch} is up to date.")
234
+ return 0
235
+ print(f"Would push {len(commits)} commit(s) to origin/{branch}:")
236
+ for line in commits:
237
+ print(f" {line}")
238
+ return 0
239
+
240
+ if not commits:
241
+ print(f"Nothing to push — origin/{branch} is up to date.")
242
+ return 0
243
+
244
+ # Exposure gate: publishing planning notes to a PUBLIC repo is a meaningful,
245
+ # effectively-permanent disclosure. Same confirm-token flow as other public
246
+ # writes, with concrete wording about what becomes visible. No `github and`
247
+ # short-circuit — needs_confirm() fails CLOSED on empty/unknown visibility,
248
+ # and that fail-closed behaviour must NOT be defeated (a config entry with a
249
+ # null/empty github would otherwise push unguarded).
250
+ if needs_confirm(github, cfg):
251
+ confirm = flags.get("--confirm")
252
+ if not (isinstance(confirm, str) and valid_token(confirm, github, branch)):
253
+ print(json.dumps({
254
+ "needs_confirm": True,
255
+ "reason": (
256
+ f"{github} is PUBLIC (or its visibility is unknown). Pushing "
257
+ f"'{branch}' makes your plan files — issue notes, priorities, "
258
+ "and planning text — visible to anyone on the internet, and "
259
+ "they remain in public git history even if the branch is "
260
+ "later deleted."
261
+ ),
262
+ "token": make_token(github, branch),
263
+ }))
264
+ return 0
265
+
266
+ proc = pw.push_plan_branch(local_path, branch)
267
+ if proc is None:
268
+ print("ERROR: could not run git to push.")
269
+ return 1
270
+ if proc.returncode != 0:
271
+ err = (proc.stderr or "").strip()
272
+ if "protected" in err.lower() or "pull request" in err.lower():
273
+ print(f"ERROR: origin rejected the push — '{branch}' looks protected. "
274
+ f"Exempt '{branch.split('/')[0]}/**' from PR/branch-protection "
275
+ "rules for the plan branch, or push it manually once.")
276
+ else:
277
+ print(f"ERROR: push failed: {err or 'unknown git error'}")
278
+ return 1
279
+ print(f"✓ Pushed '{branch}' to origin ({len(commits)} commit(s)). "
280
+ "Teammates can `plan-branch init` to connect.")
281
+ return 0
282
+
283
+
284
+ def run(args: list[str]) -> int:
285
+ flags, positional = parse_flags(
286
+ args, {"--branch", "--confirm", "--dry-run", "--json"})
287
+
288
+ action = positional[0] if positional else None
289
+ if action not in _ACTIONS:
290
+ print(f"usage: work_plan.py plan-branch <{'|'.join(_ACTIONS)}> <repo> "
291
+ "[--branch=<name>] [--confirm=<token>] [--dry-run] [--json]")
292
+ return 2
293
+
294
+ repo_arg = positional[1] if len(positional) > 1 else None
295
+
296
+ try:
297
+ cfg = load_config()
298
+ except (ConfigError, subprocess.CalledProcessError, OSError) as e:
299
+ # ConfigError is the expected case; CalledProcessError / OSError cover a
300
+ # malformed config or a missing `yq` so the command degrades to a clean
301
+ # error instead of a traceback (never-raise contract).
302
+ print(f"ERROR: could not load config: {e}")
303
+ return 1
304
+
305
+ resolved = _resolve_repo(cfg, repo_arg)
306
+ if resolved is None:
307
+ return 1
308
+ key, entry, github, local_path = resolved
309
+
310
+ if action == "init":
311
+ return _do_init(cfg, key, entry, github, local_path, flags)
312
+ if action == "status":
313
+ return _do_status(cfg, key, entry, github, local_path, flags)
314
+ return _do_push(cfg, key, entry, github, local_path, flags)
@@ -19,7 +19,7 @@ from lib.scratch import cache_dir
19
19
  from lib.prompts import parse_flags, prompt_yes_no
20
20
 
21
21
  KNOWN = {"--repo", "--json", "--since-days", "--type", "--stamp", "--draft",
22
- "--llm", "--apply", "--archive", "--issues"}
22
+ "--llm", "--apply", "--archive", "--issues", "--stall-days"}
23
23
  _ORDER = ["shipped", "partial", "dead", "foreign", "manifest-less"]
24
24
 
25
25
 
@@ -35,8 +35,54 @@ def _resolve_repo_root(flags) -> Path:
35
35
  return Path.cwd()
36
36
 
37
37
 
38
- def _evaluate(doc, repo_root, today, dead_days) -> dict:
39
- text = doc.path.read_text(encoding="utf-8", errors="replace")
38
+ def _resolve_stall_days(flags) -> int:
39
+ """Precedence for the staleness threshold (#164):
40
+ --stall-days flag (int) -> config.yml `stall_days` (int) -> default 14.
41
+ A non-integer flag value falls through to the config/default tier.
42
+ """
43
+ raw = flags.get("--stall-days")
44
+ if raw not in (None, True):
45
+ try:
46
+ return int(raw)
47
+ except (TypeError, ValueError):
48
+ pass
49
+ cfg_val = config_mod.load_config().get("stall_days")
50
+ if isinstance(cfg_val, int):
51
+ return cfg_val
52
+ return verdict_mod.STALL_DAYS
53
+
54
+
55
+ def _read(path) -> str:
56
+ """Read a doc's text. Indirected so tests can patch it without a real file."""
57
+ return path.read_text(encoding="utf-8", errors="replace")
58
+
59
+
60
+ def _declared_paths_on_disk(decls, repo_root) -> list:
61
+ """Repo-relative declared paths that point at a real FILE inside repo_root.
62
+
63
+ Both guards matter for the staleness clock: a junk declared path like `/`
64
+ resolves (via `repo_root / "/"`) to the filesystem root — a directory that
65
+ exists — and an out-of-tree `../x` path can exist too. Either one passed to
66
+ `git log -- <paths…>` poisons the WHOLE call (it returns nothing), which
67
+ would falsely mark an actively-built plan as stalled. So require an actual
68
+ file (`is_file`) that resolves under repo_root."""
69
+ root = Path(repo_root).resolve()
70
+ out = []
71
+ for d in decls:
72
+ p = Path(repo_root) / d.path
73
+ try:
74
+ if not p.is_file():
75
+ continue
76
+ resolved = p.resolve()
77
+ except OSError:
78
+ continue
79
+ if resolved == root or root in resolved.parents:
80
+ out.append(d.path)
81
+ return out
82
+
83
+
84
+ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
85
+ text = _read(doc.path)
40
86
  decls = manifest.parse_declared_paths(text)
41
87
  pdate = manifest.plan_date_from_filename(doc.path.name)
42
88
  score = manifest.score_manifest(decls, repo_root, pdate)
@@ -48,12 +94,36 @@ def _evaluate(doc, repo_root, today, dead_days) -> dict:
48
94
  "foreign", "🧳", "declared paths point outside this repo — misfiled?")
49
95
  else:
50
96
  v = verdict_mod.classify(score, done, total_chk, last_d, today, dead_days)
97
+
98
+ # Staleness clock (#164): a partial plan whose declared manifest files have
99
+ # gone cold = "started executing, then drifted off." Key off the manifest
100
+ # files' commit date, NOT the plan doc's git date — plan docs are gitignored,
101
+ # so the doc date is null and would make this a silent no-op.
102
+ manifest_dt = None
103
+ stalled = False
104
+ if v.label == "partial":
105
+ on_disk = _declared_paths_on_disk(decls, repo_root)
106
+ if on_disk:
107
+ manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
108
+ if manifest_dt is None:
109
+ stalled = True # present on disk but never committed
110
+ else:
111
+ stalled = (today - manifest_dt.date()).days >= stall_days
112
+ # else: no declared files on disk yet -> brand-new, not stalled.
113
+
114
+ lie_gap = (v.label == "shipped" and total_chk > 0
115
+ and done / total_chk < 0.25)
51
116
  return {
52
117
  "rel": doc.rel, "kind": doc.kind,
53
118
  "verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
54
119
  "files_present": score.satisfied, "files_declared": score.total,
55
120
  "checkboxes_done": done, "checkboxes_total": total_chk,
56
121
  "last_touched": last_d.isoformat() if last_d else None,
122
+ "manifest_last_touched": manifest_dt.date().isoformat() if manifest_dt else None,
123
+ "stalled": stalled,
124
+ "lie_gap": lie_gap,
125
+ "unchecked_items": manifest.unchecked_checkbox_labels(text),
126
+ "stall_days": stall_days,
57
127
  }
58
128
 
59
129
 
@@ -62,11 +132,7 @@ def _render(rows, repo_root) -> None:
62
132
  by = {}
63
133
  for r in rows:
64
134
  by.setdefault(r["verdict"], []).append(r)
65
- lie_gap = sum(
66
- 1 for r in rows
67
- if r["verdict"] == "shipped" and r["checkboxes_total"]
68
- and r["checkboxes_done"] / r["checkboxes_total"] < 0.25
69
- )
135
+ lie_gap = sum(1 for r in rows if r["lie_gap"])
70
136
  summary = " · ".join(f"{len(by[k])} {k}" for k in _ORDER if by.get(k))
71
137
  print(f"{len(rows)} docs · {summary}")
72
138
  print(f"lie-gap (shipped but <25% boxes checked): {lie_gap}\n")
@@ -271,7 +337,8 @@ def run(args: list) -> int:
271
337
  if type_filter and type_filter is not True:
272
338
  docs = [d for d in docs if d.kind == type_filter]
273
339
 
274
- rows = [_evaluate(d, repo_root, today, dead_days) for d in docs]
340
+ stall_days = _resolve_stall_days(flags)
341
+ rows = [_evaluate(d, repo_root, today, dead_days, stall_days) for d in docs]
275
342
 
276
343
  if flags.get("--llm"):
277
344
  if flags.get("--apply"):
@@ -41,6 +41,20 @@ from lib.write_guard import needs_confirm
41
41
  PER_TRACK_TIMEOUT = 15 # seconds; each gh call gets this budget
42
42
 
43
43
 
44
+ def _track_key(track) -> tuple:
45
+ """Stable, unique identity for a track across a reconcile run.
46
+
47
+ Track slugs are NOT unique — the same slug can name a track in two different
48
+ repos (this is explicitly supported). Keying reconcile's in-flight state by
49
+ slug let a later repo's fetch overwrite an earlier same-slug track's, so
50
+ under `--all --yes` issues from one repo could be written into the
51
+ same-named track in ANOTHER repo — cross-repo membership corruption (#255).
52
+ The (repo, path) pair is unique per track file and stable for the whole run.
53
+ Display still uses `track.name`; only dict keys use this.
54
+ """
55
+ return (track.repo, str(track.path))
56
+
57
+
44
58
  def _resolve_labels(track) -> list[str]:
45
59
  """Return the GitHub label(s) marking issues as belonging to this track.
46
60
 
@@ -143,7 +157,7 @@ def run(args: list[str]) -> int:
143
157
 
144
158
  # Phase 1: parallel fetch of labeled issues for all tracks
145
159
  work_items = [(track, _resolve_labels(track)) for track in targets if track.repo]
146
- results: dict = {} # track.name → list[dict] or None (timeout/error)
160
+ results: dict = {} # _track_key(track) → list[dict] or None (timeout/error)
147
161
 
148
162
  total = len(work_items)
149
163
  if total > 1:
@@ -156,21 +170,21 @@ def run(args: list[str]) -> int:
156
170
  # Iterate in submit order for readable output; futures run in parallel
157
171
  for i, track, future in submitted:
158
172
  try:
159
- results[track.name] = future.result()
160
- print(f" [{i}/{total}] ✓ {track.name}")
173
+ results[_track_key(track)] = future.result()
174
+ print(f" [{i}/{total}] ✓ {track.name} ({track.repo})")
161
175
  except RuntimeError as e:
162
- print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
163
- results[track.name] = None
176
+ print(f" [{i}/{total}] ⚠ {track.name} ({track.repo}): {e} — skipping")
177
+ results[_track_key(track)] = None
164
178
  else:
165
179
  # Single track: fetch directly (no thread overhead)
166
180
  for i, (track, labels) in enumerate(work_items, 1):
167
181
  print(f" [{i}/{total}] fetching {track.repo} ({track.name})...", flush=True)
168
182
  try:
169
- results[track.name] = _fetch_labeled_issues(track.repo, labels)
170
- print(f" [{i}/{total}] ✓ {track.name}")
183
+ results[_track_key(track)] = _fetch_labeled_issues(track.repo, labels)
184
+ print(f" [{i}/{total}] ✓ {track.name} ({track.repo})")
171
185
  except RuntimeError as e:
172
- print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
173
- results[track.name] = None
186
+ print(f" [{i}/{total}] ⚠ {track.name} ({track.repo}): {e} — skipping")
187
+ results[_track_key(track)] = None
174
188
 
175
189
  # Phase 2a: index which fetched track(s) label each issue. Used to turn a
176
190
  # bare FLAG (in a track's frontmatter, but it has lost that track's label)
@@ -178,45 +192,46 @@ def run(args: list[str]) -> int:
178
192
  # track in the same repo.
179
193
  labeled_index: dict = {} # issue number -> list[track]
180
194
  for track in targets:
181
- if not track.repo or results.get(track.name) is None:
195
+ if not track.repo or results.get(_track_key(track)) is None:
182
196
  continue
183
- for num in {i["number"] for i in results[track.name]}:
197
+ for num in {i["number"] for i in results[_track_key(track)]}:
184
198
  labeled_index.setdefault(num, []).append(track)
185
199
 
186
200
  # Phase 2b: detect cross-track moves (#163). An issue qualifies when it is
187
201
  # in track A's frontmatter, no longer carries A's label, and is now labeled
188
202
  # by exactly one OTHER active track B in the same repo. Ambiguous cases
189
203
  # (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
204
+ moved_out: dict = {} # _track_key(src) -> set(num)
205
+ moved_in: dict = {} # _track_key(dst) -> set(num)
206
+ move_dst: dict = {} # (_track_key(src), num) -> dst track
193
207
  for track in targets:
194
- if not track.repo or results.get(track.name) is None:
208
+ if not track.repo or results.get(_track_key(track)) is None:
195
209
  continue
196
- labeled_nums = {i["number"] for i in results[track.name]}
210
+ labeled_nums = {i["number"] for i in results[_track_key(track)]}
197
211
  listed_nums = set(track.meta.get("github", {}).get("issues") or [])
198
212
  for num in sorted(listed_nums - labeled_nums):
199
213
  cands = [b for b in labeled_index.get(num, [])
200
214
  if b is not track and b.repo == track.repo]
201
215
  if len(cands) == 1:
202
216
  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
217
+ moved_out.setdefault(_track_key(track), set()).add(num)
218
+ moved_in.setdefault(_track_key(dst), set()).add(num)
219
+ move_dst[(_track_key(track), num)] = dst
206
220
 
207
221
  # Phase 2c: per-track diff, report, confirm. Membership changes accumulate
208
- # in `final` (track name -> desired issue set); each affected track is
222
+ # in `final` (_track_key -> desired issue set); each affected track is
209
223
  # written exactly ONCE at the end, so a move that touches two tracks never
210
224
  # double-writes or clobbers a sibling's accepted ADDs. A move is governed by
211
225
  # 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)
226
+ final: dict = {} # _track_key(track) -> set(num)
227
+ affected: dict = {} # _track_key(track) -> track (only those we may write)
214
228
 
215
229
  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]
230
+ key = _track_key(t)
231
+ if key not in final:
232
+ final[key] = set(t.meta.get("github", {}).get("issues") or [])
233
+ affected[key] = t
234
+ return final[key]
220
235
 
221
236
  any_changes = False
222
237
  for track in targets:
@@ -224,19 +239,19 @@ def run(args: list[str]) -> int:
224
239
  if not track.repo:
225
240
  continue
226
241
 
227
- labeled = results.get(track.name)
242
+ labeled = results.get(_track_key(track))
228
243
  if labeled is None:
229
244
  continue
230
245
 
231
246
  labels = _resolve_labels(track)
232
247
  labeled_nums = {i["number"] for i in labeled}
233
248
  listed_nums = set(track.meta.get("github", {}).get("issues") or [])
234
- out_moves = sorted(moved_out.get(track.name, set()))
249
+ out_moves = sorted(moved_out.get(_track_key(track), set()))
235
250
 
236
251
  # MOVE issues are reported (and applied) as moves, not as ADD on the
237
252
  # 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()))
253
+ adds = sorted(labeled_nums - listed_nums - moved_in.get(_track_key(track), set()))
254
+ flag_nums = sorted(listed_nums - labeled_nums - moved_out.get(_track_key(track), set()))
240
255
 
241
256
  if not adds and not flag_nums and not out_moves:
242
257
  continue
@@ -253,7 +268,7 @@ def run(args: list[str]) -> int:
253
268
  if out_moves:
254
269
  print(f" MOVE ({len(out_moves)}) — relabeled to another track in this repo:")
255
270
  for num in out_moves:
256
- dst = move_dst[(track.name, num)]
271
+ dst = move_dst[(_track_key(track), num)]
257
272
  dst_slug = dst.meta.get("track", dst.name)
258
273
  pub = " [dst PUBLIC]" if needs_confirm(dst.repo, cfg) else ""
259
274
  print(f" #{num} {slug} → {dst_slug}{pub}")
@@ -286,7 +301,7 @@ def run(args: list[str]) -> int:
286
301
  if adds:
287
302
  _final_for(track).update(adds)
288
303
  for num in out_moves:
289
- dst = move_dst[(track.name, num)]
304
+ dst = move_dst[(_track_key(track), num)]
290
305
  # Public-repo guard (#163): under --yes we never silently write
291
306
  # membership into a PUBLIC/shared destination track — that move is
292
307
  # skipped with a pointer to the gated `move` verb. Interactive runs
@@ -300,8 +315,8 @@ def run(args: list[str]) -> int:
300
315
  _final_for(dst).add(num)
301
316
 
302
317
  # Write each affected track exactly once, only if its set actually changed.
303
- for name, issues in final.items():
304
- track = affected[name]
318
+ for key, issues in final.items():
319
+ track = affected[key]
305
320
  original = set(track.meta.get("github", {}).get("issues") or [])
306
321
  if issues == original:
307
322
  continue