@stylusnexus/work-plan 2026.6.9-4 → 2026.6.10-1
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 +5 -5
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/SKILL.md +2 -2
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/handoff.py +15 -6
- package/skills/work-plan/commands/hygiene.py +2 -0
- package/skills/work-plan/commands/init.py +13 -3
- package/skills/work-plan/commands/init_repo.py +8 -2
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/new_track.py +7 -0
- package/skills/work-plan/commands/reconcile.py +106 -17
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- package/skills/work-plan/lib/frontmatter.py +12 -3
- package/skills/work-plan/lib/git_state.py +61 -52
- package/skills/work-plan/lib/github_state.py +46 -13
- package/skills/work-plan/lib/prompts.py +46 -4
- package/skills/work-plan/lib/tracks.py +36 -4
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
- package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
- package/skills/work-plan/tests/test_config.py +12 -12
- package/skills/work-plan/tests/test_github_state.py +3 -3
- package/skills/work-plan/tests/test_init_repo.py +12 -7
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_new_track.py +7 -7
- package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
- package/skills/work-plan/tests/test_prompts.py +121 -0
- package/skills/work-plan/tests/test_reconcile_move.py +154 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +19 -0
- package/skills/work-plan/tests/test_repo_filter.py +6 -6
- package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
- package/skills/work-plan/tests/test_set_notes_root.py +6 -2
- package/skills/work-plan/tests/test_track_resolution.py +2 -2
- package/skills/work-plan/tests/test_tracks.py +4 -4
- package/skills/work-plan/work_plan.py +13 -13
- /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ The five essentials you'll use 80% of the time are:
|
|
|
51
51
|
| `/work-plan brief` | Morning. Multi-track snapshot — what's on your plate across every active track. Add `--repo=<key>` to scope to one project. |
|
|
52
52
|
| `/work-plan handoff <track>` | End of a work block. Captures what you touched. Use `--auto-next` for an algorithmic priority-sorted `next_up` (no LLM), `--set-next 1,2,3` for explicit numbers, or pair with Claude in chat for a curated pick. |
|
|
53
53
|
| `/work-plan orient <track>` | Switching context. ~15-line paste-block of priority / last session / next pick / git state — drop into a fresh Claude Code terminal. |
|
|
54
|
-
| `/work-plan reconcile <track> \| --all \| --repo=<key> [--draft]` | Track frontmatter membership drifted from GitHub labels. Use on label-driven tracks only — for hand-curated tracks, use `refresh-md` instead. `--draft` previews proposed ADDs/FLAGs without prompting
|
|
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 three steps: ① `refresh-md --all` (pull live GitHub state into every active track's status table), ② `reconcile --all` (sync frontmatter membership against GitHub labels), ③ `duplicates` (flag likely-duplicate issues). `--repo=<key>` scopes steps ① and ② to one repo; step ③ is skipped in scoped mode. |
|
|
56
56
|
|
|
57
57
|
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:
|
|
@@ -148,8 +148,8 @@ The CLI never auto-pushes. When you create or update a shared track, it prints a
|
|
|
148
148
|
**Multi-repo disambiguation:** if the same track slug exists in two repos, qualify with `@<repo>` or `--repo=<key>`:
|
|
149
149
|
|
|
150
150
|
```bash
|
|
151
|
-
/work-plan slot 4234 auth-flow@
|
|
152
|
-
/work-plan close auth-flow --repo=
|
|
151
|
+
/work-plan slot 4234 auth-flow@myproject
|
|
152
|
+
/work-plan close auth-flow --repo=myproject
|
|
153
153
|
```
|
|
154
154
|
|
|
155
155
|
## Plan & doc liveness (`plan-status`)
|
|
@@ -493,7 +493,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
493
493
|
| `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. |
|
|
494
494
|
| `refresh-md <track>` `\|` `--all` `\|` `--repo=<key>` | Update issue STATE (open/closed, status labels) inside the track body's status table. Does NOT change track membership — this is the right tool for "refresh the work I just completed." `--all` sweeps every active track; `--repo=<key>` scopes the sweep to one repo. |
|
|
495
495
|
| `hygiene [--repo=<key>]` | Weekly all-in-one: `refresh-md` + `reconcile` + `duplicates`. With `--repo=<key>`, steps 1 and 2 scope to that repo and the global `duplicates` step is skipped. |
|
|
496
|
-
| `list [--all]` | List active tracks (or all including parked/archived). |
|
|
496
|
+
| `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. |
|
|
497
497
|
| `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. |
|
|
498
498
|
| `init-repo <key> --github=<slug> [--local=<path>]` | Bootstrap a new repo: create `<notes_root>/<key>/archive/{shipped,abandoned}/` and add the repo block to your config. `--github` is required; `--local` is optional. |
|
|
499
499
|
| `new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]` | One-shot, non-interactive: create a new track file under `notes_root` for `<repo>` (a config key **or** an `org/repo` slug) with frontmatter. Unlike `init`, it makes the file for you — the headless creation path the VS Code viewer uses. |
|
|
@@ -502,7 +502,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
502
502
|
| `group [--milestone=X] [--label=Y] [--repo=Z] [--private] [--apply] [--limit=N]` | AI-cluster GitHub issues into thematic track files. Two-step: CLI prints prompt → you save JSON answer → `--apply` creates the tracks. `--private` routes to `notes_root` instead of `.work-plan/`. `--limit` controls how many issues are shown in the prompt (default 100). |
|
|
503
503
|
| `auto-triage [--repo=<key>] [--apply] [--limit=N]` | AI-assign untracked open issues to existing tracks. Two-step (same pattern as `group`). Run `coverage` first to measure the gap. `--limit` controls how many untracked issues are shown (default 100). |
|
|
504
504
|
| `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
|
|
505
|
-
| `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). `--draft` previews ADDs/FLAGs without prompting or writing. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
|
|
505
|
+
| `reconcile <track>` `\|` `--all` `\|` `--repo=<key> [--draft] [--yes]` | Update track MEMBERSHIP (the `github.issues` list in frontmatter) by syncing against a GitHub label. Read-only on GitHub. Default label is `track/<slug>`; override per-track via `github.labels: [...]` in frontmatter (OR semantics). In an `--all`/`--repo` sweep it also detects **MOVEs** — an issue relabeled from one track to another in the same repo is moved (removed from the old track, added to the new); ambiguous targets stay as FLAGs. `--draft` previews ADDs/MOVEs/FLAGs without prompting or writing. `--yes` applies without prompting (non-interactive, e.g. the VS Code extension); PUBLIC-repo move destinations are skipped under `--yes`. `--repo=<key>` scopes the sweep to one repo. NOT for hand-curated tracks (it'll propose dropping curated issues every run) — use `refresh-md` if you only want to update issue state. When >50% of frontmatter issues lack the label, reconcile prints a hint pointing to `refresh-md`. |
|
|
506
506
|
| `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
|
|
507
507
|
| `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). |
|
|
508
508
|
| `plan-status [--repo=<key>] [--json] [--stamp [--draft]] [--llm [--apply]] [--archive \| --issues] [--draft]` | Reach a verdict on every plan/spec doc in a repo by correlating its declared file-manifest against git + the filesystem: ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less / 🧳 foreign. Read-only by default. `--stamp` writes an idempotent status header into each doc (`--draft` previews); `--llm` runs a two-step AI verdict on prose/ambiguous docs; `--archive` moves dead plans to `archive/abandoned/` and `--issues` opens issues for partial plans (both gated, both honor `--draft`); `--json` for machine output. |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.
|
|
1
|
+
2026.06.10+9dce675
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.
|
|
3
|
+
"version": "2026.6.10-1",
|
|
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"
|
|
@@ -97,8 +97,8 @@ Track files live in one of two places:
|
|
|
97
97
|
|
|
98
98
|
**Disambiguation when the same track slug exists in two repos:**
|
|
99
99
|
```
|
|
100
|
-
/work-plan slot 4234 auth-flow@
|
|
101
|
-
/work-plan close auth-flow --repo=
|
|
100
|
+
/work-plan slot 4234 auth-flow@myproject # @repo qualifier
|
|
101
|
+
/work-plan close auth-flow --repo=myproject # --repo=<key> flag
|
|
102
102
|
```
|
|
103
103
|
|
|
104
104
|
Both forms work on: `slot`, `close`, `handoff`, `canonicalize`, `refresh-md`, `reconcile`, `set`.
|
|
@@ -3,7 +3,10 @@ from datetime import datetime
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
from lib.config import load_config, ConfigError
|
|
6
|
-
from lib.tracks import
|
|
6
|
+
from lib.tracks import (
|
|
7
|
+
discover_tracks, discover_archived_tracks, filter_tracks_by_repo,
|
|
8
|
+
priority_rank, recency_sort_key,
|
|
9
|
+
)
|
|
7
10
|
from lib.github_state import fetch_issues, extract_priority, short_milestone
|
|
8
11
|
from lib.prompts import parse_flags
|
|
9
12
|
from lib.git_state import (
|
|
@@ -193,11 +196,8 @@ def _build_track_block(track, cfg, now: datetime) -> dict:
|
|
|
193
196
|
return gap_seconds_to_label(int(gs))
|
|
194
197
|
|
|
195
198
|
in_prog_rank = 0 if operational_status == "in-progress" else 1
|
|
196
|
-
pri_rank =
|
|
197
|
-
recency_key = (
|
|
198
|
-
-parse_iso_timestamp(meta["last_touched"]).timestamp()
|
|
199
|
-
if meta.get("last_touched") else 0
|
|
200
|
-
)
|
|
199
|
+
pri_rank = priority_rank(meta)
|
|
200
|
+
recency_key = recency_sort_key(meta)
|
|
201
201
|
|
|
202
202
|
return {
|
|
203
203
|
"name": meta.get("track", track.name),
|
|
@@ -19,6 +19,7 @@ from lib.session_log import append_session_log, SESSION_LOG_HEADER
|
|
|
19
19
|
from lib.git_state import (
|
|
20
20
|
has_uncommitted, current_branch, parse_iso_timestamp,
|
|
21
21
|
gap_seconds_to_label, uncommitted_file_count, commits_ahead,
|
|
22
|
+
is_safe_ref, GIT_TIMEOUT,
|
|
22
23
|
)
|
|
23
24
|
from lib.github_state import fetch_issues, state_to_status_label, extract_priority, short_milestone
|
|
24
25
|
from lib.status_table import update_row_status, sync_missing_rows, find_canonical_status_tables, ISSUE_NUM_RE
|
|
@@ -514,12 +515,20 @@ def _recent_commits(track, since_dt) -> list[dict]:
|
|
|
514
515
|
|
|
515
516
|
if branches:
|
|
516
517
|
for b in branches:
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
518
|
+
# A branch name from frontmatter is passed as a positional rev; a
|
|
519
|
+
# dash-led value (e.g. `--output=/path`) would be read by git as an
|
|
520
|
+
# option → arbitrary-file write. Reject before use (#192).
|
|
521
|
+
if not is_safe_ref(str(b)):
|
|
522
|
+
continue
|
|
523
|
+
try:
|
|
524
|
+
proc = subprocess.run(
|
|
525
|
+
["git", "-C", str(track.local_path), "log", b,
|
|
526
|
+
f"--since={since_iso}",
|
|
527
|
+
"--pretty=format:%H|%s|%cI"],
|
|
528
|
+
capture_output=True, text=True, timeout=GIT_TIMEOUT,
|
|
529
|
+
)
|
|
530
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
531
|
+
continue
|
|
523
532
|
if proc.returncode != 0 or not proc.stdout.strip():
|
|
524
533
|
continue
|
|
525
534
|
for line in proc.stdout.strip().split("\n"):
|
|
@@ -78,6 +78,8 @@ def run(args: list[str]) -> int:
|
|
|
78
78
|
print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}")
|
|
79
79
|
print("=" * 60)
|
|
80
80
|
reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
|
|
81
|
+
if yes:
|
|
82
|
+
reconcile_args.append("--yes")
|
|
81
83
|
rc = reconcile.run(reconcile_args)
|
|
82
84
|
if rc != 0:
|
|
83
85
|
print(f"\n⚠ reconcile exited with code {rc}; continuing.")
|
|
@@ -67,12 +67,22 @@ def run(args: list[str]) -> int:
|
|
|
67
67
|
return 1
|
|
68
68
|
folder = None
|
|
69
69
|
else:
|
|
70
|
-
|
|
70
|
+
# Containment guard (#195): a non-shared target MUST live under
|
|
71
|
+
# notes_root. Without this, `init /etc/anything` (any user-writable
|
|
72
|
+
# file with no frontmatter) would get frontmatter prepended via
|
|
73
|
+
# write_file, clobbering it. `path` is already resolved; resolve
|
|
74
|
+
# notes_root too so the comparison is symlink/relative-safe.
|
|
75
|
+
notes_root = Path(cfg["notes_root"]).expanduser().resolve()
|
|
71
76
|
try:
|
|
72
77
|
rel = path.relative_to(notes_root)
|
|
73
|
-
folder = rel.parts[0] if len(rel.parts) > 1 else None
|
|
74
78
|
except ValueError:
|
|
75
|
-
|
|
79
|
+
print(
|
|
80
|
+
f"ERROR: {path} is not inside notes_root ({notes_root}) or a"
|
|
81
|
+
" registered .work-plan/ directory — refusing to write"
|
|
82
|
+
" frontmatter outside the tracked tree."
|
|
83
|
+
)
|
|
84
|
+
return 1
|
|
85
|
+
folder = rel.parts[0] if len(rel.parts) > 1 else None
|
|
76
86
|
repo = resolve_github_for_folder(folder, cfg) if folder else None
|
|
77
87
|
|
|
78
88
|
issue_nums = sorted(set(int(m) for m in re.findall(r"#(\d+)", body)))
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Non-interactive: --github is required; --local is optional (no prompts).
|
|
4
4
|
"""
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
7
8
|
import subprocess
|
|
8
9
|
from pathlib import Path
|
|
@@ -113,11 +114,16 @@ def run(args: list[str]) -> int:
|
|
|
113
114
|
if local:
|
|
114
115
|
repo_block["local"] = local
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
# `key` is validated against ^[a-z][a-z0-9-]*$ above, so it's safe in the yq
|
|
118
|
+
# path. The repo block is passed as an OPAQUE env value via env() (parsed as
|
|
119
|
+
# YAML/JSON) rather than interpolated into the expression — uniform with the
|
|
120
|
+
# strenv() hardening in set-notes-root (#196).
|
|
121
|
+
env = {**os.environ, "WP_REPO_BLOCK": json.dumps(repo_block)}
|
|
122
|
+
yq_expr = f".repos.{key} = env(WP_REPO_BLOCK)"
|
|
117
123
|
try:
|
|
118
124
|
subprocess.run(
|
|
119
125
|
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
120
|
-
check=True, capture_output=True, text=True,
|
|
126
|
+
check=True, capture_output=True, text=True, env=env,
|
|
121
127
|
)
|
|
122
128
|
except subprocess.CalledProcessError as e:
|
|
123
129
|
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
"""list subcommand."""
|
|
2
2
|
from lib.config import load_config, ConfigError
|
|
3
|
-
from lib.tracks import
|
|
3
|
+
from lib.tracks import (
|
|
4
|
+
discover_tracks, discover_archived_tracks,
|
|
5
|
+
priority_rank, recency_sort_key,
|
|
6
|
+
)
|
|
7
|
+
from lib.prompts import parse_flags
|
|
8
|
+
|
|
9
|
+
_VALID_SORTS = ("recent", "priority")
|
|
4
10
|
|
|
5
11
|
|
|
6
12
|
def run(args: list[str]) -> int:
|
|
7
|
-
|
|
13
|
+
flags, _ = parse_flags(args, {"--all", "--sort"})
|
|
14
|
+
show_all = "--all" in flags
|
|
15
|
+
sort_mode = flags.get("--sort")
|
|
16
|
+
if sort_mode is True or (sort_mode and sort_mode not in _VALID_SORTS):
|
|
17
|
+
print(f"usage: work_plan.py list [--all] [--sort={'|'.join(_VALID_SORTS)}]")
|
|
18
|
+
return 2
|
|
19
|
+
|
|
8
20
|
try:
|
|
9
21
|
cfg = load_config()
|
|
10
22
|
except ConfigError as e:
|
|
@@ -16,17 +28,19 @@ def run(args: list[str]) -> int:
|
|
|
16
28
|
print(f"No tracks found under {cfg['notes_root']}")
|
|
17
29
|
return 0
|
|
18
30
|
|
|
31
|
+
tracks = _sort_tracks(tracks, sort_mode)
|
|
32
|
+
|
|
19
33
|
print(f"Tracks under {cfg['notes_root']}:\n")
|
|
20
34
|
for t in tracks:
|
|
21
35
|
status = t.meta.get("status", "(no frontmatter)")
|
|
22
36
|
priority = t.meta.get("launch_priority", "—")
|
|
23
37
|
repo = t.repo or "(no repo)"
|
|
24
|
-
|
|
38
|
+
flags_out = []
|
|
25
39
|
if t.needs_init:
|
|
26
|
-
|
|
40
|
+
flags_out.append("NEEDS INIT")
|
|
27
41
|
if t.needs_filing:
|
|
28
|
-
|
|
29
|
-
flag_str = f" [{', '.join(
|
|
42
|
+
flags_out.append("NEEDS FILING")
|
|
43
|
+
flag_str = f" [{', '.join(flags_out)}]" if flags_out else ""
|
|
30
44
|
print(f" {t.name:30} {status:14} {priority:3} {repo}{flag_str}")
|
|
31
45
|
|
|
32
46
|
if show_all:
|
|
@@ -37,3 +51,17 @@ def run(args: list[str]) -> int:
|
|
|
37
51
|
end_state = a.meta.get("status", "?")
|
|
38
52
|
print(f" {a.name:30} {end_state:14} {a.repo or '(no repo)'}")
|
|
39
53
|
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _sort_tracks(tracks: list, sort_mode):
|
|
57
|
+
"""Order active tracks per --sort. None preserves discovery order.
|
|
58
|
+
|
|
59
|
+
- "recent": by last_touched descending (missing last_touched sorts last).
|
|
60
|
+
- "priority": by launch_priority ascending (P0→P3, then missing/other),
|
|
61
|
+
with last_touched recency as tiebreaker.
|
|
62
|
+
"""
|
|
63
|
+
if sort_mode == "recent":
|
|
64
|
+
return sorted(tracks, key=lambda t: recency_sort_key(t.meta))
|
|
65
|
+
if sort_mode == "priority":
|
|
66
|
+
return sorted(tracks, key=lambda t: (priority_rank(t.meta), recency_sort_key(t.meta)))
|
|
67
|
+
return tracks
|
|
@@ -103,6 +103,13 @@ def run(args: list[str]) -> int:
|
|
|
103
103
|
elif "/" in repo_arg:
|
|
104
104
|
github = repo_arg
|
|
105
105
|
folder = repo_arg.rsplit("/", 1)[-1]
|
|
106
|
+
# Validate the derived folder segment (#195). `rsplit` caps traversal at
|
|
107
|
+
# one segment, but a slug like `x/..` yields folder=".." → the track
|
|
108
|
+
# would be written one level ABOVE notes_root. A real GitHub repo name
|
|
109
|
+
# matches [A-Za-z0-9._-]+ and is never "." / ".." — reject anything else.
|
|
110
|
+
if folder in ("", ".", "..") or not re.fullmatch(r"[A-Za-z0-9._-]+", folder):
|
|
111
|
+
print(f"ERROR: cannot derive a safe notes folder from '{repo_arg}'.")
|
|
112
|
+
return 2
|
|
106
113
|
else:
|
|
107
114
|
print(
|
|
108
115
|
f"ERROR: unknown repo '{repo_arg}' — pass a configured key"
|
|
@@ -12,8 +12,11 @@ For a given track:
|
|
|
12
12
|
of "anything closed looks unlabeled."
|
|
13
13
|
- Compare against frontmatter `github.issues`.
|
|
14
14
|
- Propose ADDS (labeled in GitHub but missing from frontmatter).
|
|
15
|
-
- Propose
|
|
16
|
-
|
|
15
|
+
- Propose MOVES (in track A's frontmatter, but now labeled for exactly one
|
|
16
|
+
other active track B in the same repo — a relabel; remove from A, add to B).
|
|
17
|
+
- Propose FLAGS (in frontmatter but no longer labeled, with no single move
|
|
18
|
+
target — possible orphan).
|
|
19
|
+
- User confirms before writing to the LOCAL frontmatter file(s).
|
|
17
20
|
|
|
18
21
|
READ-ONLY GITHUB CONTRACT
|
|
19
22
|
reconcile only READS GitHub via `gh issue list` and `gh pr list`. It NEVER
|
|
@@ -32,6 +35,7 @@ from lib.config import load_config, ConfigError
|
|
|
32
35
|
from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
|
|
33
36
|
from lib.frontmatter import write_file
|
|
34
37
|
from lib.prompts import parse_flags, prompt_input
|
|
38
|
+
from lib.write_guard import needs_confirm
|
|
35
39
|
|
|
36
40
|
|
|
37
41
|
PER_TRACK_TIMEOUT = 15 # seconds; each gh call gets this budget
|
|
@@ -86,17 +90,18 @@ def _fetch_labeled_issues(repo: str, labels: list[str]) -> list[dict]:
|
|
|
86
90
|
|
|
87
91
|
|
|
88
92
|
def run(args: list[str]) -> int:
|
|
89
|
-
flags, positional = parse_flags(args, {"--all", "--draft", "--repo"})
|
|
93
|
+
flags, positional = parse_flags(args, {"--all", "--draft", "--repo", "--yes"})
|
|
90
94
|
do_all = flags.get("--all", False)
|
|
91
95
|
draft = flags.get("--draft", False)
|
|
96
|
+
yes = flags.get("--yes", False)
|
|
92
97
|
repo_key = flags.get("--repo")
|
|
93
98
|
if repo_key is True:
|
|
94
|
-
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
|
|
99
|
+
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
|
|
95
100
|
return 2
|
|
96
101
|
track_arg = positional[0] if positional else None
|
|
97
102
|
|
|
98
103
|
if not do_all and not track_arg and not repo_key:
|
|
99
|
-
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft]")
|
|
104
|
+
print("usage: work_plan.py reconcile <track-name> | --all | --repo=<key> [--draft] [--yes]")
|
|
100
105
|
return 2
|
|
101
106
|
|
|
102
107
|
track_name = track_arg
|
|
@@ -167,7 +172,52 @@ def run(args: list[str]) -> int:
|
|
|
167
172
|
print(f" [{i}/{total}] ⚠ {track.name}: {e} — skipping")
|
|
168
173
|
results[track.name] = None
|
|
169
174
|
|
|
170
|
-
# Phase
|
|
175
|
+
# Phase 2a: index which fetched track(s) label each issue. Used to turn a
|
|
176
|
+
# bare FLAG (in a track's frontmatter, but it has lost that track's label)
|
|
177
|
+
# into a MOVE when the issue is now labeled for exactly one OTHER active
|
|
178
|
+
# track in the same repo.
|
|
179
|
+
labeled_index: dict = {} # issue number -> list[track]
|
|
180
|
+
for track in targets:
|
|
181
|
+
if not track.repo or results.get(track.name) is None:
|
|
182
|
+
continue
|
|
183
|
+
for num in {i["number"] for i in results[track.name]}:
|
|
184
|
+
labeled_index.setdefault(num, []).append(track)
|
|
185
|
+
|
|
186
|
+
# Phase 2b: detect cross-track moves (#163). An issue qualifies when it is
|
|
187
|
+
# in track A's frontmatter, no longer carries A's label, and is now labeled
|
|
188
|
+
# by exactly one OTHER active track B in the same repo. Ambiguous cases
|
|
189
|
+
# (two or more candidate targets) stay as plain FLAGs.
|
|
190
|
+
moved_out: dict = {} # src track name -> set(num)
|
|
191
|
+
moved_in: dict = {} # dst track name -> set(num)
|
|
192
|
+
move_dst: dict = {} # (src track name, num) -> dst track
|
|
193
|
+
for track in targets:
|
|
194
|
+
if not track.repo or results.get(track.name) is None:
|
|
195
|
+
continue
|
|
196
|
+
labeled_nums = {i["number"] for i in results[track.name]}
|
|
197
|
+
listed_nums = set(track.meta.get("github", {}).get("issues") or [])
|
|
198
|
+
for num in sorted(listed_nums - labeled_nums):
|
|
199
|
+
cands = [b for b in labeled_index.get(num, [])
|
|
200
|
+
if b is not track and b.repo == track.repo]
|
|
201
|
+
if len(cands) == 1:
|
|
202
|
+
dst = cands[0]
|
|
203
|
+
moved_out.setdefault(track.name, set()).add(num)
|
|
204
|
+
moved_in.setdefault(dst.name, set()).add(num)
|
|
205
|
+
move_dst[(track.name, num)] = dst
|
|
206
|
+
|
|
207
|
+
# Phase 2c: per-track diff, report, confirm. Membership changes accumulate
|
|
208
|
+
# in `final` (track name -> desired issue set); each affected track is
|
|
209
|
+
# written exactly ONCE at the end, so a move that touches two tracks never
|
|
210
|
+
# double-writes or clobbers a sibling's accepted ADDs. A move is governed by
|
|
211
|
+
# the confirmation on its SOURCE track (where the issue currently lives).
|
|
212
|
+
final: dict = {} # track name -> set(num)
|
|
213
|
+
affected: dict = {} # track name -> track (only those we may write)
|
|
214
|
+
|
|
215
|
+
def _final_for(t):
|
|
216
|
+
if t.name not in final:
|
|
217
|
+
final[t.name] = set(t.meta.get("github", {}).get("issues") or [])
|
|
218
|
+
affected[t.name] = t
|
|
219
|
+
return final[t.name]
|
|
220
|
+
|
|
171
221
|
any_changes = False
|
|
172
222
|
for track in targets:
|
|
173
223
|
slug = track.meta.get("track", track.name)
|
|
@@ -181,11 +231,14 @@ def run(args: list[str]) -> int:
|
|
|
181
231
|
labels = _resolve_labels(track)
|
|
182
232
|
labeled_nums = {i["number"] for i in labeled}
|
|
183
233
|
listed_nums = set(track.meta.get("github", {}).get("issues") or [])
|
|
234
|
+
out_moves = sorted(moved_out.get(track.name, set()))
|
|
184
235
|
|
|
185
|
-
|
|
186
|
-
|
|
236
|
+
# MOVE issues are reported (and applied) as moves, not as ADD on the
|
|
237
|
+
# destination or FLAG on the source.
|
|
238
|
+
adds = sorted(labeled_nums - listed_nums - moved_in.get(track.name, set()))
|
|
239
|
+
flag_nums = sorted(listed_nums - labeled_nums - moved_out.get(track.name, set()))
|
|
187
240
|
|
|
188
|
-
if not adds and not flag_nums:
|
|
241
|
+
if not adds and not flag_nums and not out_moves:
|
|
189
242
|
continue
|
|
190
243
|
|
|
191
244
|
any_changes = True
|
|
@@ -197,6 +250,13 @@ def run(args: list[str]) -> int:
|
|
|
197
250
|
for num in adds:
|
|
198
251
|
i = issue_lookup[num]
|
|
199
252
|
print(f" #{num} ({i['state'].lower()}) {i['title']}")
|
|
253
|
+
if out_moves:
|
|
254
|
+
print(f" MOVE ({len(out_moves)}) — relabeled to another track in this repo:")
|
|
255
|
+
for num in out_moves:
|
|
256
|
+
dst = move_dst[(track.name, num)]
|
|
257
|
+
dst_slug = dst.meta.get("track", dst.name)
|
|
258
|
+
pub = " [dst PUBLIC]" if needs_confirm(dst.repo, cfg) else ""
|
|
259
|
+
print(f" #{num} {slug} → {dst_slug}{pub}")
|
|
200
260
|
if flag_nums:
|
|
201
261
|
print(f" FLAG ({len(flag_nums)}) — in frontmatter but missing every configured label:")
|
|
202
262
|
for num in flag_nums:
|
|
@@ -204,8 +264,8 @@ def run(args: list[str]) -> int:
|
|
|
204
264
|
|
|
205
265
|
if listed_nums and len(flag_nums) / len(listed_nums) > 0.5:
|
|
206
266
|
print(f"\n ⓘ {len(flag_nums)}/{len(listed_nums)} frontmatter issues lack the configured label(s).")
|
|
207
|
-
print(
|
|
208
|
-
print(
|
|
267
|
+
print(" This track looks hand-curated, not label-driven — reconcile may not be the right tool.")
|
|
268
|
+
print(" If you just want to update issue state in the body table, try:")
|
|
209
269
|
print(f" /work-plan refresh-md {slug}")
|
|
210
270
|
|
|
211
271
|
if draft:
|
|
@@ -213,12 +273,41 @@ def run(args: list[str]) -> int:
|
|
|
213
273
|
# Useful for sweep audits and scripted reports.
|
|
214
274
|
continue
|
|
215
275
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
276
|
+
if yes:
|
|
277
|
+
# Non-interactive: apply ADDs + MOVEs without prompting. All writes
|
|
278
|
+
# are local frontmatter — the read-only-GitHub contract is unchanged.
|
|
279
|
+
print(f"\n --yes: applying changes from {track.path.name}")
|
|
280
|
+
choice = "y"
|
|
281
|
+
else:
|
|
282
|
+
choice = prompt_input(f"\n Apply ADDs/MOVEs from {track.path.name}? [y/N]").lower()
|
|
283
|
+
if choice != "y":
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
if adds:
|
|
287
|
+
_final_for(track).update(adds)
|
|
288
|
+
for num in out_moves:
|
|
289
|
+
dst = move_dst[(track.name, num)]
|
|
290
|
+
# Public-repo guard (#163): under --yes we never silently write
|
|
291
|
+
# membership into a PUBLIC/shared destination track — that move is
|
|
292
|
+
# skipped with a pointer to the gated `move` verb. Interactive runs
|
|
293
|
+
# treat the prompt above as the confirmation.
|
|
294
|
+
if yes and needs_confirm(dst.repo, cfg):
|
|
295
|
+
dst_slug = dst.meta.get("track", dst.name)
|
|
296
|
+
print(f" ⏭ skipped MOVE #{num} → {dst_slug} ({dst.repo} is PUBLIC; "
|
|
297
|
+
f"run `/work-plan move {num} {slug} {dst_slug} --confirm` instead)")
|
|
298
|
+
continue
|
|
299
|
+
_final_for(track).discard(num)
|
|
300
|
+
_final_for(dst).add(num)
|
|
301
|
+
|
|
302
|
+
# Write each affected track exactly once, only if its set actually changed.
|
|
303
|
+
for name, issues in final.items():
|
|
304
|
+
track = affected[name]
|
|
305
|
+
original = set(track.meta.get("github", {}).get("issues") or [])
|
|
306
|
+
if issues == original:
|
|
307
|
+
continue
|
|
308
|
+
track.meta.setdefault("github", {})["issues"] = sorted(issues)
|
|
309
|
+
write_file(track.path, track.meta, track.body)
|
|
310
|
+
print(f" ✓ Updated {track.path.name}")
|
|
222
311
|
|
|
223
312
|
if not any_changes:
|
|
224
313
|
print("All tracks in sync with configured labels.")
|
|
@@ -5,6 +5,7 @@ folder. Config writes stay in the CLI (the engine), not the extension.
|
|
|
5
5
|
|
|
6
6
|
Usage: set-notes-root <path>
|
|
7
7
|
"""
|
|
8
|
+
import os
|
|
8
9
|
import subprocess
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
@@ -38,12 +39,15 @@ def run(args: list[str]) -> int:
|
|
|
38
39
|
# Ensure the target directory exists
|
|
39
40
|
new_root.mkdir(parents=True, exist_ok=True)
|
|
40
41
|
|
|
41
|
-
# Write the new notes_root into config via yq (mikefarah/yq)
|
|
42
|
-
|
|
42
|
+
# Write the new notes_root into config via yq (mikefarah/yq). The path is
|
|
43
|
+
# passed as an OPAQUE env value via strenv() — never interpolated into the
|
|
44
|
+
# yq expression — so a path containing `"` or yq operators cannot break out
|
|
45
|
+
# of the string literal and rewrite arbitrary config keys (#191).
|
|
46
|
+
env = {**os.environ, "WP_NEW_ROOT": str(new_root)}
|
|
43
47
|
try:
|
|
44
48
|
subprocess.run(
|
|
45
|
-
["yq", "-i",
|
|
46
|
-
check=True, capture_output=True, text=True,
|
|
49
|
+
["yq", "-i", ".notes_root = strenv(WP_NEW_ROOT)", str(DEFAULT_CONFIG_PATH)],
|
|
50
|
+
check=True, capture_output=True, text=True, env=env,
|
|
47
51
|
)
|
|
48
52
|
except subprocess.CalledProcessError as e:
|
|
49
53
|
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
@@ -113,8 +113,18 @@ def _apply(cfg: dict) -> int:
|
|
|
113
113
|
|
|
114
114
|
print(f"Applying {len(answers)} priority labels to {repo}...")
|
|
115
115
|
for ans in answers:
|
|
116
|
-
|
|
117
|
-
|
|
116
|
+
# The answers file is model-written; coerce the issue number to int and
|
|
117
|
+
# skip malformed entries so a non-numeric value can't reach `gh` argv
|
|
118
|
+
# (and a malformed file can't crash the apply). (#196)
|
|
119
|
+
if not isinstance(ans, dict):
|
|
120
|
+
print(f" SKIP: answer is not an object: {ans!r}")
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
num = int(ans["number"])
|
|
124
|
+
except (KeyError, TypeError, ValueError):
|
|
125
|
+
print(f" SKIP: answer missing a numeric 'number': {ans!r}")
|
|
126
|
+
continue
|
|
127
|
+
priority = ans.get("priority")
|
|
118
128
|
if priority not in ("P0", "P1", "P2", "P3"):
|
|
119
129
|
print(f" SKIP #{num}: invalid priority '{priority}'")
|
|
120
130
|
continue
|
|
@@ -21,12 +21,21 @@ def parse_file(path: Path) -> Tuple[dict, str]:
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def write_file(path: Path, meta: dict, body: str) -> None:
|
|
24
|
-
"""Write markdown with frontmatter. Empty meta = body only.
|
|
24
|
+
"""Write markdown with frontmatter. Empty meta = body only.
|
|
25
|
+
|
|
26
|
+
Refuses to write through a symlink (#195): a track file that is a symlink to
|
|
27
|
+
a target outside the notes tree would otherwise let a write land on an
|
|
28
|
+
arbitrary file. Track files are never legitimately symlinks, so this rejects
|
|
29
|
+
nothing valid; raises ValueError if one is encountered.
|
|
30
|
+
"""
|
|
31
|
+
p = Path(path)
|
|
32
|
+
if p.is_symlink():
|
|
33
|
+
raise ValueError(f"refusing to write through symlink: {p}")
|
|
25
34
|
if not meta:
|
|
26
|
-
|
|
35
|
+
p.write_text(body, encoding="utf-8")
|
|
27
36
|
return
|
|
28
37
|
yaml_text = _dict_to_yaml(meta)
|
|
29
|
-
|
|
38
|
+
p.write_text(f"---\n{yaml_text}---\n{body}", encoding="utf-8")
|
|
30
39
|
|
|
31
40
|
|
|
32
41
|
def _yaml_to_dict(yaml_text: str) -> dict:
|