@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 +2 -0
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +1 -0
- package/skills/work-plan/commands/brief.py +47 -7
- package/skills/work-plan/commands/doctor.py +529 -0
- package/skills/work-plan/commands/export.py +97 -24
- package/skills/work-plan/commands/init_repo.py +3 -6
- package/skills/work-plan/lib/config.py +29 -0
- package/skills/work-plan/lib/github_state.py +54 -0
- package/skills/work-plan/lib/notes_vcs.py +21 -9
- package/skills/work-plan/lib/tracks.py +17 -3
- package/skills/work-plan/tests/test_brief_batch_fetch.py +140 -0
- package/skills/work-plan/tests/test_config.py +59 -0
- package/skills/work-plan/tests/test_doctor.py +852 -0
- package/skills/work-plan/tests/test_export_command.py +161 -8
- package/skills/work-plan/tests/test_github_state.py +143 -1
- package/skills/work-plan/tests/test_notes_vcs.py +30 -0
- package/skills/work-plan/tests/test_tracks.py +38 -1
- package/skills/work-plan/work_plan.py +20 -0
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.
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|