@stylusnexus/work-plan 2026.6.9 → 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 +94 -15
- package/VERSION +1 -1
- package/bin/work-plan +23 -0
- package/package.json +2 -2
- package/skills/work-plan/SKILL.md +41 -8
- package/skills/work-plan/commands/auto_triage.py +243 -0
- package/skills/work-plan/commands/batch_slot.py +184 -0
- package/skills/work-plan/commands/brief.py +6 -6
- package/skills/work-plan/commands/canonicalize.py +31 -62
- package/skills/work-plan/commands/close.py +21 -6
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +21 -8
- package/skills/work-plan/commands/group.py +86 -10
- package/skills/work-plan/commands/handoff.py +32 -11
- package/skills/work-plan/commands/hygiene.py +29 -3
- package/skills/work-plan/commands/init.py +49 -7
- package/skills/work-plan/commands/init_repo.py +51 -3
- package/skills/work-plan/commands/list_cmd.py +34 -6
- package/skills/work-plan/commands/move.py +131 -0
- package/skills/work-plan/commands/new_track.py +107 -23
- package/skills/work-plan/commands/reconcile.py +175 -33
- package/skills/work-plan/commands/refresh_md.py +125 -43
- package/skills/work-plan/commands/rename_track.py +243 -0
- package/skills/work-plan/commands/set_field.py +17 -7
- package/skills/work-plan/commands/set_notes_root.py +8 -4
- package/skills/work-plan/commands/slot.py +20 -5
- package/skills/work-plan/commands/suggest_priorities.py +12 -2
- package/skills/work-plan/commands/where_was_i.py +23 -5
- package/skills/work-plan/lib/config.py +6 -0
- package/skills/work-plan/lib/export_model.py +57 -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 +100 -26
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +46 -4
- package/skills/work-plan/lib/status_table.py +95 -5
- package/skills/work-plan/lib/tracks.py +214 -19
- 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_auto_triage.py +351 -0
- package/skills/work-plan/tests/test_batch_slot.py +291 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_config.py +12 -12
- package/skills/work-plan/tests/test_config_shared.py +57 -0
- package/skills/work-plan/tests/test_coverage.py +192 -0
- package/skills/work-plan/tests/test_export.py +204 -1
- package/skills/work-plan/tests/test_export_command.py +2 -2
- package/skills/work-plan/tests/test_github_state.py +55 -17
- package/skills/work-plan/tests/test_group_apply.py +411 -0
- package/skills/work-plan/tests/test_init_repo.py +140 -7
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_list_sort.py +162 -0
- package/skills/work-plan/tests/test_move.py +240 -0
- package/skills/work-plan/tests/test_new_track.py +176 -11
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- 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 +92 -0
- 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_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_status_table.py +61 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +398 -4
- package/skills/work-plan/tests/test_where_was_i.py +135 -0
- package/skills/work-plan/work_plan.py +51 -26
- /package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/no_frontmatter.md +0 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""refresh-md subcommand."""
|
|
2
2
|
from lib.config import load_config, ConfigError
|
|
3
|
-
from lib.tracks import discover_tracks, find_track_by_name, filter_tracks_by_repo
|
|
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
|
|
|
@@ -15,12 +18,20 @@ def run(args: list[str]) -> int:
|
|
|
15
18
|
if repo_key is True:
|
|
16
19
|
print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
|
|
17
20
|
return 2
|
|
18
|
-
|
|
21
|
+
track_arg = positional[0] if positional else None
|
|
19
22
|
|
|
20
|
-
if not do_all and not
|
|
23
|
+
if not do_all and not track_arg and not repo_key:
|
|
21
24
|
print("usage: work_plan.py refresh-md <track-name> | --all | --repo=<key> [--yes]")
|
|
22
25
|
return 2
|
|
23
26
|
|
|
27
|
+
track_name = track_arg
|
|
28
|
+
repo_qualifier = repo_key
|
|
29
|
+
if track_arg:
|
|
30
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
31
|
+
track_name = name_from_arg
|
|
32
|
+
if repo_from_arg:
|
|
33
|
+
repo_qualifier = repo_from_arg
|
|
34
|
+
|
|
24
35
|
try:
|
|
25
36
|
cfg = load_config()
|
|
26
37
|
except ConfigError as e:
|
|
@@ -28,7 +39,7 @@ def run(args: list[str]) -> int:
|
|
|
28
39
|
return 1
|
|
29
40
|
|
|
30
41
|
tracks = discover_tracks(cfg)
|
|
31
|
-
if do_all or repo_key:
|
|
42
|
+
if do_all or (repo_key and not track_arg):
|
|
32
43
|
targets = [t for t in tracks if t.has_frontmatter
|
|
33
44
|
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
34
45
|
if repo_key:
|
|
@@ -41,7 +52,11 @@ def run(args: list[str]) -> int:
|
|
|
41
52
|
return 0
|
|
42
53
|
return _refresh_many(targets, yes)
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
try:
|
|
56
|
+
track = find_track_by_name(track_name, tracks, repo=repo_qualifier)
|
|
57
|
+
except AmbiguousTrackError as e:
|
|
58
|
+
print(str(e))
|
|
59
|
+
return 1
|
|
45
60
|
if not track:
|
|
46
61
|
print(f"No track matching '{track_name}'.")
|
|
47
62
|
return 1
|
|
@@ -52,7 +67,8 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
52
67
|
"""Refresh one or more tracks. Computes proposed updates, then asks one
|
|
53
68
|
confirmation (or applies all if --yes)."""
|
|
54
69
|
pending = []
|
|
55
|
-
for track in tracks:
|
|
70
|
+
for i, track in enumerate(tracks, 1):
|
|
71
|
+
print(f" [{i}/{len(tracks)}] {track.path.name}...", flush=True)
|
|
56
72
|
canonical = find_canonical_status_tables(track.body)
|
|
57
73
|
all_tables = find_all_status_tables(track.body)
|
|
58
74
|
tables = canonical if canonical else all_tables
|
|
@@ -68,7 +84,7 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
68
84
|
|
|
69
85
|
# Frontmatter is canonical for membership: issues listed there but
|
|
70
86
|
# missing from the table need a fresh row (issue #77). Fetch the union
|
|
71
|
-
# so
|
|
87
|
+
# so rows carry live title/assignee/status too.
|
|
72
88
|
frontmatter_nums = track.meta.get("github", {}).get("issues") or []
|
|
73
89
|
fetch_nums = sorted(all_issue_nums | set(frontmatter_nums))
|
|
74
90
|
if not fetch_nums:
|
|
@@ -78,55 +94,121 @@ def _refresh_many(tracks: list, yes: bool) -> int:
|
|
|
78
94
|
issues_by_num = {i["number"]: i for i in issues}
|
|
79
95
|
state_by_num = {i["number"]: state_to_status_label(i.get("state")) for i in issues}
|
|
80
96
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
continue
|
|
95
|
-
current = row["cells"][sidx].strip()
|
|
96
|
-
if current == new_status.strip():
|
|
97
|
-
continue
|
|
98
|
-
new_label = new_status.strip().split(" ", 1)[-1].lower()
|
|
99
|
-
if new_label and new_label in current.lower():
|
|
100
|
-
continue
|
|
101
|
-
new_cells = list(row["cells"])
|
|
102
|
-
new_cells[sidx] = " " + new_status + " "
|
|
103
|
-
lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
|
|
104
|
-
cell_updates += 1
|
|
105
|
-
|
|
106
|
-
new_body = "\n".join(lines)
|
|
107
|
-
# Slot in rows for frontmatter issues missing from the table, each at
|
|
108
|
-
# its frontmatter-order position. Cell updates above preserve the line
|
|
109
|
-
# count, so the table's line indices stay valid for sync_missing_rows.
|
|
110
|
-
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
|
+
)
|
|
111
110
|
|
|
112
111
|
if new_body == track.body:
|
|
113
112
|
continue
|
|
114
|
-
pending.append((track, new_body,
|
|
113
|
+
pending.append((track, new_body, detail))
|
|
115
114
|
|
|
116
115
|
if not pending:
|
|
117
116
|
print("All tracks in sync.")
|
|
118
117
|
return 0
|
|
119
118
|
|
|
120
119
|
print(f"Pending updates across {len(pending)} track(s):\n")
|
|
121
|
-
for track, _,
|
|
122
|
-
|
|
123
|
-
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}")
|
|
124
122
|
|
|
125
123
|
if not yes and not prompt_yes_no("\nApply all? [y/N]"):
|
|
126
124
|
print("Cancelled.")
|
|
127
125
|
return 0
|
|
128
126
|
|
|
129
|
-
for track, new_body, _
|
|
127
|
+
for track, new_body, _ in pending:
|
|
130
128
|
write_file(track.path, track.meta, new_body)
|
|
131
129
|
print(f"\n✓ Updated {len(pending)} file(s).")
|
|
132
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
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"""set subcommand — guarded edit of a track's frontmatter scalar/list fields."""
|
|
2
2
|
import json
|
|
3
3
|
from lib.config import load_config, ConfigError
|
|
4
|
-
from lib.tracks import discover_tracks, find_track_by_name
|
|
4
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
5
5
|
from lib.frontmatter import write_file
|
|
6
6
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
7
7
|
from lib.prompts import parse_flags
|
|
8
8
|
|
|
9
|
-
ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up"}
|
|
9
|
+
ALLOWED = {"status", "launch_priority", "milestone_alignment", "blockers", "next_up", "depends_on"}
|
|
10
10
|
LIST_FIELDS = {"blockers", "next_up"}
|
|
11
11
|
STATUSES = {"active", "in-progress", "blocked", "parked", "shipped", "abandoned"}
|
|
12
12
|
|
|
@@ -14,10 +14,14 @@ def run(args: list[str]) -> int:
|
|
|
14
14
|
# Confirm token is passed as --confirm=<token> (equals form: parse_flags only
|
|
15
15
|
# understands --key=value or bare --key, so a space-separated token would be
|
|
16
16
|
# mis-read as a positional). The VS Code extension invokes the equals form.
|
|
17
|
-
flags, positional = parse_flags(args, {"--confirm"})
|
|
17
|
+
flags, positional = parse_flags(args, {"--confirm", "--repo"})
|
|
18
18
|
if len(positional) < 2:
|
|
19
|
-
print("usage: work_plan.py set <track> field=value [field=value …] [--confirm=<token>]"); return 2
|
|
20
|
-
|
|
19
|
+
print("usage: work_plan.py set <track> field=value [field=value …] [--confirm=<token>] [--repo=<key>]"); return 2
|
|
20
|
+
track_arg, assignments = positional[0], positional[1:]
|
|
21
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(track_arg)
|
|
22
|
+
name = name_from_arg
|
|
23
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
24
|
+
repo_qualifier = repo_from_arg or repo_flag
|
|
21
25
|
parsed = {}
|
|
22
26
|
for a in assignments:
|
|
23
27
|
if "=" not in a:
|
|
@@ -25,7 +29,10 @@ def run(args: list[str]) -> int:
|
|
|
25
29
|
k, v = a.split("=", 1)
|
|
26
30
|
if k not in ALLOWED:
|
|
27
31
|
print(f"ERROR: field {k!r} not settable (allowed: {sorted(ALLOWED)})"); return 2
|
|
28
|
-
if k
|
|
32
|
+
if k == "depends_on":
|
|
33
|
+
# Comma-separated track slugs (strings, not issue numbers).
|
|
34
|
+
parsed[k] = [x.strip() for x in v.split(",") if x.strip()] if v.strip() else []
|
|
35
|
+
elif k in LIST_FIELDS:
|
|
29
36
|
try:
|
|
30
37
|
parsed[k] = [int(x) for x in v.split(",") if x.strip()] if v.strip() else []
|
|
31
38
|
except ValueError:
|
|
@@ -38,7 +45,10 @@ def run(args: list[str]) -> int:
|
|
|
38
45
|
cfg = load_config()
|
|
39
46
|
except ConfigError as e:
|
|
40
47
|
print(f"ERROR: {e}"); return 1
|
|
41
|
-
|
|
48
|
+
try:
|
|
49
|
+
track = find_track_by_name(name, discover_tracks(cfg), repo=repo_qualifier)
|
|
50
|
+
except AmbiguousTrackError as e:
|
|
51
|
+
print(str(e)); return 1
|
|
42
52
|
if not track:
|
|
43
53
|
print(f"No track matching {name!r}."); return 1
|
|
44
54
|
# Public-repo confirm gate (the extension surfaces this as a modal).
|
|
@@ -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}")
|
|
@@ -3,7 +3,7 @@ import json
|
|
|
3
3
|
import subprocess
|
|
4
4
|
|
|
5
5
|
from lib.config import load_config, ConfigError
|
|
6
|
-
from lib.tracks import discover_tracks, find_track_by_name
|
|
6
|
+
from lib.tracks import discover_tracks, find_track_by_name, parse_track_repo_arg, AmbiguousTrackError
|
|
7
7
|
from lib.frontmatter import write_file
|
|
8
8
|
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
9
9
|
from lib.prompts import parse_flags, prompt_input
|
|
@@ -27,16 +27,26 @@ def _find_prior_owners(issue_num: int, repo: str, target_name: str, tracks):
|
|
|
27
27
|
def run(args: list[str]) -> int:
|
|
28
28
|
# --confirm uses equals form: --confirm=<token>
|
|
29
29
|
# --move / --no-move are bare flags
|
|
30
|
-
|
|
30
|
+
# --repo uses equals form: --repo=<key>
|
|
31
|
+
flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move", "--repo"})
|
|
31
32
|
if not positional:
|
|
32
|
-
print("usage: work_plan.py slot <issue-num> [track
|
|
33
|
+
print("usage: work_plan.py slot <issue-num> [track | track@repo] [--repo=<key>]")
|
|
33
34
|
return 2
|
|
34
35
|
try:
|
|
35
36
|
issue_num = int(positional[0])
|
|
36
37
|
except ValueError:
|
|
37
38
|
print(f"ERROR: '{positional[0]}' is not an issue number.")
|
|
38
39
|
return 2
|
|
39
|
-
|
|
40
|
+
target_arg = positional[1] if len(positional) > 1 else None
|
|
41
|
+
repo_flag = flags.get("--repo") if flags.get("--repo") is not True else None
|
|
42
|
+
|
|
43
|
+
target_name = target_arg
|
|
44
|
+
repo_qualifier = repo_flag
|
|
45
|
+
if target_arg:
|
|
46
|
+
name_from_arg, repo_from_arg = parse_track_repo_arg(target_arg)
|
|
47
|
+
target_name = name_from_arg
|
|
48
|
+
if repo_from_arg:
|
|
49
|
+
repo_qualifier = repo_from_arg
|
|
40
50
|
|
|
41
51
|
if "--move" in flags and "--no-move" in flags:
|
|
42
52
|
print("ERROR: --move and --no-move are mutually exclusive.")
|
|
@@ -53,7 +63,12 @@ def run(args: list[str]) -> int:
|
|
|
53
63
|
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
54
64
|
|
|
55
65
|
if target_name:
|
|
56
|
-
|
|
66
|
+
try:
|
|
67
|
+
target = find_track_by_name(target_name, tracks, active_only=True,
|
|
68
|
+
repo=repo_qualifier)
|
|
69
|
+
except AmbiguousTrackError as e:
|
|
70
|
+
print(str(e))
|
|
71
|
+
return 1
|
|
57
72
|
if not target:
|
|
58
73
|
print(f"No active track matching '{target_name}'.")
|
|
59
74
|
return 1
|
|
@@ -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
|