@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,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
|
|
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
|
|
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": "
|
|
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
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
209
|
+
local_path = Path(entry["local"]).expanduser()
|
|
210
|
+
if not is_valid_git_repo(local_path):
|
|
67
211
|
continue
|
|
68
|
-
|
|
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
|
-
|
|
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
|
)
|