@stylusnexus/work-plan 2026.6.11 → 2026.6.13-2

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 (51) hide show
  1. package/README.md +38 -6
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +3 -0
  5. package/skills/work-plan/commands/auth_status.py +35 -0
  6. package/skills/work-plan/commands/close_issue.py +82 -0
  7. package/skills/work-plan/commands/export.py +72 -3
  8. package/skills/work-plan/commands/group.py +5 -1
  9. package/skills/work-plan/commands/init_repo.py +84 -14
  10. package/skills/work-plan/commands/list_open_issues.py +52 -0
  11. package/skills/work-plan/commands/new_track.py +8 -2
  12. package/skills/work-plan/commands/plan_ack.py +71 -0
  13. package/skills/work-plan/commands/plan_baseline.py +85 -0
  14. package/skills/work-plan/commands/plan_branch.py +314 -0
  15. package/skills/work-plan/commands/plan_confirm.py +83 -0
  16. package/skills/work-plan/commands/plan_status.py +140 -9
  17. package/skills/work-plan/commands/push_track.py +156 -0
  18. package/skills/work-plan/commands/reconcile.py +49 -34
  19. package/skills/work-plan/commands/refresh_md.py +49 -1
  20. package/skills/work-plan/commands/remove_repo.py +69 -0
  21. package/skills/work-plan/commands/set_field.py +22 -3
  22. package/skills/work-plan/lib/export_model.py +27 -4
  23. package/skills/work-plan/lib/git_state.py +22 -0
  24. package/skills/work-plan/lib/github_state.py +63 -0
  25. package/skills/work-plan/lib/manifest.py +28 -0
  26. package/skills/work-plan/lib/plan_fm.py +71 -0
  27. package/skills/work-plan/lib/plan_worktree.py +288 -0
  28. package/skills/work-plan/lib/status_header.py +6 -2
  29. package/skills/work-plan/lib/tracks.py +6 -2
  30. package/skills/work-plan/lib/verdict.py +1 -0
  31. package/skills/work-plan/tests/test_auth_status.py +98 -0
  32. package/skills/work-plan/tests/test_close_issue.py +121 -0
  33. package/skills/work-plan/tests/test_export.py +65 -0
  34. package/skills/work-plan/tests/test_export_command.py +95 -0
  35. package/skills/work-plan/tests/test_init_repo.py +100 -1
  36. package/skills/work-plan/tests/test_list_open_issues.py +83 -0
  37. package/skills/work-plan/tests/test_manifest.py +30 -1
  38. package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
  39. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  40. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  41. package/skills/work-plan/tests/test_plan_branch.py +279 -0
  42. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  43. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  44. package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
  45. package/skills/work-plan/tests/test_plan_worktree.py +378 -0
  46. package/skills/work-plan/tests/test_push_track.py +131 -0
  47. package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
  48. package/skills/work-plan/tests/test_refresh_md.py +75 -0
  49. package/skills/work-plan/tests/test_remove_repo.py +77 -0
  50. package/skills/work-plan/tests/test_set_field.py +60 -0
  51. package/skills/work-plan/work_plan.py +125 -6
@@ -11,6 +11,7 @@ from pathlib import Path
11
11
 
12
12
  from lib import config as config_mod
13
13
  from lib import doc_discovery, manifest, git_state, github_state
14
+ from lib import frontmatter
14
15
  from lib import verdict as verdict_mod
15
16
  from lib import status_header
16
17
  from lib import llm_evidence
@@ -19,9 +20,45 @@ from lib.scratch import cache_dir
19
20
  from lib.prompts import parse_flags, prompt_yes_no
20
21
 
21
22
  KNOWN = {"--repo", "--json", "--since-days", "--type", "--stamp", "--draft",
22
- "--llm", "--apply", "--archive", "--issues"}
23
+ "--llm", "--apply", "--archive", "--issues", "--stall-days"}
23
24
  _ORDER = ["shipped", "partial", "dead", "foreign", "manifest-less"]
24
25
 
26
+ # Human verdict-override (#286): a reviewer affirms the verdict in the doc's
27
+ # YAML frontmatter, so the mechanical heuristic (file score + checkbox %) stops
28
+ # second-guessing it — and the "shipped but boxes unchecked" lie-gap goes quiet.
29
+ _OVERRIDE_VERDICTS = {"shipped", "partial", "dead"}
30
+ _OVERRIDE_GLYPH = {"shipped": "✅", "partial": "🟡", "dead": "💀"}
31
+
32
+
33
+ def _read_fm_signals(path) -> tuple:
34
+ """Read the frontmatter signals plan-status honors (#286), in ONE parse:
35
+
36
+ (override, acknowledged, baseline)
37
+
38
+ `override` is the `verdict_override` value (case-insensitive shipped|partial|
39
+ dead, else None — a typo can't pin a bogus verdict). `acknowledged` is True
40
+ when the doc carries a truthy `acknowledged` (the durable ack plan-ack writes).
41
+ `baseline` is the `verdict_baseline` value (a verdict string, else None) that
42
+ plan-baseline stamps for drift detection. All frontmatter-only — never the
43
+ body/checkboxes. A doc with no frontmatter (most plans) parses to empty meta,
44
+ a clean (None, False, None) no-op."""
45
+ try:
46
+ meta, _ = frontmatter.parse_file(Path(path))
47
+ except Exception:
48
+ return (None, False, None)
49
+ if not isinstance(meta, dict):
50
+ return (None, False, None)
51
+ val = meta.get("verdict_override")
52
+ override = (val.strip().lower()
53
+ if isinstance(val, str) and val.strip().lower() in _OVERRIDE_VERDICTS
54
+ else None)
55
+ acknowledged = bool(meta.get("acknowledged"))
56
+ bval = meta.get("verdict_baseline")
57
+ baseline = (bval.strip().lower()
58
+ if isinstance(bval, str) and bval.strip().lower() in _OVERRIDE_VERDICTS
59
+ else None)
60
+ return (override, acknowledged, baseline)
61
+
25
62
 
26
63
  def _resolve_repo_root(flags) -> Path:
27
64
  repo = flags.get("--repo")
@@ -35,8 +72,54 @@ def _resolve_repo_root(flags) -> Path:
35
72
  return Path.cwd()
36
73
 
37
74
 
38
- def _evaluate(doc, repo_root, today, dead_days) -> dict:
39
- text = doc.path.read_text(encoding="utf-8", errors="replace")
75
+ def _resolve_stall_days(flags) -> int:
76
+ """Precedence for the staleness threshold (#164):
77
+ --stall-days flag (int) -> config.yml `stall_days` (int) -> default 14.
78
+ A non-integer flag value falls through to the config/default tier.
79
+ """
80
+ raw = flags.get("--stall-days")
81
+ if raw not in (None, True):
82
+ try:
83
+ return int(raw)
84
+ except (TypeError, ValueError):
85
+ pass
86
+ cfg_val = config_mod.load_config().get("stall_days")
87
+ if isinstance(cfg_val, int):
88
+ return cfg_val
89
+ return verdict_mod.STALL_DAYS
90
+
91
+
92
+ def _read(path) -> str:
93
+ """Read a doc's text. Indirected so tests can patch it without a real file."""
94
+ return path.read_text(encoding="utf-8", errors="replace")
95
+
96
+
97
+ def _declared_paths_on_disk(decls, repo_root) -> list:
98
+ """Repo-relative declared paths that point at a real FILE inside repo_root.
99
+
100
+ Both guards matter for the staleness clock: a junk declared path like `/`
101
+ resolves (via `repo_root / "/"`) to the filesystem root — a directory that
102
+ exists — and an out-of-tree `../x` path can exist too. Either one passed to
103
+ `git log -- <paths…>` poisons the WHOLE call (it returns nothing), which
104
+ would falsely mark an actively-built plan as stalled. So require an actual
105
+ file (`is_file`) that resolves under repo_root."""
106
+ root = Path(repo_root).resolve()
107
+ out = []
108
+ for d in decls:
109
+ p = Path(repo_root) / d.path
110
+ try:
111
+ if not p.is_file():
112
+ continue
113
+ resolved = p.resolve()
114
+ except OSError:
115
+ continue
116
+ if resolved == root or root in resolved.parents:
117
+ out.append(d.path)
118
+ return out
119
+
120
+
121
+ def _evaluate(doc, repo_root, today, dead_days, stall_days) -> dict:
122
+ text = _read(doc.path)
40
123
  decls = manifest.parse_declared_paths(text)
41
124
  pdate = manifest.plan_date_from_filename(doc.path.name)
42
125
  score = manifest.score_manifest(decls, repo_root, pdate)
@@ -48,25 +131,72 @@ def _evaluate(doc, repo_root, today, dead_days) -> dict:
48
131
  "foreign", "🧳", "declared paths point outside this repo — misfiled?")
49
132
  else:
50
133
  v = verdict_mod.classify(score, done, total_chk, last_d, today, dead_days)
134
+
135
+ # Frontmatter signals (#286): a `verdict_override` pins the verdict over the
136
+ # mechanical one (applied BEFORE the staleness clock + lie-gap so both key off
137
+ # the confirmed verdict), and `acknowledged` is the durable, shared ack.
138
+ override, acknowledged, baseline = _read_fm_signals(doc.path)
139
+ if override:
140
+ v = verdict_mod.Verdict(
141
+ override, _OVERRIDE_GLYPH[override], f"human-confirmed · {v.rationale}")
142
+
143
+ # Staleness clock (#164): a partial plan whose declared manifest files have
144
+ # gone cold = "started executing, then drifted off." Key off the manifest
145
+ # files' commit date, NOT the plan doc's git date — plan docs are gitignored,
146
+ # so the doc date is null and would make this a silent no-op.
147
+ manifest_dt = None
148
+ stalled = False
149
+ if v.label == "partial":
150
+ on_disk = _declared_paths_on_disk(decls, repo_root)
151
+ if on_disk:
152
+ manifest_dt = git_state.paths_last_commit_date(on_disk, repo_root)
153
+ if manifest_dt is None:
154
+ stalled = True # present on disk but never committed
155
+ else:
156
+ stalled = (today - manifest_dt.date()).days >= stall_days
157
+ # else: no declared files on disk yet -> brand-new, not stalled.
158
+
159
+ # A human-confirmed verdict silences the lie-gap: the reviewer has affirmed
160
+ # the "shipped" claim despite unchecked boxes, so it's no longer a lie.
161
+ lie_gap = (not override and v.label == "shipped" and total_chk > 0
162
+ and done / total_chk < 0.25)
163
+ # Drift (#286): a stamped `verdict_baseline` that no longer matches the live
164
+ # verdict — e.g. a once-shipped plan whose declared files were deleted. A
165
+ # human override owns the verdict, so it suppresses drift (same as lie-gap).
166
+ verdict_drift = bool(baseline) and not override and baseline != v.label
51
167
  return {
52
168
  "rel": doc.rel, "kind": doc.kind,
53
169
  "verdict": v.label, "glyph": v.glyph, "rationale": v.rationale,
54
170
  "files_present": score.satisfied, "files_declared": score.total,
55
171
  "checkboxes_done": done, "checkboxes_total": total_chk,
56
172
  "last_touched": last_d.isoformat() if last_d else None,
173
+ "manifest_last_touched": manifest_dt.date().isoformat() if manifest_dt else None,
174
+ "stalled": stalled,
175
+ "lie_gap": lie_gap,
176
+ "override": override,
177
+ "acknowledged": acknowledged,
178
+ "verdict_baseline": baseline,
179
+ "verdict_drift": verdict_drift,
180
+ "offtree_paths": manifest.offtree_declared_paths(decls, repo_root),
181
+ "unchecked_items": manifest.unchecked_checkbox_labels(text),
182
+ "stall_days": stall_days,
57
183
  }
58
184
 
59
185
 
186
+ # Public alias so other commands (e.g. `export`, which resolves a track's linked
187
+ # plan badge for #285) reuse the SAME verdict/lie-gap/override evaluation instead
188
+ # of reimplementing it and drifting. Kept at this module path so the existing
189
+ # tests that patch `commands.plan_status.git_state.*` and call `_evaluate`
190
+ # directly keep working unchanged.
191
+ evaluate_doc = _evaluate
192
+
193
+
60
194
  def _render(rows, repo_root) -> None:
61
195
  print(f"# plan-status — {repo_root}\n")
62
196
  by = {}
63
197
  for r in rows:
64
198
  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
- )
199
+ lie_gap = sum(1 for r in rows if r["lie_gap"])
70
200
  summary = " · ".join(f"{len(by[k])} {k}" for k in _ORDER if by.get(k))
71
201
  print(f"{len(rows)} docs · {summary}")
72
202
  print(f"lie-gap (shipped but <25% boxes checked): {lie_gap}\n")
@@ -271,7 +401,8 @@ def run(args: list) -> int:
271
401
  if type_filter and type_filter is not True:
272
402
  docs = [d for d in docs if d.kind == type_filter]
273
403
 
274
- rows = [_evaluate(d, repo_root, today, dead_days) for d in docs]
404
+ stall_days = _resolve_stall_days(flags)
405
+ rows = [_evaluate(d, repo_root, today, dead_days, stall_days) for d in docs]
275
406
 
276
407
  if flags.get("--llm"):
277
408
  if flags.get("--apply"):
@@ -0,0 +1,156 @@
1
+ """push-track — promote a PRIVATE track to a repo's SHARED tier and publish it
2
+ (#306).
3
+
4
+ Tracks default to the private tier (`notes_root`, local-only, never pushed). The
5
+ shared tier lives in a repo's `.work-plan/` on its canonical `plan_branch` (via a
6
+ git worktree) and is how a track becomes visible to teammates. This verb moves a
7
+ private track's `.md` into the shared `.work-plan/`, removes the private copy
8
+ (so the track isn't duplicated), commits it to the plan branch, and pushes —
9
+ unless `--no-push`.
10
+
11
+ The tier is derived from WHERE the file lives, so promotion is a file move; no
12
+ frontmatter edit. Pushing to a PUBLIC repo's plan branch makes the plan
13
+ world-visible — the exposed state the viewer's visibility×tier badge warns about
14
+ — so the push is confirm-token gated, like `plan-branch push`.
15
+
16
+ Usage:
17
+ work_plan.py push-track <track | track@repo> [--repo=<key>] [--no-push] [--confirm=<token>]
18
+ """
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from lib.config import load_config, ConfigError
24
+ from lib.tracks import (
25
+ discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError,
26
+ )
27
+ from lib.frontmatter import write_file
28
+ from lib import plan_worktree as pw
29
+ from lib.write_guard import needs_confirm, make_token, valid_token
30
+ from lib.prompts import parse_flags
31
+
32
+ KNOWN = {"--repo", "--no-push", "--confirm"}
33
+
34
+
35
+ def run(args: list) -> int:
36
+ flags, positional = parse_flags(args, KNOWN)
37
+ if not positional:
38
+ print("usage: work_plan.py push-track <track> [--repo=<key>] [--no-push] "
39
+ "[--confirm=<token>]", file=sys.stderr)
40
+ return 2
41
+ name_from_arg, repo_from_arg = parse_track_repo_arg(positional[0])
42
+ repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
43
+ repo_qualifier = repo_from_arg or repo_flag
44
+ no_push = bool(flags.get("--no-push"))
45
+
46
+ try:
47
+ cfg = load_config()
48
+ except ConfigError as e:
49
+ print(f"ERROR: {e}", file=sys.stderr)
50
+ return 1
51
+
52
+ try:
53
+ track = find_track_by_name(name_from_arg, discover_tracks(cfg), repo=repo_qualifier)
54
+ except AmbiguousTrackError as e:
55
+ print(str(e), file=sys.stderr)
56
+ return 1
57
+ if not track:
58
+ print(f"No track matching '{name_from_arg}'.", file=sys.stderr)
59
+ return 1
60
+ if track.tier == "shared":
61
+ print(f"'{track.name}' is already in the shared tier — nothing to promote.",
62
+ file=sys.stderr)
63
+ return 1
64
+ if not track.folder:
65
+ print(f"'{track.name}' has no configured repo (folder) — can't resolve a "
66
+ "shared tier to promote into.", file=sys.stderr)
67
+ return 1
68
+
69
+ entry = (cfg.get("repos") or {}).get(track.folder) or {}
70
+ github = entry.get("github")
71
+ branch = entry.get("plan_branch")
72
+ local = entry.get("local")
73
+ if not local:
74
+ print(f"repo '{track.folder}' has no local clone path in config.", file=sys.stderr)
75
+ return 2
76
+ if not branch:
77
+ print(f"repo '{track.folder}' has no shared plan branch. Set one up first:\n"
78
+ f" /work-plan plan-branch init {track.folder}", file=sys.stderr)
79
+ return 1
80
+
81
+ # Exposure gate (the push is what publishes). Fire BEFORE any mutation so the
82
+ # viewer's modal lands first; --no-push keeps it local, so no gate. Fails
83
+ # CLOSED on unknown visibility (same as plan-branch push).
84
+ if not no_push and needs_confirm(github, cfg):
85
+ confirm = flags.get("--confirm")
86
+ if not (isinstance(confirm, str) and valid_token(confirm, github, track.name)):
87
+ print(_needs_confirm_json(github, branch, track.name))
88
+ return 0
89
+
90
+ # Resolve (and ensure) the shared-tier worktree dir.
91
+ shared_dir = pw.shared_tier_dir(entry)
92
+ if shared_dir is None:
93
+ print(f"ERROR: could not open the shared plan branch '{branch}' for "
94
+ f"'{track.folder}'. Run `plan-branch init {track.folder}` first.",
95
+ file=sys.stderr)
96
+ return 1
97
+ dest = shared_dir / f"{track.name}.md"
98
+ if dest.exists():
99
+ print(f"ERROR: a shared track '{track.name}' already exists at {dest}.",
100
+ file=sys.stderr)
101
+ return 1
102
+
103
+ # Move: write into the shared tier (frontmatter preserved), then remove the
104
+ # private copy so discover_tracks shows the track once (shared), not twice.
105
+ write_file(dest, track.meta, track.body)
106
+ try:
107
+ track.path.unlink()
108
+ except OSError as e:
109
+ print(f"WARN: wrote the shared copy but could not remove the private "
110
+ f"file {track.path}: {e} — remove it by hand to avoid a duplicate.",
111
+ file=sys.stderr)
112
+
113
+ worktree = shared_dir.parent
114
+ sha = pw.commit_shared_tier(
115
+ worktree, f"work-plan: promote track '{track.name}' to shared tier",
116
+ [f".work-plan/{track.name}.md"],
117
+ )
118
+ if sha is None:
119
+ print(f"WARN: moved '{track.name}' into the shared tier but the commit "
120
+ "did not land — commit it by hand in the plan-branch worktree.",
121
+ file=sys.stderr)
122
+
123
+ if no_push:
124
+ print(f"✓ promoted '{track.name}' to the shared tier (local commit "
125
+ f"{sha or '—'}). Run `plan-branch push {track.folder}` to share it.")
126
+ return 0
127
+
128
+ proc = pw.push_plan_branch(Path(local).expanduser(), branch)
129
+ if proc is None or proc.returncode != 0:
130
+ err = (getattr(proc, "stderr", "") or "").strip()
131
+ if "protected" in err.lower() or "pull request" in err.lower():
132
+ print(f"ERROR: origin rejected the push — '{branch}' looks protected. "
133
+ f"Exempt the plan branch from PR/branch-protection, or push it "
134
+ "by hand once. The promotion is committed locally.", file=sys.stderr)
135
+ else:
136
+ print(f"ERROR: promoted + committed locally, but the push failed: "
137
+ f"{err or 'unknown git error'}. Retry with `plan-branch push "
138
+ f"{track.folder}`.", file=sys.stderr)
139
+ return 1
140
+ print(f"✓ promoted '{track.name}' to the shared tier and pushed '{branch}'. "
141
+ "Teammates can `plan-branch init` to see it.")
142
+ return 0
143
+
144
+
145
+ def _needs_confirm_json(github, branch, name) -> str:
146
+ return json.dumps({
147
+ "needs_confirm": True,
148
+ "reason": (
149
+ f"{github} is PUBLIC (or its visibility is unknown). Promoting "
150
+ f"'{name}' to the shared tier and pushing '{branch}' makes that "
151
+ "track — its issue notes, priorities, and planning text — visible to "
152
+ "anyone on the internet, and it stays in public git history even if "
153
+ "later removed. Use --no-push to keep it local for now."
154
+ ),
155
+ "token": make_token(github, name),
156
+ })
@@ -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
@@ -65,8 +65,17 @@ def run(args: list[str]) -> int:
65
65
 
66
66
  def _refresh_many(tracks: list, yes: bool) -> int:
67
67
  """Refresh one or more tracks. Computes proposed updates, then asks one
68
- confirmation (or applies all if --yes)."""
68
+ confirmation (or applies all if --yes).
69
+
70
+ A track whose live fetch comes back incomplete (GitHub timeout, permission
71
+ error, or a frontmatter issue that no longer resolves) is SKIPPED, not
72
+ refreshed: the canonical table is rebuilt from frontmatter membership, so a
73
+ missing issue would render as '(not fetched)' and silently overwrite its
74
+ valid last-known row (#256). Skipped tracks are reported and force a nonzero
75
+ exit so `--yes` / `hygiene` callers can tell a degraded run from a clean one.
76
+ """
69
77
  pending = []
78
+ degraded = [] # (track, missing_nums) — fetch was incomplete; left untouched
70
79
  for i, track in enumerate(tracks, 1):
71
80
  print(f" [{i}/{len(tracks)}] {track.path.name}...", flush=True)
72
81
  canonical = find_canonical_status_tables(track.body)
@@ -94,6 +103,22 @@ def _refresh_many(tracks: list, yes: bool) -> int:
94
103
  issues_by_num = {i["number"]: i for i in issues}
95
104
  state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
96
105
 
106
+ # Both render paths rebuild the table from frontmatter membership, so a
107
+ # frontmatter issue we couldn't fetch would land as a '(not fetched)'
108
+ # row, replacing its valid last-known values. Refuse to publish that:
109
+ # skip the track and surface the gap (#256). Table-only numbers that
110
+ # aren't in frontmatter don't feed the rebuild, so they don't gate.
111
+ unique_fm = set(frontmatter_nums)
112
+ missing = sorted(n for n in unique_fm if n not in issues_by_num)
113
+ if missing:
114
+ degraded.append((track, missing))
115
+ scope = ("no issues" if len(missing) == len(unique_fm)
116
+ else f"{len(missing)}/{len(unique_fm)} issues")
117
+ nums = ", ".join(f"#{n}" for n in missing)
118
+ print(f" ⚠ fetch returned {scope} short ({nums}) "
119
+ f"— skipping to preserve current rows")
120
+ continue
121
+
97
122
  if canonical:
98
123
  # Canonical table → RE-DERIVE the whole block from frontmatter
99
124
  # membership + live data, milestone-ordered (#101). Re-deriving from
@@ -113,6 +138,9 @@ def _refresh_many(tracks: list, yes: bool) -> int:
113
138
  pending.append((track, new_body, detail))
114
139
 
115
140
  if not pending:
141
+ if degraded:
142
+ _report_degraded(degraded)
143
+ return 1
116
144
  print("All tracks in sync.")
117
145
  return 0
118
146
 
@@ -127,9 +155,29 @@ def _refresh_many(tracks: list, yes: bool) -> int:
127
155
  for track, new_body, _ in pending:
128
156
  write_file(track.path, track.meta, new_body)
129
157
  print(f"\n✓ Updated {len(pending)} file(s).")
158
+
159
+ if degraded:
160
+ _report_degraded(degraded)
161
+ return 1
130
162
  return 0
131
163
 
132
164
 
165
+ def _report_degraded(degraded: list) -> None:
166
+ """Summarize tracks skipped because their live fetch was incomplete (#256).
167
+
168
+ Their tables are left exactly as they were — better a stale-but-valid row
169
+ than a '(not fetched)' placeholder published as truth. A persistently
170
+ missing number usually means the issue was deleted/transferred and should
171
+ be dropped from frontmatter."""
172
+ print(f"\n⚠ Skipped {len(degraded)} track(s) — live fetch was incomplete, "
173
+ f"so their tables were left untouched:")
174
+ for track, missing in degraded:
175
+ nums = ", ".join(f"#{n}" for n in missing)
176
+ print(f" {track.path.name}: could not fetch {nums}")
177
+ print(" Re-run once GitHub is reachable, or drop deleted issues from "
178
+ "frontmatter (`/work-plan reconcile`).")
179
+
180
+
133
181
  def _rederive_canonical(track, canonical_tables, frontmatter_nums,
134
182
  issues_by_num, state_by_num):
135
183
  """Rebuild the canonical block, milestone-ordered, from live data.