@stylusnexus/work-plan 2026.6.10-1 → 2026.6.10-2
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 +3 -2
- package/VERSION +1 -1
- package/package.json +1 -1
- package/skills/work-plan/commands/canonicalize.py +7 -92
- package/skills/work-plan/commands/refresh_md.py +106 -37
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/tests/test_refresh_md.py +159 -61
- package/skills/work-plan/tests/test_rename_track.py +351 -0
- package/skills/work-plan/tests/test_status_table.py +61 -0
- package/skills/work-plan/work_plan.py +7 -2
package/README.md
CHANGED
|
@@ -491,12 +491,13 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
491
491
|
| `orient [track]` (alias: `where-was-i`) | Read-only paste block. With a track name: ~15-line track summary (priority, last session, next pick, git state). With no track: cwd snapshot (branch, recent commits, modified files) for non-track work. Add `--pick` for the interactive track picker. |
|
|
492
492
|
| `slot <issue-num> [track]` | A new GitHub issue should belong to a track — adds it to the track's `github.issues` list. Non-interactive flags: `--move`/`--no-move` (relocate the issue off its prior track, or leave it; default no-move), `--confirm=<token>` (public-repo gate, see below). |
|
|
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
|
-
| `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. |
|
|
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." 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. `--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
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. |
|
|
500
|
+
| `rename-track <old-slug \| old@repo> <new-slug> [--repo=<key>] [--fix-refs] [--commit]` | Rename an active track's slug: moves its `.md` file and updates the frontmatter `track` field + `last_touched`. Validates `<new-slug>` like `new-track` and rejects a name already taken in the same repo/tier. For shared tracks, `--commit` stages + commits the move (otherwise it prints a "commit to share" hint). `--fix-refs` rewrites sibling tracks' `depends_on` that reference the old slug; without it they're just warned about. Archived tracks are immutable. Public-repo gated. |
|
|
500
501
|
| `set-notes-root <path>` | Relocate where your private track notes live (updates `notes_root` in config). Does not move existing tracks — it warns if any would be orphaned. |
|
|
501
502
|
| `suggest-priorities --repo=<key>` | Two-step AI label backfill: CLI fetches unlabeled issues, Claude proposes priorities, `--apply` writes labels via `gh`. |
|
|
502
503
|
| `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). |
|
|
@@ -504,7 +505,7 @@ See `docs/usage-examples.md` for end-to-end scenarios (morning brief, mid-work h
|
|
|
504
505
|
| `coverage [--repo=<key>] [--list] [--limit=N]` | Report how many open issues are not in any track. `--list` prints titles. Read-only. |
|
|
505
506
|
| `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
507
|
| `duplicates [--repo=<key>]` | Find likely-duplicate issues by title similarity (stdlib `difflib`). Prints `gh issue close` consolidation commands. |
|
|
507
|
-
| `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). |
|
|
508
|
+
| `canonicalize <track>` | Add a canonical issue table to a track file (so `refresh-md` knows where to update). The table carries a `Milestone` column and is ordered active-milestone-first — issues in the track's `milestone_alignment` milestone, then other milestones grouped (blank divider row between groups), then no-milestone last — so "what's next" sits above "someday" (#101). It's one table (not per-milestone sub-tables) so `refresh-md` re-derives it cleanly. |
|
|
508
509
|
| `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. |
|
|
509
510
|
|
|
510
511
|
Run `python3 ~/.claude/skills/work-plan/work_plan.py --help` for the full list with examples.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026.06.10+
|
|
1
|
+
2026.06.10+a3d10bf
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stylusnexus/work-plan",
|
|
3
|
-
"version": "2026.6.10-
|
|
3
|
+
"version": "2026.6.10-2",
|
|
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"
|
|
@@ -8,9 +8,11 @@ Use --all to canonicalize every active track that doesn't yet have one.
|
|
|
8
8
|
"""
|
|
9
9
|
from lib.config import load_config, ConfigError
|
|
10
10
|
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
11
|
-
from lib.github_state import fetch_issues
|
|
11
|
+
from lib.github_state import fetch_issues
|
|
12
12
|
from lib.frontmatter import write_file
|
|
13
|
-
from lib.status_table import
|
|
13
|
+
from lib.status_table import (
|
|
14
|
+
find_canonical_status_tables, render_canonical_table, insert_canonical_block,
|
|
15
|
+
)
|
|
14
16
|
from lib.prompts import parse_flags
|
|
15
17
|
|
|
16
18
|
|
|
@@ -75,10 +77,11 @@ def run(args: list[str]) -> int:
|
|
|
75
77
|
issues = fetch_issues(track.repo, issue_nums)
|
|
76
78
|
issues_by_num = {i["number"]: i for i in issues}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
table_md = render_canonical_table(
|
|
81
|
+
issue_nums, issues_by_num,
|
|
80
82
|
milestone_alignment=track.meta.get("milestone_alignment"),
|
|
81
83
|
)
|
|
84
|
+
new_body = insert_canonical_block(track.body, table_md, replace=force)
|
|
82
85
|
write_file(track.path, track.meta, new_body)
|
|
83
86
|
print(f" ✓ {track.name}: canonical table added/refreshed ({len(issue_nums)} issues)")
|
|
84
87
|
any_changes = True
|
|
@@ -86,91 +89,3 @@ def run(args: list[str]) -> int:
|
|
|
86
89
|
if not any_changes:
|
|
87
90
|
print("Nothing to do.")
|
|
88
91
|
return 0
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _insert_canonical_table(body: str, issue_nums: list[int],
|
|
92
|
-
issues_by_num: dict, replace: bool = False,
|
|
93
|
-
milestone_alignment=None) -> str:
|
|
94
|
-
"""Insert (or replace) a canonical table at the top of the body."""
|
|
95
|
-
table_md = _render_canonical_table(issue_nums, issues_by_num, milestone_alignment)
|
|
96
|
-
|
|
97
|
-
if replace:
|
|
98
|
-
# Strip existing canonical block (marker + heading + table + separator)
|
|
99
|
-
body = _strip_existing_canonical(body)
|
|
100
|
-
|
|
101
|
-
# Prepend table after any leading whitespace
|
|
102
|
-
body_stripped = body.lstrip("\n")
|
|
103
|
-
leading_whitespace = body[: len(body) - len(body_stripped)]
|
|
104
|
-
return leading_whitespace + table_md + "\n---\n\n" + body_stripped
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _render_canonical_table(issue_nums: list[int], issues_by_num: dict,
|
|
108
|
-
milestone_alignment=None) -> str:
|
|
109
|
-
lines = [
|
|
110
|
-
"## Issues (canonical)",
|
|
111
|
-
"",
|
|
112
|
-
f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
|
|
113
|
-
"",
|
|
114
|
-
]
|
|
115
|
-
|
|
116
|
-
# Build a normalized issue list with compact milestone strings.
|
|
117
|
-
from lib.github_state import short_milestone
|
|
118
|
-
norm_issues = []
|
|
119
|
-
for num in sorted(issue_nums):
|
|
120
|
-
gh = issues_by_num.get(num, {})
|
|
121
|
-
ms = short_milestone(gh.get("milestone")) or None
|
|
122
|
-
norm_issues.append({"number": num, "milestone": ms, "_gh": gh})
|
|
123
|
-
|
|
124
|
-
from lib.export_model import group_issues_by_milestone
|
|
125
|
-
groups = group_issues_by_milestone(norm_issues, milestone_alignment)
|
|
126
|
-
|
|
127
|
-
if len(groups) <= 1:
|
|
128
|
-
# Single milestone group (or all null) — render flat, same as before.
|
|
129
|
-
lines.append("| # | Title | Assignee | Status |")
|
|
130
|
-
lines.append("|---|---|---|---|")
|
|
131
|
-
for num in sorted(issue_nums):
|
|
132
|
-
i = issues_by_num.get(num, {})
|
|
133
|
-
lines.append(render_issue_row(
|
|
134
|
-
num, i.get("title", "(not fetched)"),
|
|
135
|
-
format_assignees(i), state_to_status_label(i.get("state")),
|
|
136
|
-
))
|
|
137
|
-
lines.append("")
|
|
138
|
-
return "\n".join(lines)
|
|
139
|
-
|
|
140
|
-
# Multiple milestone groups — render with section headings.
|
|
141
|
-
for label, issues in groups:
|
|
142
|
-
if label:
|
|
143
|
-
heading = f"{label} ({len(issues)})"
|
|
144
|
-
else:
|
|
145
|
-
heading = f"No milestone ({len(issues)})"
|
|
146
|
-
lines.append(f"### {heading}")
|
|
147
|
-
lines.append("")
|
|
148
|
-
lines.append("| # | Title | Assignee | Status |")
|
|
149
|
-
lines.append("|---|---|---|---|")
|
|
150
|
-
for norm in issues:
|
|
151
|
-
num = norm["number"]
|
|
152
|
-
i = norm["_gh"]
|
|
153
|
-
lines.append(render_issue_row(
|
|
154
|
-
num, i.get("title", "(not fetched)"),
|
|
155
|
-
format_assignees(i), state_to_status_label(i.get("state")),
|
|
156
|
-
))
|
|
157
|
-
lines.append("")
|
|
158
|
-
return "\n".join(lines)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _strip_existing_canonical(body: str) -> str:
|
|
162
|
-
"""Remove an existing canonical-table block from the top of the body."""
|
|
163
|
-
if CANONICAL_MARKER not in body:
|
|
164
|
-
return body
|
|
165
|
-
# Find the start of the heading "## Issues (canonical)" if present, else the marker
|
|
166
|
-
heading_idx = body.find("## Issues (canonical)")
|
|
167
|
-
marker_idx = body.find(CANONICAL_MARKER)
|
|
168
|
-
start = heading_idx if 0 <= heading_idx < marker_idx else marker_idx
|
|
169
|
-
# Find end: the next "---\n" separator after the marker
|
|
170
|
-
sep_idx = body.find("\n---\n", marker_idx)
|
|
171
|
-
if sep_idx == -1:
|
|
172
|
-
# No separator — strip just the marker line
|
|
173
|
-
end = body.find("\n", marker_idx) + 1
|
|
174
|
-
else:
|
|
175
|
-
end = sep_idx + len("\n---\n")
|
|
176
|
-
return body[:start] + body[end:].lstrip("\n")
|
|
@@ -3,7 +3,10 @@ from lib.config import load_config, ConfigError
|
|
|
3
3
|
from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo, parse_track_repo_arg, AmbiguousTrackError
|
|
4
4
|
from lib.github_state import fetch_issues, state_to_status_label
|
|
5
5
|
from lib.frontmatter import write_file
|
|
6
|
-
from lib.status_table import
|
|
6
|
+
from lib.status_table import (
|
|
7
|
+
find_all_status_tables, find_canonical_status_tables, sync_missing_rows,
|
|
8
|
+
render_canonical_table, insert_canonical_block, ISSUE_NUM_RE,
|
|
9
|
+
)
|
|
7
10
|
from lib.prompts import prompt_yes_no, parse_flags
|
|
8
11
|
|
|
9
12
|
|
|
@@ -81,7 +84,7 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
81
84
|
|
|
82
85
|
# Frontmatter is canonical for membership: issues listed there but
|
|
83
86
|
# missing from the table need a fresh row (issue #77). Fetch the union
|
|
84
|
-
# so
|
|
87
|
+
# so rows carry live title/assignee/status too.
|
|
85
88
|
frontmatter_nums = track.meta.get("github", {}).get("issues") or []
|
|
86
89
|
fetch_nums = sorted(all_issue_nums | set(frontmatter_nums))
|
|
87
90
|
if not fetch_nums:
|
|
@@ -91,55 +94,121 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
91
94
|
issues_by_num = {i["number"]: i for i in issues}
|
|
92
95
|
state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
|
|
93
96
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
continue
|
|
108
|
-
current = row["cells"][sidx].strip()
|
|
109
|
-
if current == new_status.strip():
|
|
110
|
-
continue
|
|
111
|
-
new_label = new_status.strip().split(" ", 1)[-1].lower()
|
|
112
|
-
if new_label and new_label in current.lower():
|
|
113
|
-
continue
|
|
114
|
-
new_cells = list(row["cells"])
|
|
115
|
-
new_cells[sidx] = " " + new_status + " "
|
|
116
|
-
lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
|
|
117
|
-
cell_updates += 1
|
|
118
|
-
|
|
119
|
-
new_body = "\n".join(lines)
|
|
120
|
-
# Slot in rows for frontmatter issues missing from the table, each at
|
|
121
|
-
# its frontmatter-order position. Cell updates above preserve the line
|
|
122
|
-
# count, so the table's line indices stay valid for sync_missing_rows.
|
|
123
|
-
new_body, rows_added = sync_missing_rows(new_body, frontmatter_nums, issues_by_num)
|
|
97
|
+
if canonical:
|
|
98
|
+
# Canonical table → RE-DERIVE the whole block from frontmatter
|
|
99
|
+
# membership + live data, milestone-ordered (#101). Re-deriving from
|
|
100
|
+
# the one shared renderer is what keeps the markdown table from
|
|
101
|
+
# decaying: order, columns, missing rows, and statuses are all
|
|
102
|
+
# rebuilt every run, so it can't drift from the viewer.
|
|
103
|
+
new_body, detail = _rederive_canonical(
|
|
104
|
+
track, canonical, frontmatter_nums, issues_by_num, state_by_num
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
new_body, detail = _refresh_narrative(
|
|
108
|
+
track, tables, frontmatter_nums, issues_by_num, state_by_num
|
|
109
|
+
)
|
|
124
110
|
|
|
125
111
|
if new_body == track.body:
|
|
126
112
|
continue
|
|
127
|
-
pending.append((track, new_body,
|
|
113
|
+
pending.append((track, new_body, detail))
|
|
128
114
|
|
|
129
115
|
if not pending:
|
|
130
116
|
print("All tracks in sync.")
|
|
131
117
|
return 0
|
|
132
118
|
|
|
133
119
|
print(f"Pending updates across {len(pending)} track(s):\n")
|
|
134
|
-
for track, _,
|
|
135
|
-
|
|
136
|
-
print(f" {track.path.name:50} {cells} cell(s){added_str}")
|
|
120
|
+
for track, _, detail in pending:
|
|
121
|
+
print(f" {track.path.name:50} {detail}")
|
|
137
122
|
|
|
138
123
|
if not yes and not prompt_yes_no("\nApply all? [y/N]"):
|
|
139
124
|
print("Cancelled.")
|
|
140
125
|
return 0
|
|
141
126
|
|
|
142
|
-
for track, new_body, _
|
|
127
|
+
for track, new_body, _ in pending:
|
|
143
128
|
write_file(track.path, track.meta, new_body)
|
|
144
129
|
print(f"\n✓ Updated {len(pending)} file(s).")
|
|
145
130
|
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _rederive_canonical(track, canonical_tables, frontmatter_nums,
|
|
134
|
+
issues_by_num, state_by_num):
|
|
135
|
+
"""Rebuild the canonical block, milestone-ordered, from live data.
|
|
136
|
+
|
|
137
|
+
Returns (new_body, detail_str). detail reports rows added vs. the old table
|
|
138
|
+
and status changes, falling back to a format/order note when the only
|
|
139
|
+
change is reordering or the one-time 4→5 column migration."""
|
|
140
|
+
old_nums, old_status = set(), {}
|
|
141
|
+
for table in canonical_tables:
|
|
142
|
+
sidx = table["status_col_index"]
|
|
143
|
+
for row in table["rows"]:
|
|
144
|
+
row_nums = [int(m) for cell in row["cells"]
|
|
145
|
+
for m in ISSUE_NUM_RE.findall(cell)]
|
|
146
|
+
for num in row_nums:
|
|
147
|
+
old_nums.add(num)
|
|
148
|
+
if sidx < len(row["cells"]):
|
|
149
|
+
old_status[num] = row["cells"][sidx].strip()
|
|
150
|
+
|
|
151
|
+
table_md = render_canonical_table(
|
|
152
|
+
frontmatter_nums, issues_by_num,
|
|
153
|
+
milestone_alignment=track.meta.get("milestone_alignment"),
|
|
154
|
+
)
|
|
155
|
+
new_body = insert_canonical_block(track.body, table_md, replace=True)
|
|
156
|
+
|
|
157
|
+
rows_added = len(set(frontmatter_nums) - old_nums)
|
|
158
|
+
# Frontmatter is membership truth: a row in the old table but no longer in
|
|
159
|
+
# frontmatter is dropped on re-derive. Surface it so an approving user can
|
|
160
|
+
# see a deletion, not just additions.
|
|
161
|
+
rows_removed = len(old_nums - set(frontmatter_nums))
|
|
162
|
+
status_changes = sum(
|
|
163
|
+
1 for n in frontmatter_nums
|
|
164
|
+
if n in old_status and n in state_by_num
|
|
165
|
+
and old_status[n] != state_by_num[n].strip()
|
|
166
|
+
)
|
|
167
|
+
bits = []
|
|
168
|
+
if status_changes:
|
|
169
|
+
bits.append(f"{status_changes} status change(s)")
|
|
170
|
+
if rows_added:
|
|
171
|
+
bits.append(f"{rows_added} row(s) added")
|
|
172
|
+
if rows_removed:
|
|
173
|
+
bits.append(f"{rows_removed} row(s) removed")
|
|
174
|
+
detail = ", ".join(bits) if bits else "canonical table re-derived"
|
|
175
|
+
return new_body, detail
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _refresh_narrative(track, tables, frontmatter_nums, issues_by_num, state_by_num):
|
|
179
|
+
"""Original behavior for tracks WITHOUT a canonical table: update status
|
|
180
|
+
cells in narrative tables in place, then slot in missing frontmatter rows.
|
|
181
|
+
Conservative — never reorders or restructures a hand-written table."""
|
|
182
|
+
lines = track.body.split("\n")
|
|
183
|
+
cell_updates = 0
|
|
184
|
+
for table in tables:
|
|
185
|
+
sidx = table["status_col_index"]
|
|
186
|
+
for row in table["rows"]:
|
|
187
|
+
nums = []
|
|
188
|
+
for cell in row["cells"]:
|
|
189
|
+
nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
|
|
190
|
+
for num in nums:
|
|
191
|
+
if num not in state_by_num:
|
|
192
|
+
continue
|
|
193
|
+
new_status = state_by_num[num]
|
|
194
|
+
if sidx >= len(row["cells"]):
|
|
195
|
+
continue
|
|
196
|
+
current = row["cells"][sidx].strip()
|
|
197
|
+
if current == new_status.strip():
|
|
198
|
+
continue
|
|
199
|
+
new_label = new_status.strip().split(" ", 1)[-1].lower()
|
|
200
|
+
if new_label and new_label in current.lower():
|
|
201
|
+
continue
|
|
202
|
+
new_cells = list(row["cells"])
|
|
203
|
+
new_cells[sidx] = " " + new_status + " "
|
|
204
|
+
lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
|
|
205
|
+
cell_updates += 1
|
|
206
|
+
|
|
207
|
+
new_body = "\n".join(lines)
|
|
208
|
+
# Slot in rows for frontmatter issues missing from the table, each at its
|
|
209
|
+
# frontmatter-order position. Cell updates preserve the line count, so the
|
|
210
|
+
# table's line indices stay valid for sync_missing_rows.
|
|
211
|
+
new_body, rows_added = sync_missing_rows(new_body, frontmatter_nums, issues_by_num)
|
|
212
|
+
|
|
213
|
+
added_str = f", {rows_added} row(s) added" if rows_added else ""
|
|
214
|
+
return new_body, f"{cell_updates} cell(s){added_str}"
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""rename-track subcommand — rename an existing active track's slug + file.
|
|
2
|
+
|
|
3
|
+
Resolves <old-slug> to a single active Track, renames its .md file on disk,
|
|
4
|
+
updates the frontmatter `track` field + `last_touched`, and (for shared tracks)
|
|
5
|
+
optionally commits the move with --commit. Cross-references in sibling tracks'
|
|
6
|
+
`depends_on` lists are warned about, or rewritten with --fix-refs.
|
|
7
|
+
|
|
8
|
+
Non-goals: no bulk rename, no body search-and-replace, no archive rename
|
|
9
|
+
(archived tracks aren't discovered, so they can't be targeted).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
rename-track <old-slug | old@repo> <new-slug>
|
|
13
|
+
[--repo=<key>] [--fix-refs] [--commit] [--confirm=<token>]
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from lib.config import load_config, ConfigError, is_valid_git_repo
|
|
22
|
+
from lib.tracks import (
|
|
23
|
+
discover_tracks,
|
|
24
|
+
find_track_by_name,
|
|
25
|
+
parse_track_repo_arg,
|
|
26
|
+
AmbiguousTrackError,
|
|
27
|
+
)
|
|
28
|
+
from lib.frontmatter import write_file
|
|
29
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
30
|
+
from lib.prompts import parse_flags
|
|
31
|
+
|
|
32
|
+
# Same slug rule as new-track: lowercase letters/digits/hyphens, starts with letter.
|
|
33
|
+
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _git_commit_rename(
|
|
37
|
+
old_path: Path, new_path: Path, old_slug: str, new_slug: str
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Stage the old + new paths and commit a single shared-track rename.
|
|
40
|
+
|
|
41
|
+
Path-scoped (never `git add .`). git detects the move as a rename at commit
|
|
42
|
+
time from content similarity. Non-fatal: any git failure warns and returns.
|
|
43
|
+
"""
|
|
44
|
+
# The clone root is .work-plan/'s parent.
|
|
45
|
+
clone_root = new_path.parent.parent
|
|
46
|
+
if not is_valid_git_repo(clone_root):
|
|
47
|
+
print("⚠ --commit ignored: track is private (not in a git repo)")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Determine current branch name for the success message.
|
|
51
|
+
branch = "HEAD"
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
["git", "-C", str(clone_root), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
55
|
+
capture_output=True, text=True, check=False,
|
|
56
|
+
)
|
|
57
|
+
if result.returncode == 0:
|
|
58
|
+
branch = result.stdout.strip()
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
# Stage ONLY the two affected paths (old deletion + new addition).
|
|
63
|
+
try:
|
|
64
|
+
subprocess.run(
|
|
65
|
+
["git", "-C", str(clone_root), "add", str(old_path), str(new_path)],
|
|
66
|
+
capture_output=True, text=True, check=True,
|
|
67
|
+
)
|
|
68
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
69
|
+
msg = getattr(e, "stderr", str(e))
|
|
70
|
+
print(f"⚠ --commit: git add failed ({msg.strip()!r}) — continuing without commit")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
commit_msg = f"chore: rename shared track '{old_slug}' → '{new_slug}'"
|
|
74
|
+
try:
|
|
75
|
+
subprocess.run(
|
|
76
|
+
["git", "-C", str(clone_root), "commit", "-m", commit_msg],
|
|
77
|
+
capture_output=True, text=True, check=True,
|
|
78
|
+
)
|
|
79
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
80
|
+
msg = getattr(e, "stderr", str(e))
|
|
81
|
+
print(f"⚠ --commit: git commit failed ({msg.strip()!r}) — continuing without commit")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
print(f"✓ committed rename '{old_slug}' → '{new_slug}' to {branch}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fix_cross_references(
|
|
88
|
+
tracks: list, renamed: object, old_slug: str, new_slug: str, *, apply: bool
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Find sibling tracks in the same repo whose `depends_on` lists old_slug.
|
|
91
|
+
|
|
92
|
+
With apply=True, rewrite each occurrence to new_slug and persist the file;
|
|
93
|
+
otherwise just report. Returns the number of referring tracks found.
|
|
94
|
+
"""
|
|
95
|
+
referrers = [
|
|
96
|
+
t for t in tracks
|
|
97
|
+
if t is not renamed
|
|
98
|
+
and t.has_frontmatter
|
|
99
|
+
and t.repo == renamed.repo
|
|
100
|
+
and old_slug in (t.meta.get("depends_on") or [])
|
|
101
|
+
]
|
|
102
|
+
if not referrers:
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
if apply:
|
|
106
|
+
for t in referrers:
|
|
107
|
+
t.meta["depends_on"] = [
|
|
108
|
+
new_slug if dep == old_slug else dep
|
|
109
|
+
for dep in t.meta.get("depends_on") or []
|
|
110
|
+
]
|
|
111
|
+
write_file(t.path, t.meta, t.body)
|
|
112
|
+
print(
|
|
113
|
+
f"✓ updated depends_on in {len(referrers)} track(s): "
|
|
114
|
+
+ ", ".join(t.name for t in referrers)
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
print(
|
|
118
|
+
f"⚠ {len(referrers)} track(s) still depend on '{old_slug}': "
|
|
119
|
+
+ ", ".join(t.name for t in referrers)
|
|
120
|
+
)
|
|
121
|
+
print(" Re-run with --fix-refs to rewrite their depends_on to the new slug.")
|
|
122
|
+
return len(referrers)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run(args: list[str]) -> int:
|
|
126
|
+
flags, positional = parse_flags(
|
|
127
|
+
args, {"--repo", "--confirm", "--fix-refs", "--commit"}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if len(positional) < 2:
|
|
131
|
+
print(
|
|
132
|
+
"usage: work_plan.py rename-track <old-slug | old@repo> <new-slug>"
|
|
133
|
+
" [--repo=<key>] [--fix-refs] [--commit] [--confirm=<token>]"
|
|
134
|
+
)
|
|
135
|
+
return 2
|
|
136
|
+
|
|
137
|
+
old_arg = positional[0]
|
|
138
|
+
new_slug = positional[1]
|
|
139
|
+
|
|
140
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(old_arg)
|
|
141
|
+
old_name = name_from_arg
|
|
142
|
+
repo_qualifier = repo_from_arg or (
|
|
143
|
+
flags.get("--repo") if flags.get("--repo") is not True else None
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Validate the new slug up front (cheap, no I/O).
|
|
147
|
+
if not _SLUG_RE.fullmatch(new_slug):
|
|
148
|
+
print(
|
|
149
|
+
f"ERROR: '{new_slug}' is not a valid slug."
|
|
150
|
+
" Use lowercase letters, digits, hyphens; must start with a letter."
|
|
151
|
+
)
|
|
152
|
+
return 2
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
cfg = load_config()
|
|
156
|
+
except ConfigError as e:
|
|
157
|
+
print(f"ERROR: {e}")
|
|
158
|
+
return 1
|
|
159
|
+
|
|
160
|
+
tracks = discover_tracks(cfg)
|
|
161
|
+
try:
|
|
162
|
+
track = find_track_by_name(old_name, tracks, repo=repo_qualifier)
|
|
163
|
+
except AmbiguousTrackError as e:
|
|
164
|
+
print(str(e))
|
|
165
|
+
return 1
|
|
166
|
+
if not track:
|
|
167
|
+
print(f"No track matching '{old_name}'.")
|
|
168
|
+
return 1
|
|
169
|
+
|
|
170
|
+
if new_slug == track.name:
|
|
171
|
+
print(f"ERROR: '{new_slug}' is already the track's slug — nothing to rename.")
|
|
172
|
+
return 2
|
|
173
|
+
|
|
174
|
+
# Reject if a track with new_slug already exists in the same repo/tier
|
|
175
|
+
# (same target directory). new_path.exists() is the authoritative check.
|
|
176
|
+
new_path = track.path.parent / f"{new_slug}.md"
|
|
177
|
+
if new_path.exists():
|
|
178
|
+
print(f"ERROR: a track '{new_slug}' already exists at {new_path}")
|
|
179
|
+
return 2
|
|
180
|
+
|
|
181
|
+
# Public-repo confirm gate — fires BEFORE any write or move. Mirrors close.
|
|
182
|
+
confirm = flags.get("--confirm")
|
|
183
|
+
if track.repo and needs_confirm(track.repo, cfg) and not (
|
|
184
|
+
isinstance(confirm, str) and valid_token(confirm, track.repo, new_slug)
|
|
185
|
+
):
|
|
186
|
+
print(json.dumps({
|
|
187
|
+
"needs_confirm": True,
|
|
188
|
+
"reason": (
|
|
189
|
+
f"{track.repo} is PUBLIC (or visibility unknown); "
|
|
190
|
+
f"renaming '{track.name}' → '{new_slug}' will be written there."
|
|
191
|
+
),
|
|
192
|
+
"token": make_token(track.repo, new_slug),
|
|
193
|
+
}))
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# Perform the rename: write the rewritten frontmatter (track slug +
|
|
198
|
+
# last_touched) to the NEW path FIRST, then remove the old file. Doing
|
|
199
|
+
# it in this order means a write_file failure (yq error, symlink refusal)
|
|
200
|
+
# leaves the original intact — no half-renamed state where the filename
|
|
201
|
+
# and the frontmatter `track` field disagree.
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
old_path = track.path
|
|
204
|
+
old_slug = track.name
|
|
205
|
+
|
|
206
|
+
track.meta["track"] = new_slug
|
|
207
|
+
track.meta["last_touched"] = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
208
|
+
|
|
209
|
+
write_file(new_path, track.meta, track.body)
|
|
210
|
+
old_path.unlink()
|
|
211
|
+
track.path = new_path
|
|
212
|
+
track.name = new_slug
|
|
213
|
+
|
|
214
|
+
is_shared = getattr(track, "tier", None) == "shared"
|
|
215
|
+
if is_shared:
|
|
216
|
+
print(f"✓ Renamed shared track '{old_slug}' → '{new_slug}' at {new_path}")
|
|
217
|
+
else:
|
|
218
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
219
|
+
try:
|
|
220
|
+
display = new_path.relative_to(notes_root)
|
|
221
|
+
except ValueError:
|
|
222
|
+
display = new_path
|
|
223
|
+
print(f"✓ Renamed track '{old_slug}' → '{new_slug}' at {display}")
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# --commit: stage + commit the rename to the shared repo (non-fatal).
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
if "--commit" in flags:
|
|
229
|
+
if is_shared:
|
|
230
|
+
_git_commit_rename(old_path, new_path, old_slug, new_slug)
|
|
231
|
+
else:
|
|
232
|
+
print("⚠ --commit ignored: track is private (not in a git repo)")
|
|
233
|
+
elif is_shared:
|
|
234
|
+
print(" ↑ shared track — commit + push to share this rename with teammates.")
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
# Cross-reference hygiene: sibling tracks that depend_on the old slug.
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
_fix_cross_references(
|
|
240
|
+
tracks, track, old_slug, new_slug, apply="--fix-refs" in flags
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return 0
|