@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,12 +1,35 @@
1
1
  """Shared CLI helpers: prompts and arg parsing."""
2
+ import sys
3
+
4
+
5
+ def _stdin_is_interactive() -> bool:
6
+ """True only when stdin is a real terminal we can block on for a reply.
7
+
8
+ When the CLI is launched with stdin wired to a pipe or socket that stays
9
+ open but never delivers a line — e.g. the VS Code extension spawning
10
+ `work_plan.py` — `input()` blocks forever (no data, no EOF). A closed pipe
11
+ raises EOFError and is handled; an *idle open* one hangs. Guarding on
12
+ `isatty()` lets the prompt helpers fall back to their default instead of
13
+ deadlocking. Non-interactive callers should pass an explicit flag
14
+ (`--yes`, `--draft`) rather than rely on the prompt.
15
+ """
16
+ try:
17
+ return bool(sys.stdin) and sys.stdin.isatty()
18
+ except (ValueError, AttributeError):
19
+ # stdin closed/detached, or replaced by an object without isatty.
20
+ return False
2
21
 
3
22
 
4
23
  def prompt_input(message: str, default: str = "") -> str:
5
24
  """Print prompt and read a free-form line. Treats EOF (no stdin) as default.
6
25
 
7
- Returns the stripped input, or `default` if EOF or blank.
26
+ Returns the stripped input, or `default` if EOF, blank, or there is no
27
+ interactive terminal to read from.
8
28
  """
9
29
  print(message)
30
+ if not _stdin_is_interactive():
31
+ print(f"(no interactive terminal — using default {default!r})")
32
+ return default
10
33
  try:
11
34
  line = input().strip()
12
35
  except EOFError:
@@ -15,7 +38,12 @@ def prompt_input(message: str, default: str = "") -> str:
15
38
 
16
39
 
17
40
  def prompt_lines() -> list[str]:
18
- """Read lines from stdin until blank line or EOF. Returns list of non-blank lines."""
41
+ """Read lines from stdin until blank line or EOF. Returns list of non-blank lines.
42
+
43
+ With no interactive terminal, returns an empty list rather than blocking.
44
+ """
45
+ if not _stdin_is_interactive():
46
+ return []
19
47
  out = []
20
48
  try:
21
49
  while True:
@@ -29,11 +57,14 @@ def prompt_lines() -> list[str]:
29
57
 
30
58
 
31
59
  def prompt_yes_no(message: str = "Apply? [y/N]") -> bool:
32
- """Print prompt and read y/N. Treats EOF (no stdin) as no.
60
+ """Print prompt and read y/N. Treats EOF or no terminal as no.
33
61
 
34
62
  Returns True only if user explicitly types 'y' (case-insensitive).
35
63
  """
36
64
  print(message)
65
+ if not _stdin_is_interactive():
66
+ print("(no interactive terminal — defaulting to no)")
67
+ return False
37
68
  try:
38
69
  choice = input().strip().lower()
39
70
  except EOFError:
@@ -49,14 +80,25 @@ def parse_flags(args: list[str], known: set[str]) -> tuple[dict, list[str]]:
49
80
  For `--key=value` flags, key.split("=", 1)[0] is matched against `known`.
50
81
 
51
82
  Returns: (flags_dict, positional_list).
52
- - flags_dict: {"--all": True, "--repo": "critforge", ...} for flags found.
83
+ - flags_dict: {"--all": True, "--repo": "myproject", ...} for flags found.
53
84
  - positional_list: args that aren't flags.
54
85
 
55
86
  Unknown flags are passed through as positional args (caller decides what to do).
56
87
  """
57
88
  flags = {}
58
89
  positional = []
90
+ end_of_opts = False
59
91
  for arg in args:
92
+ # A bare `--` ends option parsing: everything after it is positional,
93
+ # even if it begins with `--`. Lets callers (e.g. the VS Code extension)
94
+ # pass a GitHub-derived value like a `--repo`-named track as a plain
95
+ # positional instead of having it misparsed as a flag (#194).
96
+ if end_of_opts:
97
+ positional.append(arg)
98
+ continue
99
+ if arg == "--":
100
+ end_of_opts = True
101
+ continue
60
102
  if not arg.startswith("--"):
61
103
  positional.append(arg)
62
104
  continue
@@ -104,12 +104,102 @@ def update_row_status(body: str, issue_num: int, new_status: str) -> str:
104
104
  return "\n".join(lines)
105
105
 
106
106
 
107
- def render_issue_row(num: int, title: str, assignee: str, status: str) -> str:
108
- """Render a canonical issue-table row: `| #N | title | assignee | status |`.
107
+ def render_issue_row(num: int, title: str, assignee: str, status: str,
108
+ milestone: Optional[str] = None) -> str:
109
+ """Render a canonical issue-table row.
110
+
111
+ Single source of truth for the canonical row shape. With `milestone=None`
112
+ (the default) renders the 4-column form `| #N | title | assignee | status |`
113
+ used by narrative tables and sync_missing_rows appends. Pass a milestone
114
+ string (possibly empty) to render the 5-column canonical form
115
+ `| #N | title | milestone | assignee | status |` used by render_canonical_table
116
+ (#101). An empty string still renders the column — distinct from None, which
117
+ drops it."""
118
+ if milestone is None:
119
+ return f"| #{num} | {title} | {assignee} | {status} |"
120
+ return f"| #{num} | {title} | {milestone} | {assignee} | {status} |"
121
+
122
+
123
+ def render_canonical_table(issue_nums: list, issues_by_num: dict,
124
+ milestone_alignment=None) -> str:
125
+ """Render the canonical issues block: heading, marker, and ONE table.
126
+
127
+ The table carries a `Milestone` column and is ordered active-milestone-first
128
+ (the shared `milestone_sort_key`): issues whose milestone matches the track's
129
+ `milestone_alignment` come first, then other milestones grouped by label,
130
+ then no-milestone issues last; a blank divider row separates each group.
131
+
132
+ Deliberately a SINGLE table (not per-milestone sub-tables): it round-trips
133
+ through refresh-md, which re-derives this whole block on every run, so the
134
+ rendered order can't decay (#101). The blank divider row has no `#NNNN`
135
+ ref, so the table parsers skip it.
136
+
137
+ Returns the block string (heading + marker + table); callers add the
138
+ trailing `---` separator via insert_canonical_block."""
139
+ from lib.github_state import (
140
+ short_milestone, format_assignees, state_to_status_label,
141
+ )
142
+ from lib.export_model import group_issues_by_milestone
143
+
144
+ lines = [
145
+ "## Issues (canonical)",
146
+ "",
147
+ f"{CANONICAL_MARKER} — auto-managed by /work-plan refresh-md. Don't edit by hand. -->",
148
+ "",
149
+ "| # | Title | Milestone | Assignee | Status |",
150
+ "|---|---|---|---|---|",
151
+ ]
152
+
153
+ norm = []
154
+ for num in sorted(issue_nums):
155
+ gh = issues_by_num.get(num, {})
156
+ ms = short_milestone(gh.get("milestone")) or None
157
+ norm.append({"number": num, "milestone": ms, "_gh": gh})
158
+
159
+ groups = group_issues_by_milestone(norm, milestone_alignment)
160
+ for gi, (label, issues) in enumerate(groups):
161
+ if gi > 0:
162
+ lines.append("| | | | | |") # blank divider row between milestone groups
163
+ for it in issues:
164
+ gh = it["_gh"]
165
+ lines.append(render_issue_row(
166
+ it["number"], gh.get("title", "(not fetched)"),
167
+ format_assignees(gh), state_to_status_label(gh.get("state")),
168
+ milestone=it["milestone"] or "",
169
+ ))
170
+ lines.append("")
171
+ return "\n".join(lines)
172
+
173
+
174
+ def strip_canonical_block(body: str) -> str:
175
+ """Remove an existing canonical-table block from the top of the body.
109
176
 
110
- Single source of truth for the canonical row shape used by canonicalize
111
- (initial table) and by sync_missing_rows (drift-healing appends)."""
112
- return f"| #{num} | {title} | {assignee} | {status} |"
177
+ The block runs from the `## Issues (canonical)` heading (or the marker if
178
+ the heading is absent) through the next `\\n---\\n` separator. Returns the
179
+ body unchanged when no marker is present."""
180
+ if CANONICAL_MARKER not in body:
181
+ return body
182
+ heading_idx = body.find("## Issues (canonical)")
183
+ marker_idx = body.find(CANONICAL_MARKER)
184
+ start = heading_idx if 0 <= heading_idx < marker_idx else marker_idx
185
+ sep_idx = body.find("\n---\n", marker_idx)
186
+ if sep_idx == -1:
187
+ end = body.find("\n", marker_idx) + 1
188
+ else:
189
+ end = sep_idx + len("\n---\n")
190
+ return body[:start] + body[end:].lstrip("\n")
191
+
192
+
193
+ def insert_canonical_block(body: str, table_md: str, replace: bool = False) -> str:
194
+ """Prepend `table_md` (a render_canonical_table block) at the top of body,
195
+ followed by a `---` separator. With replace=True, strip any existing
196
+ canonical block first (so refresh-md re-derive and canonicalize --force
197
+ produce identical output)."""
198
+ if replace:
199
+ body = strip_canonical_block(body)
200
+ body_stripped = body.lstrip("\n")
201
+ leading = body[: len(body) - len(body_stripped)]
202
+ return leading + table_md + "\n---\n\n" + body_stripped
113
203
 
114
204
 
115
205
  def append_rows(body: str, table: dict, row_lines: list[str]) -> str:
@@ -1,10 +1,42 @@
1
- """Discover tracks under notes_root."""
1
+ """Discover tracks under notes_root and shared .work-plan/ dirs."""
2
+ import sys
2
3
  from dataclasses import dataclass, field
3
4
  from pathlib import Path
4
5
  from typing import Optional
5
6
 
6
7
  from lib.frontmatter import parse_file
7
- from lib.config import resolve_github_for_folder, resolve_local_path_for_folder
8
+ from lib.config import (
9
+ resolve_github_for_folder,
10
+ resolve_local_path_for_folder,
11
+ is_valid_git_repo,
12
+ )
13
+ from lib.git_state import parse_iso_timestamp
14
+
15
+ _PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
16
+
17
+
18
+ def priority_rank(meta: dict) -> int:
19
+ """Rank a track's launch_priority for ascending sort: P0<P1<P2<P3<anything.
20
+
21
+ Unknown / missing values (e.g. "—" or absent) sort after all known ranks.
22
+ """
23
+ return _PRIORITY_RANK.get(meta.get("launch_priority"), len(_PRIORITY_RANK))
24
+
25
+
26
+ def recency_sort_key(meta: dict) -> float:
27
+ """Sort key for last_touched recency (most recent first when sorted ascending).
28
+
29
+ Returns the negative POSIX timestamp so that a plain ascending sort puts the
30
+ most-recently-touched track first. Tracks with no (or unparseable)
31
+ last_touched return +inf, sorting them LAST.
32
+ """
33
+ raw = meta.get("last_touched")
34
+ if not raw:
35
+ return float("inf")
36
+ try:
37
+ return -parse_iso_timestamp(raw).timestamp()
38
+ except (ValueError, TypeError):
39
+ return float("inf")
8
40
 
9
41
 
10
42
  @dataclass
@@ -19,14 +51,37 @@ class Track:
19
51
  local_path: Optional[Path] = None
20
52
  meta: dict = field(default_factory=dict)
21
53
  body: str = ""
54
+ tier: Optional[str] = None
22
55
 
23
56
 
24
57
  def discover_tracks(cfg: dict) -> list[Track]:
25
- """Walk notes_root for active (non-archived) .md files."""
26
- notes_root = Path(cfg["notes_root"]).expanduser()
27
- if not notes_root.exists():
28
- return []
29
- return _walk(notes_root, cfg, include_archive=False)
58
+ """Walk notes_root for active (non-archived) .md files, then union with
59
+ shared tracks from each configured repo's .work-plan/ directory.
60
+ Shared wins on (repo, name) collisions.
61
+ """
62
+ private = _discover_private_tracks(cfg, include_archive=False)
63
+ shared = _discover_shared_tracks(cfg, include_archive=False)
64
+
65
+ # Build lookup for shared tracks keyed by (repo, name)
66
+ shared_keys: dict = {}
67
+ for t in shared:
68
+ key = (t.repo, t.name)
69
+ shared_keys[key] = t
70
+
71
+ # Merge: private tracks that have no colliding shared track are kept
72
+ merged = list(shared)
73
+ for t in private:
74
+ key = (t.repo, t.name)
75
+ if key in shared_keys:
76
+ print(
77
+ f"WARN: track {t.name!r} (repo={t.repo!r}) exists in both shared"
78
+ f" ({shared_keys[key].path}) and private ({t.path}); using shared.",
79
+ file=sys.stderr,
80
+ )
81
+ else:
82
+ merged.append(t)
83
+
84
+ return merged
30
85
 
31
86
 
32
87
  def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
@@ -38,49 +93,188 @@ def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
38
93
  or (t.repo and t.repo.lower() == k)]
39
94
 
40
95
 
41
- def find_track_by_name(name: str, tracks: list[Track],
42
- *, active_only: bool = False) -> Optional[Track]:
96
+ class AmbiguousTrackError(Exception):
97
+ """Raised when a track name matches more than one track across repos."""
98
+
99
+ def __init__(self, name: str, candidates: list[Track]):
100
+ self.name = name
101
+ self.candidates = candidates
102
+ repos = [f" {t.name} (repo: {t.repo or t.folder!r})" for t in candidates]
103
+ super().__init__(
104
+ f"Track {name!r} is ambiguous — found in {len(candidates)} repos:\n"
105
+ + "\n".join(repos)
106
+ + f"\nUse --repo=<key> or '{name}@<repo>' to disambiguate."
107
+ )
108
+
109
+
110
+ def parse_track_repo_arg(arg: str) -> tuple:
111
+ """Split 'trackname@repokey' into (trackname, repokey); return (arg, None) if no @."""
112
+ if "@" in arg:
113
+ name, _, repo = arg.rpartition("@")
114
+ return (name, repo) if name else (arg, None)
115
+ return (arg, None)
116
+
117
+
118
+ def find_track_by_name(
119
+ name: str, tracks: list[Track],
120
+ *, active_only: bool = False, repo: Optional[str] = None
121
+ ) -> Optional[Track]:
43
122
  """Find a single Track matching `name` (filename stem OR frontmatter `track`).
44
123
 
124
+ If repo is given, first filter to tracks matching that repo (folder key or
125
+ GitHub slug, case-insensitive). Then find a single name match.
126
+
45
127
  If active_only=True, only considers tracks with status active/in-progress/blocked.
46
- Returns the single match or None. Used by every command that takes a track arg.
128
+
129
+ Returns the single match or None (0 matches).
130
+ Raises AmbiguousTrackError if 2+ matches remain after filtering.
47
131
  """
48
132
  candidates = tracks
133
+ if repo:
134
+ candidates = filter_tracks_by_repo(candidates, repo)
49
135
  if active_only:
50
136
  candidates = [t for t in candidates if t.has_frontmatter
51
137
  and t.meta.get("status") in ("active", "in-progress", "blocked")]
52
138
  matching = [t for t in candidates if t.has_frontmatter
53
139
  and (t.name == name or t.meta.get("track") == name)]
54
- return matching[0] if len(matching) == 1 else None
140
+ if len(matching) <= 1:
141
+ return matching[0] if matching else None
142
+ raise AmbiguousTrackError(name, matching)
55
143
 
56
144
 
57
145
  def discover_archived_tracks(cfg: dict) -> list[Track]:
58
- """Walk notes_root for archived .md files only."""
146
+ """Walk notes_root for archived .md files, and also scan each repo's
147
+ .work-plan/archive/ for shared archived tracks.
148
+
149
+ Deduplicates by (repo, name): shared wins over private, same as
150
+ discover_tracks for active tracks.
151
+ """
152
+ notes_root = Path(cfg["notes_root"]).expanduser()
153
+ private_archived: list[Track] = []
154
+ if notes_root.exists():
155
+ for md_path in sorted(notes_root.rglob("*.md")):
156
+ if "archive" not in md_path.parts:
157
+ continue
158
+ # '-' prefix rejected so a `--repo.md` file can't become a `--repo`
159
+ # track that the CLI misparses as a flag (#194).
160
+ if md_path.name.startswith((".", "_", "-")):
161
+ continue
162
+ private_archived.append(_build_track(md_path, notes_root, cfg))
163
+
164
+ shared_archived = _discover_shared_tracks(cfg, include_archive=True,
165
+ archive_only=True)
166
+
167
+ # Build lookup for shared tracks keyed by (repo, name)
168
+ shared_keys: dict = {}
169
+ for t in shared_archived:
170
+ key = (t.repo, t.name)
171
+ shared_keys[key] = t
172
+
173
+ # Merge: shared wins on collision
174
+ merged = list(shared_archived)
175
+ for t in private_archived:
176
+ key = (t.repo, t.name)
177
+ if key in shared_keys:
178
+ print(
179
+ f"WARN: archived track {t.name!r} (repo={t.repo!r}) exists in"
180
+ f" both shared ({shared_keys[key].path}) and private"
181
+ f" ({t.path}); using shared.",
182
+ file=sys.stderr,
183
+ )
184
+ else:
185
+ merged.append(t)
186
+
187
+ return merged
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Private helpers
192
+ # ---------------------------------------------------------------------------
193
+
194
+ def _discover_private_tracks(cfg: dict, include_archive: bool) -> list[Track]:
59
195
  notes_root = Path(cfg["notes_root"]).expanduser()
60
196
  if not notes_root.exists():
61
197
  return []
62
- out = []
63
- for md_path in sorted(notes_root.rglob("*.md")):
64
- if "archive" not in md_path.parts:
198
+ return _walk(notes_root, cfg, include_archive=include_archive)
199
+
200
+
201
+ def _discover_shared_tracks(cfg: dict, include_archive: bool = False,
202
+ archive_only: bool = False) -> list[Track]:
203
+ """Walk each configured repo's local clone .work-plan/ directory."""
204
+ out: list[Track] = []
205
+ repos = cfg.get("repos", {})
206
+ for folder_key, entry in repos.items():
207
+ if not entry or not entry.get("local"):
65
208
  continue
66
- if md_path.name.startswith((".", "_")):
209
+ local_path = Path(entry["local"]).expanduser()
210
+ if not is_valid_git_repo(local_path):
67
211
  continue
68
- out.append(_build_track(md_path, notes_root, cfg))
212
+ github_repo = entry.get("github")
213
+ notes_dir = local_path / ".work-plan"
214
+ if not notes_dir.is_dir():
215
+ continue
216
+ for md_path in sorted(notes_dir.rglob("*.md")):
217
+ # Skip dotfiles, README, and dash-led names (a `--repo.md` file
218
+ # would otherwise become a `--repo` track the CLI misparses, #194).
219
+ if md_path.name.startswith((".", "-")) or md_path.name == "README.md":
220
+ continue
221
+ in_archive = "archive" in md_path.relative_to(notes_dir).parts
222
+ if archive_only and not in_archive:
223
+ continue
224
+ if not include_archive and in_archive:
225
+ continue
226
+ out.append(_build_shared_track(
227
+ md_path, folder_key, github_repo, local_path
228
+ ))
69
229
  return out
70
230
 
71
231
 
232
+ def _build_shared_track(md_path: Path, folder_key: str,
233
+ github_repo: Optional[str], local_path: Path) -> Track:
234
+ """Build a Track from a shared .work-plan/ markdown file."""
235
+ meta, body = parse_file(md_path)
236
+ has_fm = bool(meta)
237
+
238
+ # Single-owner rule: if frontmatter disagrees with folder config, warn and
239
+ # use the folder's configured github repo (never the frontmatter value).
240
+ if has_fm and meta.get("github", {}).get("repo"):
241
+ fm_repo = meta["github"]["repo"]
242
+ if fm_repo != github_repo:
243
+ print(
244
+ f"WARN: shared track {md_path.name!r} frontmatter github.repo"
245
+ f" differs from folder config; using folder {github_repo!r}",
246
+ file=sys.stderr,
247
+ )
248
+
249
+ return Track(
250
+ path=md_path,
251
+ name=md_path.stem,
252
+ has_frontmatter=has_fm,
253
+ needs_init=False,
254
+ needs_filing=False,
255
+ repo=github_repo,
256
+ folder=folder_key,
257
+ local_path=local_path,
258
+ meta=meta,
259
+ body=body,
260
+ tier="shared",
261
+ )
262
+
263
+
72
264
  def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
73
265
  out = []
74
266
  for md_path in sorted(notes_root.rglob("*.md")):
75
267
  if not include_archive and "archive" in md_path.parts:
76
268
  continue
77
- if md_path.name.startswith((".", "_")):
269
+ # '-' prefix rejected so a `--repo.md` file can't become a `--repo`
270
+ # track that the CLI misparses as a flag (#194).
271
+ if md_path.name.startswith((".", "_", "-")):
78
272
  continue
79
273
  out.append(_build_track(md_path, notes_root, cfg))
80
274
  return out
81
275
 
82
276
 
83
- def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> Track:
277
+ def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> "Track":
84
278
  meta, body = parse_file(md_path)
85
279
  has_fm = bool(meta)
86
280
  rel = md_path.relative_to(notes_root)
@@ -106,4 +300,5 @@ def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> Track:
106
300
  local_path=local,
107
301
  meta=meta,
108
302
  body=body,
303
+ tier="private",
109
304
  )
@@ -3,7 +3,7 @@ track: old
3
3
  status: shipped
4
4
  launch_priority: P2
5
5
  github:
6
- repo: stylusnexus/CritForge
6
+ repo: your-org/myproject
7
7
  issues: [50]
8
8
  ---
9
9
 
@@ -3,7 +3,7 @@ track: example
3
3
  status: active
4
4
  launch_priority: P1
5
5
  github:
6
- repo: stylusnexus/CritForge
6
+ repo: your-org/myproject
7
7
  issues: [100, 200]
8
8
  next_up: [100]
9
9
  ---
@@ -3,7 +3,7 @@ track: tabletop
3
3
  status: active
4
4
  launch_priority: P1
5
5
  github:
6
- repo: stylusnexus/CritForge
6
+ repo: your-org/myproject
7
7
  issues: [4254, 4127]
8
8
  branches: []
9
9
  next_up: [4254]