@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.
Files changed (71) hide show
  1. package/README.md +94 -15
  2. package/VERSION +1 -1
  3. package/bin/work-plan +23 -0
  4. package/package.json +2 -2
  5. package/skills/work-plan/SKILL.md +41 -8
  6. package/skills/work-plan/commands/auto_triage.py +243 -0
  7. package/skills/work-plan/commands/batch_slot.py +184 -0
  8. package/skills/work-plan/commands/brief.py +6 -6
  9. package/skills/work-plan/commands/canonicalize.py +31 -62
  10. package/skills/work-plan/commands/close.py +21 -6
  11. package/skills/work-plan/commands/coverage.py +100 -0
  12. package/skills/work-plan/commands/duplicates.py +21 -8
  13. package/skills/work-plan/commands/group.py +86 -10
  14. package/skills/work-plan/commands/handoff.py +32 -11
  15. package/skills/work-plan/commands/hygiene.py +29 -3
  16. package/skills/work-plan/commands/init.py +49 -7
  17. package/skills/work-plan/commands/init_repo.py +51 -3
  18. package/skills/work-plan/commands/list_cmd.py +34 -6
  19. package/skills/work-plan/commands/move.py +131 -0
  20. package/skills/work-plan/commands/new_track.py +107 -23
  21. package/skills/work-plan/commands/reconcile.py +175 -33
  22. package/skills/work-plan/commands/refresh_md.py +125 -43
  23. package/skills/work-plan/commands/rename_track.py +243 -0
  24. package/skills/work-plan/commands/set_field.py +17 -7
  25. package/skills/work-plan/commands/set_notes_root.py +8 -4
  26. package/skills/work-plan/commands/slot.py +20 -5
  27. package/skills/work-plan/commands/suggest_priorities.py +12 -2
  28. package/skills/work-plan/commands/where_was_i.py +23 -5
  29. package/skills/work-plan/lib/config.py +6 -0
  30. package/skills/work-plan/lib/export_model.py +57 -2
  31. package/skills/work-plan/lib/frontmatter.py +12 -3
  32. package/skills/work-plan/lib/git_state.py +61 -52
  33. package/skills/work-plan/lib/github_state.py +100 -26
  34. package/skills/work-plan/lib/notes_readme.py +38 -0
  35. package/skills/work-plan/lib/prompts.py +46 -4
  36. package/skills/work-plan/lib/status_table.py +95 -5
  37. package/skills/work-plan/lib/tracks.py +214 -19
  38. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/archive/shipped/old.md +1 -1
  39. package/skills/work-plan/tests/fixtures/notes_root/{critforge → myproject}/example.md +1 -1
  40. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +1 -1
  41. package/skills/work-plan/tests/test_auto_triage.py +351 -0
  42. package/skills/work-plan/tests/test_batch_slot.py +291 -0
  43. package/skills/work-plan/tests/test_close_tier.py +166 -0
  44. package/skills/work-plan/tests/test_config.py +12 -12
  45. package/skills/work-plan/tests/test_config_shared.py +57 -0
  46. package/skills/work-plan/tests/test_coverage.py +192 -0
  47. package/skills/work-plan/tests/test_export.py +204 -1
  48. package/skills/work-plan/tests/test_export_command.py +2 -2
  49. package/skills/work-plan/tests/test_github_state.py +55 -17
  50. package/skills/work-plan/tests/test_group_apply.py +411 -0
  51. package/skills/work-plan/tests/test_init_repo.py +140 -7
  52. package/skills/work-plan/tests/test_init_shared.py +185 -0
  53. package/skills/work-plan/tests/test_list_sort.py +162 -0
  54. package/skills/work-plan/tests/test_move.py +240 -0
  55. package/skills/work-plan/tests/test_new_track.py +176 -11
  56. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  57. package/skills/work-plan/tests/test_plan_status_issues.py +2 -2
  58. package/skills/work-plan/tests/test_prompts.py +121 -0
  59. package/skills/work-plan/tests/test_reconcile_move.py +154 -0
  60. package/skills/work-plan/tests/test_reconcile_readonly.py +92 -0
  61. package/skills/work-plan/tests/test_refresh_md.py +159 -61
  62. package/skills/work-plan/tests/test_rename_track.py +351 -0
  63. package/skills/work-plan/tests/test_repo_filter.py +6 -6
  64. package/skills/work-plan/tests/test_security_cli_hardening.py +142 -0
  65. package/skills/work-plan/tests/test_set_notes_root.py +6 -2
  66. package/skills/work-plan/tests/test_status_table.py +61 -0
  67. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  68. package/skills/work-plan/tests/test_tracks.py +398 -4
  69. package/skills/work-plan/tests/test_where_was_i.py +135 -0
  70. package/skills/work-plan/work_plan.py +51 -26
  71. /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 find_all_status_tables, find_canonical_status_tables, sync_missing_rows, ISSUE_NUM_RE
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
- track_name = positional[0] if positional else None
21
+ track_arg = positional[0] if positional else None
19
22
 
20
- if not do_all and not track_name and not repo_key:
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
- track = find_track_by_name(track_name, tracks)
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 appended rows carry live title/assignee/status too.
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
- lines = track.body.split("\n")
82
- cell_updates = 0
83
- for table in tables:
84
- sidx = table["status_col_index"]
85
- for row in table["rows"]:
86
- nums = []
87
- for cell in row["cells"]:
88
- nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
89
- for num in nums:
90
- if num not in state_by_num:
91
- continue
92
- new_status = state_by_num[num]
93
- if sidx >= len(row["cells"]):
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, cell_updates, rows_added))
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, _, cells, added in pending:
122
- added_str = f", {added} row(s) added" if added else ""
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, _, _ in pending:
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
- name, assignments = positional[0], positional[1:]
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 in LIST_FIELDS:
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
- track = find_track_by_name(name, discover_tracks(cfg))
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
- yq_expr = f'.notes_root = "{new_root}"'
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", yq_expr, str(DEFAULT_CONFIG_PATH)],
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
- flags, positional = parse_flags(args, {"--confirm", "--move", "--no-move"})
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-name]")
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
- target_name = positional[1] if len(positional) > 1 else None
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
- target = find_track_by_name(target_name, tracks, active_only=True)
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
- num = ans["number"]
117
- priority = ans["priority"]
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