@stylusnexus/work-plan 2026.7.13 → 2026.7.15
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/doctor.py +529 -0
- 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 +25 -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_config.py +59 -0
- package/skills/work-plan/tests/test_doctor.py +852 -0
- package/skills/work-plan/tests/test_github_state.py +41 -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
|
@@ -433,6 +433,31 @@ def repo_visibility(repo: str) -> Optional[str]:
|
|
|
433
433
|
return vis
|
|
434
434
|
|
|
435
435
|
|
|
436
|
+
def repo_full_name(slug: str) -> Optional[str]:
|
|
437
|
+
"""Live GitHub identity check via `gh api repos/<slug> --jq .full_name`.
|
|
438
|
+
|
|
439
|
+
Returns the repo's current canonical `owner/repo` — following GitHub's own
|
|
440
|
+
rename redirect if `slug` was renamed and the redirect is still live — or
|
|
441
|
+
None on any failure (404, no access, rate-limited, offline, bad slug
|
|
442
|
+
shape). Never raises. Used by `doctor` to detect a GitHub-confirmed rename;
|
|
443
|
+
NOT a stable identity check (see doctor's own docs) — a slug that was
|
|
444
|
+
reused by an unrelated repo after the redirect lapses is indistinguishable
|
|
445
|
+
from a genuine rename here.
|
|
446
|
+
"""
|
|
447
|
+
if not _valid_repo(slug):
|
|
448
|
+
return None
|
|
449
|
+
try:
|
|
450
|
+
proc = subprocess.run(
|
|
451
|
+
["gh", "api", f"repos/{slug}", "--jq", ".full_name"],
|
|
452
|
+
capture_output=True, text=True, timeout=GH_TIMEOUT,
|
|
453
|
+
)
|
|
454
|
+
except Exception:
|
|
455
|
+
return None
|
|
456
|
+
if proc.returncode != 0:
|
|
457
|
+
return None
|
|
458
|
+
return proc.stdout.strip() or None
|
|
459
|
+
|
|
460
|
+
|
|
436
461
|
def extract_priority(labels: list[dict]) -> str:
|
|
437
462
|
label_names = {lbl["name"] for lbl in labels}
|
|
438
463
|
for p in PRIORITY_LABELS:
|
|
@@ -132,26 +132,38 @@ def head_parent_sha(notes_root: Path) -> Optional[str]:
|
|
|
132
132
|
return proc.stdout.strip()
|
|
133
133
|
|
|
134
134
|
|
|
135
|
-
def
|
|
136
|
-
"""
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
def dirty_paths_checked(notes_root: Path) -> tuple:
|
|
136
|
+
"""Like `dirty_paths`, but distinguishes "clean tree" from "the git status
|
|
137
|
+
call itself failed" (timeout, spawn error, not a repo) — both of which
|
|
138
|
+
`dirty_paths` alone reports as an empty set. Returns (call_succeeded, paths).
|
|
139
|
+
|
|
140
|
+
Callers that need to know WHETHER they can trust an empty result (e.g.
|
|
141
|
+
doctor's dirty-file safety check, which must not treat "couldn't check" as
|
|
142
|
+
"definitely clean") should call this instead of `dirty_paths`.
|
|
141
143
|
"""
|
|
142
144
|
proc = _git(notes_root, "-c", "core.quotepath=false", "status", "--porcelain")
|
|
143
145
|
if proc is None or proc.returncode != 0:
|
|
144
|
-
return set()
|
|
146
|
+
return (False, set())
|
|
145
147
|
paths = set()
|
|
146
148
|
for line in proc.stdout.splitlines():
|
|
147
149
|
if len(line) < 4:
|
|
148
150
|
continue
|
|
149
151
|
path = line[3:]
|
|
150
|
-
if " -> " in path:
|
|
152
|
+
if " -> " in path:
|
|
151
153
|
path = path.split(" -> ", 1)[1]
|
|
152
154
|
if path:
|
|
153
155
|
paths.add(path)
|
|
154
|
-
return paths
|
|
156
|
+
return (True, paths)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def dirty_paths(notes_root: Path) -> set:
|
|
160
|
+
"""Set of work-tree paths with staged/unstaged changes (raw, quotepath off).
|
|
161
|
+
|
|
162
|
+
Empty set on any failure. Renames collapse to the destination path. Used by
|
|
163
|
+
the dispatcher to commit ONLY what a command changed, leaving pre-existing
|
|
164
|
+
dirty files untouched.
|
|
165
|
+
"""
|
|
166
|
+
return dirty_paths_checked(notes_root)[1]
|
|
155
167
|
|
|
156
168
|
|
|
157
169
|
def last_commit_summary(notes_root: Path) -> Optional[str]:
|
|
@@ -327,15 +327,29 @@ def _build_shared_track(md_path: Path, folder_key: str,
|
|
|
327
327
|
)
|
|
328
328
|
|
|
329
329
|
|
|
330
|
-
def
|
|
330
|
+
def iter_private_track_paths(notes_root: Path, include_archive: bool) -> list:
|
|
331
|
+
"""Eligible track markdown paths under `notes_root`, in sorted order.
|
|
332
|
+
|
|
333
|
+
Recursive. Skips `archive/` subtrees unless `include_archive` is True.
|
|
334
|
+
Skips filenames starting with '.', '_', or '-' (dotfiles, drafts, and
|
|
335
|
+
dash-led names that would otherwise misparse as a CLI flag, #194). This is
|
|
336
|
+
the single shared definition of "which private track files count" — both
|
|
337
|
+
`_walk` (track discovery) and `doctor` (drift scanning) call this, so they
|
|
338
|
+
cannot diverge on which files are in scope.
|
|
339
|
+
"""
|
|
331
340
|
out = []
|
|
332
341
|
for md_path in sorted(notes_root.rglob("*.md")):
|
|
333
342
|
if not include_archive and "archive" in md_path.parts:
|
|
334
343
|
continue
|
|
335
|
-
# '-' prefix rejected so a `--repo.md` file can't become a `--repo`
|
|
336
|
-
# track that the CLI misparses as a flag (#194).
|
|
337
344
|
if md_path.name.startswith((".", "_", "-")):
|
|
338
345
|
continue
|
|
346
|
+
out.append(md_path)
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
|
|
351
|
+
out = []
|
|
352
|
+
for md_path in iter_private_track_paths(notes_root, include_archive):
|
|
339
353
|
out.append(_build_track(md_path, notes_root, cfg))
|
|
340
354
|
return out
|
|
341
355
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import unittest
|
|
3
3
|
import tempfile
|
|
4
4
|
import sys
|
|
5
|
+
import subprocess
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
@@ -10,6 +11,7 @@ sys.path.insert(0, str(SKILL_ROOT))
|
|
|
10
11
|
from lib.config import (
|
|
11
12
|
load_config, ConfigError,
|
|
12
13
|
resolve_github_for_folder, resolve_local_path_for_folder,
|
|
14
|
+
write_repo_field,
|
|
13
15
|
)
|
|
14
16
|
|
|
15
17
|
|
|
@@ -81,5 +83,62 @@ class ResolveTest(unittest.TestCase):
|
|
|
81
83
|
self.assertIsNone(resolve_local_path_for_folder("unknown", self.cfg))
|
|
82
84
|
|
|
83
85
|
|
|
86
|
+
class BaseConfigWriteTest(unittest.TestCase):
|
|
87
|
+
"""Base class for tests that need to write and read config files."""
|
|
88
|
+
def _write_config(self, content):
|
|
89
|
+
"""Write content to a temporary config file and return its path."""
|
|
90
|
+
d = tempfile.mkdtemp()
|
|
91
|
+
path = Path(d) / "config.yml"
|
|
92
|
+
path.write_text(content, encoding="utf-8")
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestScalarShapeKeys(BaseConfigWriteTest):
|
|
97
|
+
def test_scalar_entry_is_tracked(self):
|
|
98
|
+
cfg_path = self._write_config(
|
|
99
|
+
"notes_root: /tmp/notes\n"
|
|
100
|
+
"repos:\n"
|
|
101
|
+
" foo: org/foo\n"
|
|
102
|
+
" bar:\n"
|
|
103
|
+
" github: org/bar\n"
|
|
104
|
+
)
|
|
105
|
+
cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
|
|
106
|
+
self.assertEqual(cfg["_scalar_shape_keys"], {"foo"})
|
|
107
|
+
|
|
108
|
+
def test_no_scalar_entries_is_empty_set(self):
|
|
109
|
+
cfg_path = self._write_config(
|
|
110
|
+
"notes_root: /tmp/notes\n"
|
|
111
|
+
"repos:\n"
|
|
112
|
+
" bar:\n"
|
|
113
|
+
" github: org/bar\n"
|
|
114
|
+
)
|
|
115
|
+
cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
|
|
116
|
+
self.assertEqual(cfg["_scalar_shape_keys"], set())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestWriteRepoField(BaseConfigWriteTest):
|
|
120
|
+
def test_writes_only_the_given_fields(self):
|
|
121
|
+
cfg_path = self._write_config(
|
|
122
|
+
"notes_root: /tmp/notes\n"
|
|
123
|
+
"repos:\n"
|
|
124
|
+
" bar:\n"
|
|
125
|
+
" github: org/bar\n"
|
|
126
|
+
" local: /code/bar\n"
|
|
127
|
+
)
|
|
128
|
+
write_repo_field("bar", {"github": "org/bar-renamed"}, path=cfg_path)
|
|
129
|
+
cfg = load_config(path=cfg_path, notes_root=Path("/tmp/notes"))
|
|
130
|
+
self.assertEqual(cfg["repos"]["bar"]["github"], "org/bar-renamed")
|
|
131
|
+
self.assertEqual(cfg["repos"]["bar"]["local"], "/code/bar")
|
|
132
|
+
|
|
133
|
+
def test_raises_on_scalar_entry(self):
|
|
134
|
+
cfg_path = self._write_config(
|
|
135
|
+
"notes_root: /tmp/notes\n"
|
|
136
|
+
"repos:\n"
|
|
137
|
+
" foo: org/foo\n"
|
|
138
|
+
)
|
|
139
|
+
with self.assertRaises(subprocess.CalledProcessError):
|
|
140
|
+
write_repo_field("foo", {"github": "org/foo-renamed"}, path=cfg_path)
|
|
141
|
+
|
|
142
|
+
|
|
84
143
|
if __name__ == "__main__":
|
|
85
144
|
unittest.main()
|