@stylusnexus/work-plan 2026.7.13 → 2026.7.16

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
@@ -54,6 +54,7 @@ The five essentials you'll use 80% of the time are:
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 four steps: ① `refresh-md --all` (pull live GitHub state into every active track's status table), ② `reconcile --all` (sync frontmatter membership against GitHub labels), ③ `dedupe-tiers` (report shared/private duplicate tracks, no deletes), ④ `duplicates` (flag likely-duplicate issues). `--repo=<key>` scopes steps ①–③ to one repo; step ④ is skipped in scoped mode. |
56
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. |
57
+ | `/work-plan doctor --fix` | Detect config drift after a folder rename or GitHub repo move. Checks whether config.yml, local clones, track frontmatter, and GitHub match (renamed local folder, renamed repo, broken path, duplicate entries, stale per-track `github.repo`). `--fix` corrects the two safe cases (GitHub-confirmed rename, stale track slug) and re-scans. |
57
58
 
58
59
  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:
59
60
 
@@ -529,6 +530,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
529
530
  | `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. |
530
531
  | `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. |
531
532
  | `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `dedupe-tiers` (report-only) + `duplicates`. With `--repo=<key>`, steps 1–3 scope to that repo and the global `duplicates` step is skipped. |
533
+ | `doctor [--json] [--fix]` | Detect config drift: a renamed local folder or GitHub repo that `config.yml` no longer matches, a non-git local path, duplicate entries, an invalid/missing `notes_root`, an orphaned notes folder, or a stale per-track `github.repo`. Run this right after any rename/move. `--fix` corrects only the two mechanically-safe cases (a GitHub-confirmed rename, a stale track slug) and always re-scans afterward. `--json` for machine output. |
532
534
  | `dedupe-tiers [--repo=<key>] [--apply]` | Remove private track copies that a shared twin in a repo's `.work-plan/` supersedes (#359). When a track is promoted to the shared tier, its private original under `notes_root` is sometimes left behind (bulk/manual promotion, or a failed unlink during `push-track`) — `discover_tracks` then warns `exists in both shared and private` on every run with no cleanup path. This removes the safe orphans and **refuses** any whose private copy references issue numbers the shared one lacks (no silent data loss; the invariant is `issue_refs(private) ⊆ issue_refs(shared)`). Covers active and archived tiers. Default is a **dry-run report**; `--apply` deletes (auto-committed to `notes_root`, so undoable via `notes-vcs undo`). `--repo=<key>` scopes to one repo. |
533
535
  | `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. |
534
536
  | `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. |
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2026.07.13+6341563
1
+ 2026.07.16+bcb403e
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stylusnexus/work-plan",
3
- "version": "2026.7.13",
3
+ "version": "2026.7.16",
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"
@@ -17,6 +17,7 @@ Track-aware daily planner. Each "track" is a YAML-frontmattered markdown file th
17
17
  | `/work-plan handoff [track] [--auto-next \| --set-next 1,2,3]` | Wrapping up a work block. Captures touched + next + blockers; writes session log. Add `--auto-next` to suggest a priority-sorted next_up list from open issues (interactive: apply / edit / skip). Tracks with `next_up_auto: true` in frontmatter get the auto-derived list surfaced in `brief` automatically. |
18
18
  | `/work-plan orient [track]` (alias `where-was-i`) | Re-orienting. With a track: ~15-line track paste-block. Without: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
19
19
  | `/work-plan hygiene [--repo=<key>]` | **Weekly all-in-one cleanup.** Three steps in sequence: ① `refresh-md --all` — pull live GitHub state into every active track's status table (same as "Sync Issue States from GitHub" but for all tracks); ② `reconcile --all` — sync track frontmatter membership against GitHub labels; ③ `duplicates` — flag likely-duplicate issues for consolidation. Run once a week to keep status icons, labels, and dedup state honest. `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode (it needs a single explicit repo to be unambiguous). |
20
+ | `/work-plan doctor [--json] [--fix]` | Config drifted — a renamed local folder or GitHub repo, config.yml no longer matches. Run right after any rename/move. `--fix` corrects the safe subset and re-scans. |
20
21
  | `/work-plan slot <issue-num> [track]` | A new GitHub issue should belong to a track. If the issue is already listed in another active track's frontmatter, you'll be prompted to move it (remove from source) instead of duplicating. |
21
22
  | `/work-plan close [track]` | Track is done (shipped) / paused (parked) / won't ship (abandoned). |
22
23
  | `/work-plan mark-cleanup <track> [--reason=<text>] [--clear]` | Earmark a track for cleanup — a reversible `cleanup_candidate` flag `hygiene` surfaces. Triggers: "mark this track for cleanup / retirement". Non-destructive. |
@@ -1,4 +1,6 @@
1
1
  """brief subcommand — fully featured."""
2
+ from __future__ import annotations
3
+
2
4
  import os
3
5
  from datetime import datetime
4
6
  from pathlib import Path
@@ -108,9 +110,43 @@ def run(args: list[str]) -> int:
108
110
  print(framing)
109
111
  print()
110
112
 
113
+ # Batch same-repo GitHub reads across tracks (#420): tracks sharing a repo
114
+ # otherwise each re-fetch the same issue set / recent-issues list from
115
+ # scratch. Fetch once per repo here, then partition back into each track
116
+ # in _build_track_block — field coverage, ordering, and fail-soft fallback
117
+ # stay identical to the old per-track fetch (same underlying helpers, same
118
+ # per-track number/slug subsets, just fetched once instead of N times).
119
+ repo_tracks: dict = {}
120
+ for t in active:
121
+ if t.repo:
122
+ repo_tracks.setdefault(t.repo, []).append(t)
123
+
124
+ issues_by_repo: dict = {}
125
+ new_issues_by_repo: dict = {}
126
+ for repo, rtracks in repo_tracks.items():
127
+ all_nums = _numeric_refs(*[
128
+ ref_list
129
+ for rt in rtracks
130
+ for ref_list in (rt.meta.get("github", {}).get("issues") or [],
131
+ rt.meta.get("next_up") or [])
132
+ ])
133
+ issues_by_repo[repo] = (
134
+ {i["number"]: i for i in fetch_issues(repo, all_nums)} if all_nums else {}
135
+ )
136
+
137
+ slugs = [rt.meta.get("track", rt.name) for rt in rtracks]
138
+ slug_labels = build_slug_labels(rtracks)
139
+ new_issues_by_repo[repo] = find_new_issues_for_tracks(
140
+ repo, slugs, slug_labels=slug_labels, since_days=7,
141
+ )
142
+
111
143
  blocks = []
112
144
  for t in active:
113
- b = _build_track_block(t, cfg, now)
145
+ b = _build_track_block(
146
+ t, cfg, now,
147
+ repo_issues_by_num=issues_by_repo.get(t.repo, {}),
148
+ repo_new_issues=new_issues_by_repo.get(t.repo, {}),
149
+ )
114
150
  blocks.append((b["sort_key"], b))
115
151
 
116
152
  blocks.sort(key=lambda x: x[0])
@@ -142,10 +178,14 @@ def run(args: list[str]) -> int:
142
178
  return 0
143
179
 
144
180
 
145
- def _build_track_block(track, cfg, now: datetime) -> dict:
181
+ def _build_track_block(track, cfg, now: datetime, *,
182
+ repo_issues_by_num: dict | None = None,
183
+ repo_new_issues: dict | None = None) -> dict:
146
184
  meta = track.meta
147
185
  repo = track.repo
148
186
  local = track.local_path
187
+ repo_issues_by_num = repo_issues_by_num or {}
188
+ repo_new_issues = repo_new_issues or {}
149
189
 
150
190
  issue_nums = meta.get("github", {}).get("issues") or []
151
191
  stored_next_up = meta.get("next_up") or []
@@ -153,7 +193,9 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
153
193
  # so stale closed entries surface as a clear signal rather than vanishing.
154
194
  # Only numeric refs are fetchable; string tokens in next_up are dropped (#417).
155
195
  fetch_nums = _numeric_refs(issue_nums, stored_next_up)
156
- issues = fetch_issues(repo, fetch_nums) if (repo and fetch_nums) else []
196
+ # Partition this track's slice out of the repo-batched fetch (#420) instead
197
+ # of fetching it directly — same order, same skip-if-missing fallback.
198
+ issues = [repo_issues_by_num[n] for n in fetch_nums if n in repo_issues_by_num]
157
199
  issues_by_num = {i["number"]: i for i in issues}
158
200
 
159
201
  # When `next_up_auto: true` is set in track frontmatter, derive the list
@@ -225,18 +267,16 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
225
267
  operational_status = stored_status
226
268
 
227
269
  track_slug = meta.get("track", track.name)
228
- slug_labels = build_slug_labels([track])
229
- new_issues_map = find_new_issues_for_tracks(repo, [track_slug], slug_labels=slug_labels, since_days=7) if repo else {}
230
270
  listed_set = set(issue_nums)
231
271
  new_issues = []
232
- for issue in new_issues_map.get(track_slug, []):
272
+ for issue in repo_new_issues.get(track_slug, []):
233
273
  if issue["number"] in listed_set:
234
274
  continue
235
275
  new_issues.append({"number": issue["number"], "title": issue["title"]})
236
276
 
237
277
  drift_items = detect_drift(track.body, issues) if issues else []
238
278
 
239
- related_recent_count = len(new_issues_map.get(track_slug, []))
279
+ related_recent_count = len(repo_new_issues.get(track_slug, []))
240
280
  signals = compute_signals(meta, issues, local, related_recent_count)
241
281
  closure_ready, _ = is_closure_ready(signals)
242
282
  if closure_ready:
@@ -0,0 +1,529 @@
1
+ """doctor subcommand — detect (and, for a safe subset, fix) drift between
2
+ config.yml, local git clones, GitHub, and notes_root track frontmatter.
3
+
4
+ See docs/superpowers/specs/2026-07-15-config-drift-doctor-design.md (revision
5
+ 5) for the full design and the four Codex /spec-review passes that shaped it.
6
+ Read-only by default; --fix applies the two mechanically-safe corrections
7
+ (a GitHub-confirmed rename, a stale track frontmatter slug) and always
8
+ re-scans from disk afterward before deciding the exit code.
9
+ """
10
+ import json
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ from concurrent.futures import ThreadPoolExecutor, wait
15
+ from pathlib import Path
16
+
17
+ from lib.config import (
18
+ load_config, ConfigError, is_valid_git_repo,
19
+ DEFAULT_CONFIG_PATH, write_repo_field, notes_vcs_auto_commit,
20
+ )
21
+ from lib.cwd_repo import _git, _normalize_remote_url
22
+ from lib.frontmatter import parse_file, write_file
23
+ from lib.github_state import repo_full_name
24
+ from lib.notes_vcs import dirty_paths_checked, auto_commit
25
+ from lib.prompts import parse_flags
26
+ from lib.tracks import iter_private_track_paths
27
+
28
+ _FATAL_EXCEPTIONS = (
29
+ ConfigError,
30
+ FileNotFoundError,
31
+ subprocess.CalledProcessError,
32
+ json.JSONDecodeError,
33
+ OSError,
34
+ AttributeError,
35
+ UnicodeDecodeError,
36
+ )
37
+
38
+ SAFE_KEY_RE = re.compile(r"^[a-z][a-z0-9-]*$")
39
+ SCAN_DEADLINE = 60 # seconds, wall-clock budget for Step 1 regardless of repo count
40
+ MAX_GH_WORKERS = 4
41
+
42
+ _HOST_RE = re.compile(r"^[\w.+-]+@([\w.-]+):|^[\w.+-]+://(?:[^/@]+@)?([^/]+)/")
43
+
44
+
45
+ def _remote_host(url: str) -> "str | None":
46
+ """Extract the host from a git remote URL, preserving it (unlike
47
+ `_normalize_remote_url`, which discards the host on purpose for its own
48
+ slug-comparison use case). Handles the same two forms `_normalize_remote_url`
49
+ does: scp-like and scheme://. An explicit `:<port>` on the scheme form
50
+ (e.g. `https://github.com:8080/org/repo.git`) is stripped so a genuine
51
+ github.com remote isn't misreported as non-GitHub just because it names a
52
+ port; the scp-like form's captured group can never contain a colon (its
53
+ own colon is the path separator, outside the capture), so the split is a
54
+ no-op there. Returns None if unparseable."""
55
+ if not url:
56
+ return None
57
+ m = _HOST_RE.match(url.strip())
58
+ if not m:
59
+ return None
60
+ host = (m.group(1) or m.group(2) or "").lower()
61
+ return host.split(":", 1)[0] or None
62
+
63
+
64
+ def _finding(type_, *, key=None, folder=None, track=None, message,
65
+ fixable=False, unverified=False, old=None, new=None):
66
+ return {
67
+ "type": type_, "key": key, "folder": folder, "track": track,
68
+ "message": message, "fixable": fixable, "unverified": unverified,
69
+ "old": old, "new": new,
70
+ }
71
+
72
+
73
+ def _load_config_safely():
74
+ """Returns (cfg, fatal_message) — exactly one is None."""
75
+ try:
76
+ return load_config(), None
77
+ except _FATAL_EXCEPTIONS as e:
78
+ return None, str(e)
79
+
80
+
81
+ def _validate_repo_field_shapes(cfg):
82
+ """Excludes any `repos` entry whose github/local field has the wrong type,
83
+ reporting `repo_entry_malformed` for each. Returns (valid_repos, findings)
84
+ — valid_repos is a NEW dict, cfg["repos"] is left untouched.
85
+ """
86
+ valid = {}
87
+ findings = []
88
+ for key, entry in (cfg.get("repos") or {}).items():
89
+ github = entry.get("github")
90
+ local = entry.get("local")
91
+ if not isinstance(github, str):
92
+ findings.append(_finding(
93
+ "repo_entry_malformed", key=key,
94
+ message=f"'github' must be a string, got {type(github).__name__}",
95
+ ))
96
+ continue
97
+ if local is not None and not isinstance(local, str):
98
+ findings.append(_finding(
99
+ "repo_entry_malformed", key=key,
100
+ message=f"'local' must be a string, got {type(local).__name__}",
101
+ ))
102
+ continue
103
+ valid[key] = entry
104
+ return valid, findings
105
+
106
+
107
+ def _resolve_canonical_slugs(repos: dict) -> dict:
108
+ """Step 1: one gh-confirmed canonical slug per repo, resolved concurrently
109
+ with an overall wall-clock deadline so a large repo count can't stall the
110
+ whole scan (or the VS Code extension's activation call, which has no
111
+ process-level timeout of its own). Not-yet-started jobs are cancelled at
112
+ the deadline so this function's own return time stays bounded by roughly
113
+ SCAN_DEADLINE + GH_TIMEOUT regardless of repo count — already-running
114
+ calls (up to MAX_GH_WORKERS of them) can't be cancelled mid-subprocess,
115
+ but that residual is count-independent, not the defect this fixes.
116
+ """
117
+ result = {}
118
+ if not repos:
119
+ return result
120
+ pool = ThreadPoolExecutor(max_workers=min(MAX_GH_WORKERS, len(repos)))
121
+ try:
122
+ futures = {pool.submit(repo_full_name, entry["github"]): key
123
+ for key, entry in repos.items()}
124
+ done, not_done = wait(futures, timeout=SCAN_DEADLINE)
125
+ for fut in done:
126
+ key = futures[fut]
127
+ full_name = fut.result()
128
+ configured = repos[key]["github"]
129
+ if full_name is None:
130
+ result[key] = {"canonical": configured, "unverified": True}
131
+ else:
132
+ result[key] = {"canonical": full_name, "unverified": False}
133
+ for fut in not_done:
134
+ key = futures[fut]
135
+ fut.cancel()
136
+ result[key] = {"canonical": repos[key]["github"], "unverified": True}
137
+ finally:
138
+ pool.shutdown(wait=False, cancel_futures=True)
139
+ return result
140
+
141
+
142
+ def _step1_findings(repos: dict, canonical: dict, scalar_shape_keys=None) -> list:
143
+ scalar_shape_keys = scalar_shape_keys or set()
144
+ findings = []
145
+ for key, entry in repos.items():
146
+ configured = entry["github"]
147
+ info = canonical[key]
148
+ if info["unverified"]:
149
+ findings.append(_finding(
150
+ "github_repo_unreachable", key=key, unverified=True,
151
+ message=f"could not confirm GitHub identity for '{configured}' "
152
+ "(404, no access, rate-limited, offline, or scan timed out)",
153
+ ))
154
+ continue
155
+ if info["canonical"].lower() == configured.lower():
156
+ continue
157
+ fixable = True
158
+ reason = None
159
+ if not SAFE_KEY_RE.match(key):
160
+ fixable = False
161
+ reason = "config key contains characters unsafe for automatic YAML updates"
162
+ elif key in scalar_shape_keys:
163
+ fixable = False
164
+ reason = (f"repo entry '{key}' uses the scalar shorthand form — convert "
165
+ f"'{key}: \"{configured}\"' to '{key}: {{github: \"{configured}\"}}' "
166
+ "by hand in config.yml, then re-run doctor --fix")
167
+ message = (f"'{key}' GitHub repo renamed: configured as '{configured}', "
168
+ f"GitHub now reports '{info['canonical']}'")
169
+ if reason:
170
+ message += f" — {reason}"
171
+ findings.append(_finding(
172
+ "github_rename_detected", key=key, message=message, fixable=fixable,
173
+ old=configured, new=info["canonical"],
174
+ ))
175
+ return findings
176
+
177
+
178
+ def _step2_findings(repos: dict, canonical: "dict | None" = None) -> list:
179
+ canonical = canonical or {}
180
+ findings = []
181
+ for key, entry in repos.items():
182
+ local = entry.get("local")
183
+ if not local:
184
+ continue
185
+ p = Path(local).expanduser()
186
+ if not p.is_absolute():
187
+ findings.append(_finding(
188
+ "local_path_relative", key=key,
189
+ message=f"'{key}'.local is a relative path ('{local}') — "
190
+ "results would depend on the current working directory; "
191
+ "use an absolute path",
192
+ ))
193
+ continue
194
+ if not p.exists():
195
+ findings.append(_finding(
196
+ "missing_local", key=key,
197
+ message=f"'{key}'.local ('{local}') does not exist on disk",
198
+ ))
199
+ continue
200
+ if not is_valid_git_repo(p):
201
+ findings.append(_finding(
202
+ "local_not_git", key=key,
203
+ message=f"'{key}'.local ('{local}') has no .git entry",
204
+ ))
205
+ continue
206
+ proc = _git(p, "remote", "get-url", "origin")
207
+ if proc is None or proc.returncode != 0 or not proc.stdout.strip():
208
+ findings.append(_finding(
209
+ "local_remote_missing", key=key,
210
+ message=f"'{key}'.local ('{local}') has no resolvable 'origin' remote",
211
+ ))
212
+ continue
213
+ raw_url = proc.stdout.strip()
214
+ host = _remote_host(raw_url)
215
+ if host != "github.com":
216
+ findings.append(_finding(
217
+ "local_remote_mismatch", key=key,
218
+ message=f"'{key}'.local's origin ('{raw_url}') is a non-GitHub host "
219
+ f"({host or 'unrecognized'}) — not compared to a slug",
220
+ ))
221
+ continue
222
+ slug = _normalize_remote_url(raw_url)
223
+ info = canonical.get(key)
224
+ expected = info["canonical"] if info else entry["github"]
225
+ if slug and slug != expected.lower():
226
+ findings.append(_finding(
227
+ "local_remote_mismatch", key=key, fixable=False,
228
+ message=f"'{key}'.local's origin ('{slug}') differs from the "
229
+ f"configured/canonical slug ('{expected}') — could be a "
230
+ "legitimate fork or mirror",
231
+ ))
232
+ return findings
233
+
234
+
235
+ def _notes_root_status(cfg: dict):
236
+ """Returns (walkable: bool, finding_or_None). walkable is False when either
237
+ notes_root_invalid or notes_root_missing fired — Step 4 must not walk."""
238
+ raw = cfg.get("notes_root")
239
+ if not isinstance(raw, str) or not raw.strip():
240
+ return False, _finding("notes_root_invalid",
241
+ message="notes_root is blank or not a string")
242
+ p = Path(raw).expanduser()
243
+ if not p.is_absolute():
244
+ return False, _finding("notes_root_invalid",
245
+ message=f"notes_root ('{raw}') is not an absolute path")
246
+ resolved = p.resolve()
247
+ if str(resolved) == resolved.anchor:
248
+ return False, _finding("notes_root_invalid",
249
+ message=f"notes_root ('{raw}') resolves to a bare filesystem root")
250
+ if not resolved.is_dir():
251
+ return False, _finding("notes_root_missing",
252
+ message=f"notes_root ('{raw}') does not exist or is not a directory")
253
+ return True, None
254
+
255
+
256
+ def _step3_findings(repos: dict, canonical: dict, cfg: dict) -> list:
257
+ findings = []
258
+ by_local = {}
259
+ by_slug = {}
260
+ for key, entry in repos.items():
261
+ local = entry.get("local")
262
+ if local:
263
+ resolved = str(Path(local).expanduser().resolve())
264
+ by_local.setdefault(resolved, []).append(key)
265
+ info = canonical.get(key)
266
+ slug = (info["canonical"] if info else entry["github"]).lower()
267
+ by_slug.setdefault(slug, []).append(key)
268
+ for resolved, keys in by_local.items():
269
+ if len(keys) > 1:
270
+ for key in keys:
271
+ findings.append(_finding(
272
+ "duplicate_local", key=key,
273
+ message=f"'{key}' shares local path '{resolved}' with {[k for k in keys if k != key]}",
274
+ ))
275
+ for slug, keys in by_slug.items():
276
+ if len(keys) > 1:
277
+ for key in keys:
278
+ findings.append(_finding(
279
+ "duplicate_github", key=key,
280
+ message=f"'{key}' shares canonical slug '{slug}' with {[k for k in keys if k != key]}",
281
+ ))
282
+ walkable, notes_finding = _notes_root_status(cfg)
283
+ if notes_finding:
284
+ findings.append(notes_finding)
285
+ return findings
286
+
287
+
288
+ def _step4_findings(cfg: dict, repos: dict, canonical: dict, walkable: bool) -> list:
289
+ if not walkable:
290
+ return []
291
+ notes_root = Path(cfg["notes_root"]).expanduser()
292
+ findings = []
293
+
294
+ # Walk once; reuse for both the orphaned-folder pass and the per-track pass.
295
+ track_paths = iter_private_track_paths(notes_root, include_archive=True)
296
+ folders_with_tracks = {
297
+ p.relative_to(notes_root).parts[0]
298
+ for p in track_paths
299
+ if len(p.relative_to(notes_root).parts) > 1
300
+ }
301
+
302
+ for child in sorted(notes_root.iterdir()):
303
+ if not child.is_dir() or child.name.startswith("."):
304
+ continue
305
+ if child.name not in folders_with_tracks:
306
+ continue
307
+ if child.name not in repos:
308
+ findings.append(_finding(
309
+ "orphaned_folder", folder=child.name,
310
+ message=f"notes_root folder '{child.name}' has no matching repos.<key>",
311
+ ))
312
+
313
+ for md_path in track_paths:
314
+ rel = md_path.relative_to(notes_root)
315
+ folder = rel.parts[0] if len(rel.parts) > 1 else None
316
+ track_name = md_path.name
317
+ try:
318
+ meta, _body = parse_file(md_path)
319
+ except Exception as e:
320
+ findings.append(_finding(
321
+ "track_unreadable", folder=folder, track=track_name,
322
+ message=f"could not parse frontmatter: {e}",
323
+ ))
324
+ continue
325
+ if not isinstance(meta, dict):
326
+ findings.append(_finding(
327
+ "track_unreadable", folder=folder, track=track_name,
328
+ message="frontmatter root is not a mapping",
329
+ ))
330
+ continue
331
+ github_block = meta.get("github")
332
+ if github_block is None:
333
+ continue
334
+ if not isinstance(github_block, dict):
335
+ findings.append(_finding(
336
+ "track_unreadable", folder=folder, track=track_name,
337
+ message="frontmatter 'github' is not a mapping",
338
+ ))
339
+ continue
340
+ repo_value = github_block.get("repo")
341
+ if repo_value is None:
342
+ continue
343
+ if not isinstance(repo_value, str):
344
+ findings.append(_finding(
345
+ "track_unreadable", folder=folder, track=track_name,
346
+ message="frontmatter 'github.repo' is not a string",
347
+ ))
348
+ continue
349
+ if folder is None or folder not in repos:
350
+ continue
351
+ info = canonical.get(folder)
352
+ if info is None or info["unverified"]:
353
+ continue
354
+ expected = info["canonical"]
355
+ if repo_value.lower() != expected.lower():
356
+ findings.append(_finding(
357
+ "stale_frontmatter", folder=folder, track=track_name, fixable=True,
358
+ message=f"track '{track_name}' frontmatter github.repo is '{repo_value}', "
359
+ f"folder '{folder}' canonical slug is '{expected}'",
360
+ old=repo_value, new=expected,
361
+ ))
362
+ return findings
363
+
364
+
365
+ def _scan(cfg, repos):
366
+ """Steps 1-4 — the full scan pipeline. Re-run verbatim after --fix applies
367
+ corrections (see `run()`), so a fixed finding must actually disappear."""
368
+ canonical = _resolve_canonical_slugs(repos)
369
+ findings = _step1_findings(repos, canonical, cfg.get("_scalar_shape_keys"))
370
+ findings += _step2_findings(repos, canonical)
371
+ findings += _step3_findings(repos, canonical, cfg)
372
+ walkable, _ = _notes_root_status(cfg)
373
+ findings += _step4_findings(cfg, repos, canonical, walkable)
374
+ return findings
375
+
376
+
377
+ def _apply_config_fixes(findings: list) -> list:
378
+ """Applies every fixable github_rename_detected finding to config.yml.
379
+ Returns the attempt ledger (one entry per attempt, in the order given)."""
380
+ ledger = []
381
+ for f in findings:
382
+ if f["type"] != "github_rename_detected" or not f["fixable"]:
383
+ continue
384
+ entry = {"type": f["type"], "key": f["key"], "folder": None, "track": None,
385
+ "old": f["old"], "new": f["new"], "fixed": False, "error": None}
386
+ try:
387
+ write_repo_field(f["key"], {"github": f["new"]}, path=DEFAULT_CONFIG_PATH)
388
+ entry["fixed"] = True
389
+ except subprocess.CalledProcessError as e:
390
+ entry["error"] = (e.stderr or str(e)).strip()
391
+ ledger.append(entry)
392
+ return ledger
393
+
394
+
395
+ def _apply_frontmatter_fixes(notes_root: Path, findings: list, auto_commit_enabled: bool):
396
+ """Applies every fixable stale_frontmatter finding, subject to the
397
+ dirty-file policy. Returns (ledger, skipped_due_to_unknown_dirty_state).
398
+
399
+ NOTE: a stale_frontmatter finding's `fixable=True` (set in
400
+ _step4_findings, at scan time) is an optimistic classification — that
401
+ scan has no way to know whether the track file is currently dirty. This
402
+ function is the authoritative, fix-time gate: it re-checks dirty state
403
+ via dirty_paths_checked() below before ever writing, and skips (fixable
404
+ in practice: false) anything already dirty or anything it can't verify.
405
+ A scan can therefore report a finding as "fixable" that a subsequent
406
+ --fix safely declines to touch. This is deliberate, not a bug.
407
+ """
408
+ ok_before, dirty_before = dirty_paths_checked(notes_root)
409
+ if not ok_before:
410
+ return [], True # fail closed: no writes at all this run
411
+
412
+ ledger = []
413
+ changed_paths = []
414
+ for f in findings:
415
+ if f["type"] != "stale_frontmatter" or not f["fixable"]:
416
+ continue
417
+ rel = f"{f['folder']}/{f['track']}"
418
+ entry = {"type": f["type"], "key": None, "folder": f["folder"], "track": f["track"],
419
+ "old": f["old"], "new": f["new"], "fixed": False, "error": None}
420
+ if rel in dirty_before:
421
+ entry["error"] = "file has uncommitted changes; commit/stash first or fix by hand"
422
+ ledger.append(entry)
423
+ continue
424
+ md_path = notes_root / f["folder"] / f["track"]
425
+ try:
426
+ meta, body = parse_file(md_path)
427
+ # Defensive re-check: `folder`/`track` on a finding lose any
428
+ # intermediate path segment (e.g. `known/archive/old.md` reports
429
+ # folder="known", track="old.md"), so a same-named file elsewhere
430
+ # under `folder` (archived vs. not) could otherwise collide here.
431
+ # Confirm the file we resolved still carries the OLD value this
432
+ # finding was raised against before writing — a mismatch means
433
+ # we resolved the wrong file, not that the finding is stale.
434
+ current = meta.get("github") if isinstance(meta, dict) else None
435
+ current_repo = current.get("repo") if isinstance(current, dict) else None
436
+ if current_repo != f["old"]:
437
+ entry["error"] = (
438
+ f"resolved path {md_path} does not carry the expected old "
439
+ f"value ({f['old']!r}, found {current_repo!r}) — refusing "
440
+ "to write; likely a nested/archived track name collision"
441
+ )
442
+ ledger.append(entry)
443
+ continue
444
+ meta["github"]["repo"] = f["new"]
445
+ write_file(md_path, meta, body)
446
+ entry["fixed"] = True
447
+ changed_paths.append(rel)
448
+ except Exception as e:
449
+ entry["error"] = str(e)
450
+ ledger.append(entry)
451
+
452
+ if auto_commit_enabled and changed_paths:
453
+ ok_after, dirty_after = dirty_paths_checked(notes_root)
454
+ if ok_after:
455
+ delta = sorted(set(dirty_after) - dirty_before)
456
+ if delta:
457
+ auto_commit(notes_root, "doctor: fix stale repo identity in track frontmatter",
458
+ paths=delta)
459
+ return ledger, False
460
+
461
+
462
+ def _print_json(payload):
463
+ print(json.dumps(payload))
464
+
465
+
466
+ def run(args: list) -> int:
467
+ flags, _ = parse_flags(args, {"--json", "--fix"})
468
+ want_json = bool(flags.get("--json"))
469
+ want_fix = bool(flags.get("--fix"))
470
+
471
+ cfg, fatal = _load_config_safely()
472
+ if fatal is not None:
473
+ if want_json:
474
+ _print_json({"fatal": fatal, "attempts": [], "findings": []})
475
+ return 0
476
+ print(f"ERROR: work-plan config could not be loaded ({fatal}) — doctor cannot run.")
477
+ return 1
478
+
479
+ repos, shape_findings = _validate_repo_field_shapes(cfg)
480
+ findings = list(shape_findings) + _scan(cfg, repos)
481
+ attempts = []
482
+
483
+ if want_fix:
484
+ attempts += _apply_config_fixes(findings)
485
+ notes_root_raw = cfg.get("notes_root")
486
+ walkable, _ = _notes_root_status(cfg)
487
+ if walkable:
488
+ notes_root = Path(notes_root_raw).expanduser()
489
+ auto_enabled = notes_vcs_auto_commit(cfg)
490
+ fm_ledger, snapshot_failed = _apply_frontmatter_fixes(notes_root, findings, auto_enabled)
491
+ attempts += fm_ledger
492
+ if snapshot_failed:
493
+ print("WARN: notes-vcs status check failed — cannot safely determine "
494
+ "which files are already dirty; no frontmatter fixes applied this run.",
495
+ file=sys.stderr)
496
+
497
+ # Mandatory post-fix rescan, from disk.
498
+ cfg2, fatal2 = _load_config_safely()
499
+ if fatal2 is not None:
500
+ if want_json:
501
+ _print_json({"fatal": f"{fatal2} (residual state indeterminate after --fix)",
502
+ "attempts": attempts, "findings": []})
503
+ return 0
504
+ print(f"ERROR: post-fix rescan failed ({fatal2}) — residual state indeterminate.")
505
+ return 1
506
+ repos2, shape_findings2 = _validate_repo_field_shapes(cfg2)
507
+ findings = list(shape_findings2) + _scan(cfg2, repos2)
508
+
509
+ if want_json:
510
+ _print_json({"attempts": attempts, "findings": findings})
511
+ return 0
512
+
513
+ for a in attempts:
514
+ thing = a["key"] or f"{a['folder']}/{a['track']}"
515
+ if a["fixed"]:
516
+ print(f"FIXED: {thing}: {a['old']} -> {a['new']}")
517
+ else:
518
+ print(f"ERROR fixing {thing}: {a['error']}")
519
+ if not findings:
520
+ if attempts:
521
+ print(f"{sum(1 for a in attempts if a['fixed'])} fixed, all clear.")
522
+ else:
523
+ print("No drift found.")
524
+ return 0
525
+ for f in findings:
526
+ prefix = "WARN (unfixed)" if attempts else "WARN"
527
+ print(f"{prefix}: {f['message']}")
528
+ print(f"{len(findings)} issue(s) found.")
529
+ return 1