@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.
@@ -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 dirty_paths(notes_root: Path) -> set:
136
- """Set of work-tree paths with staged/unstaged changes (raw, quotepath off).
137
-
138
- Empty set on any failure. Renames collapse to the destination path. Used by
139
- the dispatcher to commit ONLY what a command changed, leaving pre-existing
140
- dirty files untouched.
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: # rename / copy → the destination 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 _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
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()