@stylusnexus/work-plan 2026.7.10 → 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 +9 -1
- 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/export.py +14 -11
- package/skills/work-plan/commands/init_repo.py +3 -6
- package/skills/work-plan/commands/new_track.py +21 -2
- package/skills/work-plan/commands/plan_status.py +48 -6
- package/skills/work-plan/lib/config.py +29 -0
- package/skills/work-plan/lib/doc_discovery.py +22 -2
- package/skills/work-plan/lib/export_model.py +16 -3
- 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/plan_worktree.py +47 -8
- 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_discover_archived.py +15 -0
- package/skills/work-plan/tests/test_doc_discovery.py +11 -0
- package/skills/work-plan/tests/test_doctor.py +852 -0
- package/skills/work-plan/tests/test_export.py +34 -8
- package/skills/work-plan/tests/test_export_command.py +101 -0
- package/skills/work-plan/tests/test_github_state.py +41 -1
- package/skills/work-plan/tests/test_group_apply.py +4 -0
- package/skills/work-plan/tests/test_new_track.py +79 -0
- package/skills/work-plan/tests/test_notes_vcs.py +30 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +68 -0
- package/skills/work-plan/tests/test_plan_worktree.py +31 -0
- package/skills/work-plan/tests/test_tracks.py +55 -1
- package/skills/work-plan/work_plan.py +20 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""export subcommand — emit the viewer-ready JSON read surface."""
|
|
2
2
|
import json
|
|
3
3
|
from datetime import datetime, date
|
|
4
|
+
from pathlib import Path
|
|
4
5
|
from lib.config import load_config, ConfigError, resolve_local_path_for_folder
|
|
5
6
|
from lib.tracks import discover_tracks, discover_archived_tracks, find_tier_duplicates, issue_refs
|
|
6
7
|
from lib.github_state import fetch_export_issues, fetch_open_issues, repo_visibility
|
|
7
8
|
from lib.git_state import hot_issue_numbers
|
|
8
|
-
from lib.export_model import build_export
|
|
9
|
+
from lib.export_model import build_export, track_key
|
|
9
10
|
from lib.prompts import parse_flags
|
|
10
11
|
from lib import doc_discovery
|
|
11
12
|
from lib import verdict as verdict_mod
|
|
@@ -25,11 +26,13 @@ def _plan_badge(track, cfg, today, dead_days, stall_days):
|
|
|
25
26
|
if not isinstance(rel, str) or not rel.strip():
|
|
26
27
|
return None
|
|
27
28
|
rel = rel.strip()
|
|
29
|
+
if Path(rel).is_absolute():
|
|
30
|
+
return {"rel": rel, "resolved": False}
|
|
28
31
|
local = resolve_local_path_for_folder(track.folder, cfg) if track.folder else None
|
|
29
32
|
if not local or not local.exists():
|
|
30
33
|
return {"rel": rel, "resolved": False}
|
|
31
34
|
doc_path = local / rel
|
|
32
|
-
if not
|
|
35
|
+
if not doc_discovery.is_safe_doc_path(doc_path, local):
|
|
33
36
|
return {"rel": rel, "resolved": False}
|
|
34
37
|
doc = doc_discovery.Doc(path=doc_path, rel=rel, kind=doc_discovery.classify_kind(rel))
|
|
35
38
|
row = evaluate_doc(doc, local, today, dead_days, stall_days)
|
|
@@ -85,19 +88,19 @@ def run(args: list[str]) -> int:
|
|
|
85
88
|
issue_map = fetch_export_issues(repo_to_numbers)
|
|
86
89
|
|
|
87
90
|
# Reassemble per-track lists, preserving each track's declared issue order.
|
|
88
|
-
#
|
|
89
|
-
issues_by_track: dict[tuple, list] = {}
|
|
91
|
+
# Canonical track identity keeps same-named tracks in different repos apart.
|
|
92
|
+
issues_by_track: dict[tuple[str, str], list] = {}
|
|
90
93
|
visibility: dict[str, object] = {}
|
|
91
94
|
for t in tracks:
|
|
92
95
|
nums = (t.meta.get("github", {}).get("issues")) or []
|
|
93
96
|
if t.repo and nums:
|
|
94
|
-
issues_by_track[(t
|
|
97
|
+
issues_by_track[track_key(t)] = [
|
|
95
98
|
issue_map[(t.repo, n)]
|
|
96
99
|
for n in nums
|
|
97
100
|
if (t.repo, n) in issue_map
|
|
98
101
|
]
|
|
99
102
|
else:
|
|
100
|
-
issues_by_track[(t
|
|
103
|
+
issues_by_track[track_key(t)] = []
|
|
101
104
|
if t.repo and t.repo not in visibility:
|
|
102
105
|
visibility[t.repo] = repo_visibility(t.repo)
|
|
103
106
|
|
|
@@ -140,14 +143,14 @@ def run(args: list[str]) -> int:
|
|
|
140
143
|
today = date.today()
|
|
141
144
|
cfg_stall = cfg.get("stall_days")
|
|
142
145
|
stall_days = cfg_stall if isinstance(cfg_stall, int) else verdict_mod.STALL_DAYS
|
|
143
|
-
plan_by_track: dict[str, dict] = {}
|
|
146
|
+
plan_by_track: dict[tuple[str, str], dict] = {}
|
|
144
147
|
for t in tracks:
|
|
145
148
|
badge = _plan_badge(t, cfg, today, verdict_mod.DEAD_DAYS, stall_days)
|
|
146
149
|
if badge is not None:
|
|
147
|
-
plan_by_track[t
|
|
150
|
+
plan_by_track[track_key(t)] = badge
|
|
148
151
|
|
|
149
|
-
# Per-track branch heat
|
|
150
|
-
hot_by_track: dict = {}
|
|
152
|
+
# Per-track branch heat uses the same identity as issue rows and plan badges.
|
|
153
|
+
hot_by_track: dict[tuple[str, str], set] = {}
|
|
151
154
|
for t in tracks:
|
|
152
155
|
if not t.repo:
|
|
153
156
|
continue
|
|
@@ -155,7 +158,7 @@ def run(args: list[str]) -> int:
|
|
|
155
158
|
if local and local.exists():
|
|
156
159
|
nums = hot_issue_numbers(local)
|
|
157
160
|
if nums:
|
|
158
|
-
hot_by_track[(t
|
|
161
|
+
hot_by_track[track_key(t)] = nums
|
|
159
162
|
|
|
160
163
|
# Shared/private tier duplicates (#361): the viewer is otherwise blind to
|
|
161
164
|
# them — discover_tracks drops the private copy with a stderr-only WARN. We
|
|
@@ -61,6 +61,8 @@ def _update_existing(key: str, github: str, local: "str | None", clear_local: bo
|
|
|
61
61
|
while keeping github + every other field. Mutually exclusive with `local`
|
|
62
62
|
(enforced in run() before this is called).
|
|
63
63
|
"""
|
|
64
|
+
from lib.config import write_repo_field
|
|
65
|
+
|
|
64
66
|
updates = {}
|
|
65
67
|
if clear_local:
|
|
66
68
|
# JSON null → YAML null; the * merge overwrites local with null, leaving
|
|
@@ -77,13 +79,8 @@ def _update_existing(key: str, github: str, local: "str | None", clear_local: bo
|
|
|
77
79
|
# `key` is validated against ^[a-z][a-z0-9-]*$ in run() before this is called,
|
|
78
80
|
# so it's safe in the yq path. Field values travel as an OPAQUE env value via
|
|
79
81
|
# env() (parsed as JSON), never interpolated — uniform with the add path.
|
|
80
|
-
env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
|
|
81
|
-
yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
|
|
82
82
|
try:
|
|
83
|
-
|
|
84
|
-
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
85
|
-
check=True, capture_output=True, text=True, env=env,
|
|
86
|
-
)
|
|
83
|
+
write_repo_field(key, updates)
|
|
87
84
|
except subprocess.CalledProcessError as e:
|
|
88
85
|
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
89
86
|
return 1
|
|
@@ -17,7 +17,7 @@ from pathlib import Path
|
|
|
17
17
|
from typing import Optional
|
|
18
18
|
|
|
19
19
|
from lib.config import load_config, ConfigError, is_valid_git_repo
|
|
20
|
-
from lib.plan_worktree import
|
|
20
|
+
from lib.plan_worktree import resolve_shared_tier
|
|
21
21
|
from lib.frontmatter import write_file
|
|
22
22
|
from lib.prompts import parse_flags
|
|
23
23
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
@@ -158,7 +158,10 @@ def run(args: list[str]) -> int:
|
|
|
158
158
|
if is_valid_git_repo(local_path):
|
|
159
159
|
# Worktree-aware (#260): plan_branch repos write into the
|
|
160
160
|
# worktree's .work-plan/; None → fall back to the private tier.
|
|
161
|
-
sd =
|
|
161
|
+
sd, unsafe_reason = resolve_shared_tier(repo_entry)
|
|
162
|
+
if unsafe_reason:
|
|
163
|
+
print(f"ERROR: {unsafe_reason}")
|
|
164
|
+
return 1
|
|
162
165
|
if sd is not None:
|
|
163
166
|
shared_path = sd / f"{slug}.md"
|
|
164
167
|
|
|
@@ -197,8 +200,24 @@ def run(args: list[str]) -> int:
|
|
|
197
200
|
# ------------------------------------------------------------------
|
|
198
201
|
# Create folder if missing, then write the track file
|
|
199
202
|
# ------------------------------------------------------------------
|
|
203
|
+
if is_shared:
|
|
204
|
+
# Re-resolve at the mutation boundary. Discovery and confirmation can
|
|
205
|
+
# take time, during which an attacker could replace `.work-plan` with a
|
|
206
|
+
# symlink. Refuse if the selected root changed or became unavailable.
|
|
207
|
+
current_shared, unsafe_reason = resolve_shared_tier(repo_entry)
|
|
208
|
+
if unsafe_reason or current_shared is None or current_shared != path.parent:
|
|
209
|
+
print(f"ERROR: {unsafe_reason or 'shared track directory changed before write'}")
|
|
210
|
+
return 1
|
|
200
211
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
201
212
|
|
|
213
|
+
if is_shared:
|
|
214
|
+
# mkdir may have created the tier; validate that concrete directory once
|
|
215
|
+
# more before handing the file path to the frontmatter writer.
|
|
216
|
+
current_shared, unsafe_reason = resolve_shared_tier(repo_entry)
|
|
217
|
+
if unsafe_reason or current_shared is None or current_shared != path.parent:
|
|
218
|
+
print(f"ERROR: {unsafe_reason or 'shared track directory changed before write'}")
|
|
219
|
+
return 1
|
|
220
|
+
|
|
202
221
|
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
203
222
|
meta = {
|
|
204
223
|
"track": slug,
|
|
@@ -5,7 +5,10 @@ Phase 1: read-only. Reports a human table or --json. Never mutates a doc.
|
|
|
5
5
|
Manifest-less (prose) docs are flagged 👻 for the Phase 1b LLM pass.
|
|
6
6
|
"""
|
|
7
7
|
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
8
10
|
import sys
|
|
11
|
+
import tempfile
|
|
9
12
|
from datetime import date, timedelta
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
|
|
@@ -247,21 +250,60 @@ def _render(rows, repo_root) -> None:
|
|
|
247
250
|
print()
|
|
248
251
|
|
|
249
252
|
|
|
250
|
-
def _stamp_docs(docs, rows, draft: bool) -> None:
|
|
253
|
+
def _stamp_docs(docs, rows, repo_root, draft: bool) -> None:
|
|
251
254
|
changed = []
|
|
252
255
|
for doc, row in zip(docs, rows):
|
|
256
|
+
if not doc_discovery.is_safe_doc_path(doc.path, repo_root):
|
|
257
|
+
print(f"WARN: refusing to stamp unsafe path: {doc.rel}")
|
|
258
|
+
continue
|
|
253
259
|
text = doc.path.read_text(encoding="utf-8", errors="replace")
|
|
254
260
|
new = status_header.stamp(text, row)
|
|
255
261
|
if new != text:
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
262
|
+
if draft:
|
|
263
|
+
changed.append(doc.rel)
|
|
264
|
+
elif _replace_doc_text(doc.path, new, repo_root):
|
|
265
|
+
changed.append(doc.rel)
|
|
266
|
+
else:
|
|
267
|
+
print(f"WARN: refusing to stamp unsafe path: {doc.rel}")
|
|
259
268
|
verb = "would stamp" if draft else "stamped"
|
|
260
269
|
print(f"\n{verb} {len(changed)} doc(s):")
|
|
261
270
|
for rel in changed:
|
|
262
271
|
print(f" {rel}")
|
|
263
272
|
|
|
264
273
|
|
|
274
|
+
def _replace_doc_text(path: Path, text: str, repo_root: Path) -> bool:
|
|
275
|
+
"""Atomically replace a validated doc without writing through hard links."""
|
|
276
|
+
path = Path(path)
|
|
277
|
+
tmp_path = None
|
|
278
|
+
try:
|
|
279
|
+
original_mode = stat.S_IMODE(path.stat().st_mode)
|
|
280
|
+
with tempfile.NamedTemporaryFile(
|
|
281
|
+
mode="w", encoding="utf-8", dir=str(path.parent),
|
|
282
|
+
prefix=f".{path.name}.", suffix=".tmp", delete=False) as tmp:
|
|
283
|
+
tmp.write(text)
|
|
284
|
+
tmp.flush()
|
|
285
|
+
os.fsync(tmp.fileno())
|
|
286
|
+
tmp_path = Path(tmp.name)
|
|
287
|
+
os.chmod(str(tmp_path), original_mode)
|
|
288
|
+
|
|
289
|
+
# Recheck immediately before replacement. os.replace swaps the directory
|
|
290
|
+
# entry instead of following a final-component symlink and also breaks any
|
|
291
|
+
# hard-link relationship held by the original inode.
|
|
292
|
+
if not doc_discovery.is_safe_doc_path(path, repo_root):
|
|
293
|
+
return False
|
|
294
|
+
os.replace(str(tmp_path), str(path))
|
|
295
|
+
tmp_path = None
|
|
296
|
+
return True
|
|
297
|
+
except OSError:
|
|
298
|
+
return False
|
|
299
|
+
finally:
|
|
300
|
+
if tmp_path is not None:
|
|
301
|
+
try:
|
|
302
|
+
tmp_path.unlink()
|
|
303
|
+
except OSError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
|
|
265
307
|
_LLM_VERDICTS = {"shipped", "partial", "dead"}
|
|
266
308
|
_LLM_GLYPH = {"shipped": "✅", "partial": "🟡", "dead": "💀"}
|
|
267
309
|
|
|
@@ -336,7 +378,7 @@ def _llm_apply(docs, rows, repo_root, stamp: bool, draft: bool) -> int:
|
|
|
336
378
|
|
|
337
379
|
_render(rows, repo_root)
|
|
338
380
|
if stamp:
|
|
339
|
-
_stamp_docs(docs, rows, draft=draft)
|
|
381
|
+
_stamp_docs(docs, rows, repo_root=repo_root, draft=draft)
|
|
340
382
|
return 0
|
|
341
383
|
|
|
342
384
|
|
|
@@ -551,5 +593,5 @@ def run(args: list) -> int:
|
|
|
551
593
|
if flags.get("--stamp"):
|
|
552
594
|
live_docs = [d for d in docs if not d.archived]
|
|
553
595
|
_stamp_docs(live_docs, [r for r in rows if not r.get("archived")],
|
|
554
|
-
draft=bool(flags.get("--draft")))
|
|
596
|
+
repo_root=repo_root, draft=bool(flags.get("--draft")))
|
|
555
597
|
return 0
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Load + validate ~/.claude/work-plan/config.yml."""
|
|
2
2
|
import json
|
|
3
|
+
import os
|
|
3
4
|
import subprocess
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Optional
|
|
@@ -57,9 +58,11 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH,
|
|
|
57
58
|
if "notes_root" not in cfg:
|
|
58
59
|
raise ConfigError("config.yml missing required key 'notes_root'.")
|
|
59
60
|
cfg.setdefault("repos", {})
|
|
61
|
+
scalar_shape_keys = set()
|
|
60
62
|
# Normalize string-shape entries to dict shape
|
|
61
63
|
for folder, val in list(cfg["repos"].items()):
|
|
62
64
|
if isinstance(val, str):
|
|
65
|
+
scalar_shape_keys.add(folder)
|
|
63
66
|
cfg["repos"][folder] = {"github": val, "local": None}
|
|
64
67
|
elif isinstance(val, dict):
|
|
65
68
|
val.setdefault("local", None)
|
|
@@ -67,6 +70,7 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH,
|
|
|
67
70
|
raise ConfigError(f"repo '{folder}' missing 'github' key")
|
|
68
71
|
else:
|
|
69
72
|
raise ConfigError(f"repo '{folder}' must be string or dict, got {type(val).__name__}")
|
|
73
|
+
cfg["_scalar_shape_keys"] = scalar_shape_keys
|
|
70
74
|
return cfg
|
|
71
75
|
|
|
72
76
|
|
|
@@ -97,3 +101,28 @@ def resolve_local_path_for_folder(folder_name: str, cfg: dict) -> Optional[Path]
|
|
|
97
101
|
if not entry or not entry.get("local"):
|
|
98
102
|
return None
|
|
99
103
|
return Path(entry["local"]).expanduser()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write_repo_field(key: str, updates: dict, path: Path = DEFAULT_CONFIG_PATH) -> None:
|
|
107
|
+
"""Merge `updates` into `repos.<key>` in config.yml via an opaque-env `yq`
|
|
108
|
+
merge — the same mechanic `init_repo.py::_update_existing` already used
|
|
109
|
+
(extracted here so `doctor` and `init-repo` share one implementation).
|
|
110
|
+
|
|
111
|
+
`updates` values travel as JSON through an env var, never interpolated
|
|
112
|
+
into the yq expression, so they can't break out of the merge. `key` is
|
|
113
|
+
interpolated directly into the yq path — callers MUST validate it against
|
|
114
|
+
a safe-key pattern (e.g. `^[a-z][a-z0-9-]*$`) before calling this; this
|
|
115
|
+
function does not re-validate, matching `_update_existing`'s existing
|
|
116
|
+
contract (its caller already validates via `init-repo`'s own regex check).
|
|
117
|
+
|
|
118
|
+
Raises `subprocess.CalledProcessError` on any yq failure — including the
|
|
119
|
+
known case of `repos.<key>` being a scalar string on disk (yq cannot
|
|
120
|
+
multiply a string with a map). Callers must catch this and treat it as
|
|
121
|
+
"entry not fixable this way", not crash the whole command.
|
|
122
|
+
"""
|
|
123
|
+
env = {**os.environ, "WP_REPO_UPDATES": json.dumps(updates)}
|
|
124
|
+
yq_expr = f".repos.{key} = (.repos.{key} // {{}}) * env(WP_REPO_UPDATES)"
|
|
125
|
+
subprocess.run(
|
|
126
|
+
["yq", "-i", yq_expr, str(path)],
|
|
127
|
+
check=True, capture_output=True, text=True, env=env,
|
|
128
|
+
)
|
|
@@ -30,6 +30,26 @@ def classify_kind(rel: str) -> str:
|
|
|
30
30
|
return "adhoc"
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
def is_safe_doc_path(path: Path, repo_root: Path) -> bool:
|
|
34
|
+
"""Return whether an existing doc resolves to a regular file inside the repo.
|
|
35
|
+
|
|
36
|
+
``Path.is_file()`` follows symlinks, so it is not sufficient for paths that
|
|
37
|
+
may later be stamped. Reject a symlink at the final component and require
|
|
38
|
+
the fully-resolved target to remain beneath the resolved repository root.
|
|
39
|
+
The write path calls this again to avoid trusting discovery-time state.
|
|
40
|
+
"""
|
|
41
|
+
path = Path(path)
|
|
42
|
+
try:
|
|
43
|
+
if path.is_symlink():
|
|
44
|
+
return False
|
|
45
|
+
resolved_root = Path(repo_root).resolve(strict=True)
|
|
46
|
+
resolved_path = path.resolve(strict=True)
|
|
47
|
+
resolved_path.relative_to(resolved_root)
|
|
48
|
+
return resolved_path.is_file()
|
|
49
|
+
except (OSError, ValueError):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
33
53
|
def discover_docs(repo_root: Path, globs: Optional[list] = None,
|
|
34
54
|
include_archived: bool = False) -> list:
|
|
35
55
|
globs = globs or DEFAULT_GLOBS
|
|
@@ -38,7 +58,7 @@ def discover_docs(repo_root: Path, globs: Optional[list] = None,
|
|
|
38
58
|
seen = set()
|
|
39
59
|
for g in globs:
|
|
40
60
|
for p in sorted(repo_root.glob(g)):
|
|
41
|
-
if not p
|
|
61
|
+
if not is_safe_doc_path(p, repo_root) or p in seen:
|
|
42
62
|
continue
|
|
43
63
|
seen.add(p)
|
|
44
64
|
rel = p.relative_to(repo_root).as_posix()
|
|
@@ -48,7 +68,7 @@ def discover_docs(repo_root: Path, globs: Optional[list] = None,
|
|
|
48
68
|
parent = g.rsplit("/", 1)[0] # "docs/plans/*.md" -> "docs/plans"
|
|
49
69
|
for sub, kind in ARCHIVE_SUBDIRS:
|
|
50
70
|
for p in sorted(repo_root.glob(f"{parent}/{sub}/*.md")):
|
|
51
|
-
if not p
|
|
71
|
+
if not is_safe_doc_path(p, repo_root) or p in seen:
|
|
52
72
|
continue
|
|
53
73
|
seen.add(p)
|
|
54
74
|
rel = p.relative_to(repo_root).as_posix()
|
|
@@ -5,6 +5,18 @@ from lib.next_up import resolve_next_up_order, suggest_next_up
|
|
|
5
5
|
SCHEMA = 1
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
def track_key(track) -> tuple[str, str]:
|
|
9
|
+
"""Return the canonical internal identity for a track.
|
|
10
|
+
|
|
11
|
+
The config folder key is the CLI repo qualifier and keeps separately
|
|
12
|
+
configured sources distinct even when they point at the same GitHub repo.
|
|
13
|
+
Older/minimal tracks without a folder fall back to the GitHub slug. The key
|
|
14
|
+
is internal to export assembly and does not change the schema-1 JSON surface.
|
|
15
|
+
"""
|
|
16
|
+
scope = getattr(track, "folder", None) or getattr(track, "repo", None) or ""
|
|
17
|
+
return (scope, track.name)
|
|
18
|
+
|
|
19
|
+
|
|
8
20
|
def milestone_sort_key(issue: dict, milestone_alignment=None):
|
|
9
21
|
"""Sort key for an issue dict (must have 'number' and 'milestone').
|
|
10
22
|
|
|
@@ -92,8 +104,9 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
92
104
|
out = {"schema": SCHEMA, "generated_at": now, "tracks": []}
|
|
93
105
|
for t in tracks:
|
|
94
106
|
from lib.in_progress import issue_in_progress, IN_PROGRESS_LABEL
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
key = track_key(t)
|
|
108
|
+
hot = hot_by_track.get(key, set())
|
|
109
|
+
raw = issues_by_track.get(key, [])
|
|
97
110
|
issues = [
|
|
98
111
|
normalize_issue(
|
|
99
112
|
i,
|
|
@@ -158,7 +171,7 @@ def build_export(tracks, issues_by_track, visibility, now: str,
|
|
|
158
171
|
# The track's declared plan/spec doc + its execution badge (#285), or
|
|
159
172
|
# null when the track declares no `plan:`. `{rel, resolved:false}` when
|
|
160
173
|
# the link can't be resolved (no local clone / file absent).
|
|
161
|
-
"plan": plan_by_track.get(
|
|
174
|
+
"plan": plan_by_track.get(key),
|
|
162
175
|
# Effective next_up ranking preset for this track (#326 Phase 2).
|
|
163
176
|
"next_up_preset": next_up_preset_name,
|
|
164
177
|
# True when the track has `next_up_auto: true` set in its frontmatter,
|
|
@@ -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]:
|
|
@@ -190,6 +190,51 @@ def commit_shared_tier(worktree: Path, message: str, paths) -> Optional[str]:
|
|
|
190
190
|
return head.stdout.strip() or None
|
|
191
191
|
|
|
192
192
|
|
|
193
|
+
def _contained_shared_tier(root: Path) -> tuple:
|
|
194
|
+
"""Return ``(<root>/.work-plan, None)`` when the tier is safely contained.
|
|
195
|
+
|
|
196
|
+
The shared-tier directory itself is never allowed to be a symlink, even if
|
|
197
|
+
it currently points back inside ``root``. Refusing the indirection keeps a
|
|
198
|
+
later retarget from changing the write destination after validation.
|
|
199
|
+
``resolve(strict=False)`` also catches symlinked ancestors and proves that
|
|
200
|
+
the resulting directory remains below the resolved repository/worktree.
|
|
201
|
+
"""
|
|
202
|
+
candidate = root / ".work-plan"
|
|
203
|
+
try:
|
|
204
|
+
resolved_root = root.resolve(strict=True)
|
|
205
|
+
if not resolved_root.is_dir():
|
|
206
|
+
return None, f"shared track root is not a directory: {root}"
|
|
207
|
+
if candidate.is_symlink():
|
|
208
|
+
return None, f"unsafe shared track directory is a symlink: {candidate}"
|
|
209
|
+
if candidate.exists() and not candidate.is_dir():
|
|
210
|
+
return None, f"unsafe shared track path is not a directory: {candidate}"
|
|
211
|
+
resolved_candidate = candidate.resolve(strict=False)
|
|
212
|
+
resolved_candidate.relative_to(resolved_root)
|
|
213
|
+
except (OSError, RuntimeError, ValueError):
|
|
214
|
+
return None, f"unsafe shared track directory escapes its root: {candidate}"
|
|
215
|
+
return candidate, None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resolve_shared_tier(entry: dict) -> tuple:
|
|
219
|
+
"""Resolve a repo's shared tier as ``(path, unsafe_reason)``.
|
|
220
|
+
|
|
221
|
+
``unsafe_reason`` is populated only when a candidate root exists but fails
|
|
222
|
+
containment validation. Operational absence (no local path, unavailable
|
|
223
|
+
plan branch) remains ``(None, None)`` so read-only callers retain the
|
|
224
|
+
existing graceful-degradation behaviour.
|
|
225
|
+
"""
|
|
226
|
+
if not entry or not entry.get("local"):
|
|
227
|
+
return None, None
|
|
228
|
+
local_path = Path(entry["local"]).expanduser()
|
|
229
|
+
branch = entry.get("plan_branch")
|
|
230
|
+
if branch:
|
|
231
|
+
worktree = ensure_worktree(local_path, branch)
|
|
232
|
+
if worktree is None:
|
|
233
|
+
return None, None
|
|
234
|
+
return _contained_shared_tier(worktree)
|
|
235
|
+
return _contained_shared_tier(local_path)
|
|
236
|
+
|
|
237
|
+
|
|
193
238
|
def shared_tier_dir(entry: dict) -> Optional[Path]:
|
|
194
239
|
"""The `.work-plan/` directory to read/write for a repo config `entry`.
|
|
195
240
|
|
|
@@ -199,14 +244,8 @@ def shared_tier_dir(entry: dict) -> Optional[Path]:
|
|
|
199
244
|
|
|
200
245
|
Returns None when the entry has no `local` path. Never raises.
|
|
201
246
|
"""
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
local_path = Path(entry["local"]).expanduser()
|
|
205
|
-
branch = entry.get("plan_branch")
|
|
206
|
-
if branch:
|
|
207
|
-
worktree = ensure_worktree(local_path, branch)
|
|
208
|
-
return (worktree / ".work-plan") if worktree else None
|
|
209
|
-
return local_path / ".work-plan"
|
|
247
|
+
path, _unsafe_reason = resolve_shared_tier(entry)
|
|
248
|
+
return path
|
|
210
249
|
|
|
211
250
|
|
|
212
251
|
# ---------------------------------------------------------------------------
|
|
@@ -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()
|
|
@@ -42,6 +42,21 @@ class DiscoverArchivedTest(unittest.TestCase):
|
|
|
42
42
|
by_rel["docs/plans/archive/abandoned/dead.md"].archive_kind, "abandoned")
|
|
43
43
|
self.assertFalse(by_rel["docs/plans/live.md"].archived)
|
|
44
44
|
|
|
45
|
+
def test_include_archived_skips_symlinked_doc_outside_repo(self):
|
|
46
|
+
with tempfile.TemporaryDirectory() as d:
|
|
47
|
+
root = _repo(d)
|
|
48
|
+
victim = Path(d) / "outside.md"
|
|
49
|
+
victim.write_text("# outside\n")
|
|
50
|
+
link = root / "docs/plans/archive/shipped/linked.md"
|
|
51
|
+
link.symlink_to(victim)
|
|
52
|
+
|
|
53
|
+
docs = doc_discovery.discover_docs(root, include_archived=True)
|
|
54
|
+
|
|
55
|
+
self.assertNotIn(
|
|
56
|
+
"docs/plans/archive/shipped/linked.md",
|
|
57
|
+
{doc.rel for doc in docs},
|
|
58
|
+
)
|
|
59
|
+
|
|
45
60
|
|
|
46
61
|
if __name__ == "__main__":
|
|
47
62
|
unittest.main()
|