@stylusnexus/work-plan 2026.6.9-1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,227 @@
1
+ """Find + update first markdown table with a Status column."""
2
+ import re
3
+ from typing import Optional
4
+
5
+ ISSUE_NUM_RE = re.compile(r"#(\d+)")
6
+ CANONICAL_MARKER = "<!-- canonical-issue-table"
7
+
8
+
9
+ def find_status_table(body: str) -> Optional[dict]:
10
+ """Find the first markdown table with a 'Status' column AND issue refs.
11
+
12
+ Prefers tables whose data rows contain `#NNNN` references over tables that
13
+ happen to have a 'Status' column for non-issue purposes. Falls back to the
14
+ first 'Status' table if none have issue refs.
15
+ """
16
+ tables = find_all_status_tables(body, with_issue_refs_only=False)
17
+ with_refs = [t for t in tables if t["has_issue_refs"]]
18
+ if with_refs:
19
+ return with_refs[0]
20
+ return tables[0] if tables else None
21
+
22
+
23
+ def find_all_status_tables(body: str, with_issue_refs_only: bool = True) -> list[dict]:
24
+ """Find every markdown table with a 'Status' column.
25
+
26
+ Returns a list of table dicts, each with: header_line_idx, rows,
27
+ status_col_index, has_issue_refs, is_canonical.
28
+
29
+ `is_canonical` is True if the table is preceded (within 3 lines) by a
30
+ `<!-- canonical-issue-table -->` comment. Refresh-md prefers canonical
31
+ tables when present.
32
+
33
+ If with_issue_refs_only=True (default), only returns tables whose data rows
34
+ contain `#NNNN` references.
35
+ """
36
+ lines = body.split("\n")
37
+ tables = []
38
+ i = 0
39
+ while i < len(lines):
40
+ line = lines[i]
41
+ if "|" not in line:
42
+ i += 1
43
+ continue
44
+ cells = _parse_row(line)
45
+ if not cells:
46
+ i += 1
47
+ continue
48
+ status_idx = next((idx for idx, c in enumerate(cells) if c.strip().lower() == "status"), None)
49
+ if status_idx is None:
50
+ i += 1
51
+ continue
52
+ if i + 1 >= len(lines) or not _is_separator(lines[i + 1]):
53
+ i += 1
54
+ continue
55
+ # Look backward up to 3 lines for canonical marker
56
+ is_canonical = any(
57
+ CANONICAL_MARKER in lines[k]
58
+ for k in range(max(0, i - 3), i)
59
+ )
60
+ rows = []
61
+ j = i + 2
62
+ while j < len(lines):
63
+ if "|" not in lines[j]:
64
+ break
65
+ row_cells = _parse_row(lines[j])
66
+ if not row_cells:
67
+ break
68
+ rows.append({"raw": lines[j], "cells": row_cells, "line_idx": j})
69
+ j += 1
70
+ has_refs = any(ISSUE_NUM_RE.search(cell) for row in rows for cell in row["cells"])
71
+ if not with_issue_refs_only or has_refs:
72
+ tables.append({
73
+ "header_line_idx": i,
74
+ "rows": rows,
75
+ "status_col_index": status_idx,
76
+ "has_issue_refs": has_refs,
77
+ "is_canonical": is_canonical,
78
+ })
79
+ i = j
80
+ return tables
81
+
82
+
83
+ def find_canonical_status_tables(body: str) -> list[dict]:
84
+ """Return only canonical-marked status tables."""
85
+ return [t for t in find_all_status_tables(body) if t["is_canonical"]]
86
+
87
+
88
+ def update_row_status(body: str, issue_num: int, new_status: str) -> str:
89
+ table = find_status_table(body)
90
+ if not table:
91
+ return body
92
+ lines = body.split("\n")
93
+ sidx = table["status_col_index"]
94
+ for row in table["rows"]:
95
+ nums = []
96
+ for cell in row["cells"]:
97
+ nums.extend(int(m) for m in ISSUE_NUM_RE.findall(cell))
98
+ if issue_num not in nums:
99
+ continue
100
+ new_cells = list(row["cells"])
101
+ new_cells[sidx] = " " + new_status + " "
102
+ lines[row["line_idx"]] = "|" + "|".join(new_cells) + "|"
103
+ break
104
+ return "\n".join(lines)
105
+
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 |`.
109
+
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} |"
113
+
114
+
115
+ def append_rows(body: str, table: dict, row_lines: list[str]) -> str:
116
+ """Insert pre-rendered `row_lines` after the last data row of `table`.
117
+
118
+ `table` is a dict from find_*_status_tables. New rows land directly below
119
+ the table's existing rows (or after the header separator if the table has
120
+ none), so any narrative content below the table is preserved. The table's
121
+ line indices must still be valid for `body` (callers that rewrite cells in
122
+ place keep the line count stable, so this holds)."""
123
+ if not row_lines:
124
+ return body
125
+ lines = body.split("\n")
126
+ if table["rows"]:
127
+ insert_at = table["rows"][-1]["line_idx"] + 1
128
+ else:
129
+ insert_at = table["header_line_idx"] + 2 # past header + separator
130
+ lines[insert_at:insert_at] = row_lines
131
+ return "\n".join(lines)
132
+
133
+
134
+ def _row_primary_num(row: dict) -> Optional[int]:
135
+ """First `#NNNN` issue ref in a row, or None. A row's frontmatter-order
136
+ anchor for ordered inserts."""
137
+ for cell in row["cells"]:
138
+ m = ISSUE_NUM_RE.search(cell)
139
+ if m:
140
+ return int(m.group(1))
141
+ return None
142
+
143
+
144
+ def sync_missing_rows(body: str, frontmatter_nums: list, issues_by_num: dict):
145
+ """Insert a canonical row for every frontmatter issue missing from the table.
146
+
147
+ Picks the canonical table when present (else the first status table),
148
+ diffs `frontmatter_nums` against the issue numbers already in that table,
149
+ and slots a row for each missing number into its FRONTMATTER-ORDER
150
+ position — a missing #487 lands above an existing #678 if frontmatter
151
+ lists 487 first, rather than tacking onto the end (issue #79). Existing
152
+ rows keep their relative order and are re-emitted verbatim, so the diff
153
+ only shows the inserted lines. Live title/assignee/status come from
154
+ `issues_by_num` (a {num: gh-issue-dict} map); a number with no fetched
155
+ data still gets a placeholder row so membership never silently drifts.
156
+
157
+ Returns `(new_body, rows_added)`. No-ops (returns body unchanged, 0) when
158
+ there is no table or nothing is missing."""
159
+ from lib.github_state import state_to_status_label, format_assignees
160
+
161
+ canonical = find_canonical_status_tables(body)
162
+ tables = canonical if canonical else find_all_status_tables(body)
163
+ if not tables:
164
+ return body, 0
165
+ table = tables[0]
166
+
167
+ existing = set()
168
+ for row in table["rows"]:
169
+ for cell in row["cells"]:
170
+ existing.update(int(m) for m in ISSUE_NUM_RE.findall(cell))
171
+
172
+ # frontmatter order is the canonical ranking; missing keeps that order.
173
+ rank = {n: i for i, n in enumerate(frontmatter_nums)}
174
+ missing = [n for n in frontmatter_nums if n not in existing]
175
+ if not missing:
176
+ return body, 0
177
+
178
+ new_row = {}
179
+ for num in missing:
180
+ issue = issues_by_num.get(num) or {}
181
+ new_row[num] = render_issue_row(
182
+ num, issue.get("title", "(not fetched)"),
183
+ format_assignees(issue),
184
+ state_to_status_label(issue.get("state")),
185
+ )
186
+
187
+ # No existing rows: nothing to interleave against — drop them all in
188
+ # frontmatter order after the header separator.
189
+ if not table["rows"]:
190
+ return append_rows(body, table, [new_row[n] for n in missing]), len(missing)
191
+
192
+ # Interleave: walk existing rows in place, flushing each pending missing
193
+ # row before the first existing row that outranks it. Existing rows with
194
+ # no frontmatter rank impose no constraint, so they never trigger a flush.
195
+ out, mi = [], 0
196
+ for row in table["rows"]:
197
+ r_rank = rank.get(_row_primary_num(row))
198
+ if r_rank is not None:
199
+ while mi < len(missing) and rank[missing[mi]] < r_rank:
200
+ out.append(new_row[missing[mi]])
201
+ mi += 1
202
+ out.append(row["raw"])
203
+ out.extend(new_row[n] for n in missing[mi:])
204
+
205
+ lines = body.split("\n")
206
+ first = table["rows"][0]["line_idx"]
207
+ last = table["rows"][-1]["line_idx"]
208
+ lines[first:last + 1] = out
209
+ return "\n".join(lines), len(missing)
210
+
211
+
212
+ def _parse_row(line: str) -> list[str]:
213
+ s = line.strip()
214
+ if "|" not in s:
215
+ return []
216
+ if s.startswith("|"):
217
+ s = s[1:]
218
+ if s.endswith("|"):
219
+ s = s[:-1]
220
+ return s.split("|")
221
+
222
+
223
+ def _is_separator(line: str) -> bool:
224
+ s = line.strip()
225
+ if not s:
226
+ return False
227
+ return all(c in "|-: " for c in s)
@@ -0,0 +1,248 @@
1
+ """Discover tracks under notes_root and shared .work-plan/ dirs."""
2
+ import sys
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from lib.frontmatter import parse_file
8
+ from lib.config import (
9
+ resolve_github_for_folder,
10
+ resolve_local_path_for_folder,
11
+ is_valid_git_repo,
12
+ )
13
+
14
+
15
+ @dataclass
16
+ class Track:
17
+ path: Path
18
+ name: str
19
+ has_frontmatter: bool
20
+ needs_init: bool
21
+ needs_filing: bool
22
+ repo: Optional[str] = None
23
+ folder: Optional[str] = None
24
+ local_path: Optional[Path] = None
25
+ meta: dict = field(default_factory=dict)
26
+ body: str = ""
27
+ tier: Optional[str] = None
28
+
29
+
30
+ def discover_tracks(cfg: dict) -> list[Track]:
31
+ """Walk notes_root for active (non-archived) .md files, then union with
32
+ shared tracks from each configured repo's .work-plan/ directory.
33
+ Shared wins on (repo, name) collisions.
34
+ """
35
+ private = _discover_private_tracks(cfg, include_archive=False)
36
+ shared = _discover_shared_tracks(cfg, include_archive=False)
37
+
38
+ # Build lookup for shared tracks keyed by (repo, name)
39
+ shared_keys: dict = {}
40
+ for t in shared:
41
+ key = (t.repo, t.name)
42
+ shared_keys[key] = t
43
+
44
+ # Merge: private tracks that have no colliding shared track are kept
45
+ merged = list(shared)
46
+ for t in private:
47
+ key = (t.repo, t.name)
48
+ if key in shared_keys:
49
+ print(
50
+ f"WARN: track {t.name!r} (repo={t.repo!r}) exists in both shared"
51
+ f" ({shared_keys[key].path}) and private ({t.path}); using shared.",
52
+ file=sys.stderr,
53
+ )
54
+ else:
55
+ merged.append(t)
56
+
57
+ return merged
58
+
59
+
60
+ def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
61
+ """Filter tracks by repo. Matches the config-key folder name OR the
62
+ `org/repo` GitHub slug, so users can pass either. Case-insensitive."""
63
+ k = key.lower()
64
+ return [t for t in tracks
65
+ if (t.folder and t.folder.lower() == k)
66
+ or (t.repo and t.repo.lower() == k)]
67
+
68
+
69
+ class AmbiguousTrackError(Exception):
70
+ """Raised when a track name matches more than one track across repos."""
71
+
72
+ def __init__(self, name: str, candidates: list[Track]):
73
+ self.name = name
74
+ self.candidates = candidates
75
+ repos = [f" {t.name} (repo: {t.repo or t.folder!r})" for t in candidates]
76
+ super().__init__(
77
+ f"Track {name!r} is ambiguous — found in {len(candidates)} repos:\n"
78
+ + "\n".join(repos)
79
+ + f"\nUse --repo=<key> or '{name}@<repo>' to disambiguate."
80
+ )
81
+
82
+
83
+ def parse_track_repo_arg(arg: str) -> tuple:
84
+ """Split 'trackname@repokey' into (trackname, repokey); return (arg, None) if no @."""
85
+ if "@" in arg:
86
+ name, _, repo = arg.rpartition("@")
87
+ return (name, repo) if name else (arg, None)
88
+ return (arg, None)
89
+
90
+
91
+ def find_track_by_name(
92
+ name: str, tracks: list[Track],
93
+ *, active_only: bool = False, repo: Optional[str] = None
94
+ ) -> Optional[Track]:
95
+ """Find a single Track matching `name` (filename stem OR frontmatter `track`).
96
+
97
+ If repo is given, first filter to tracks matching that repo (folder key or
98
+ GitHub slug, case-insensitive). Then find a single name match.
99
+
100
+ If active_only=True, only considers tracks with status active/in-progress/blocked.
101
+
102
+ Returns the single match or None (0 matches).
103
+ Raises AmbiguousTrackError if 2+ matches remain after filtering.
104
+ """
105
+ candidates = tracks
106
+ if repo:
107
+ candidates = filter_tracks_by_repo(candidates, repo)
108
+ if active_only:
109
+ candidates = [t for t in candidates if t.has_frontmatter
110
+ and t.meta.get("status") in ("active", "in-progress", "blocked")]
111
+ matching = [t for t in candidates if t.has_frontmatter
112
+ and (t.name == name or t.meta.get("track") == name)]
113
+ if len(matching) <= 1:
114
+ return matching[0] if matching else None
115
+ raise AmbiguousTrackError(name, matching)
116
+
117
+
118
+ def discover_archived_tracks(cfg: dict) -> list[Track]:
119
+ """Walk notes_root for archived .md files, and also scan each repo's
120
+ .work-plan/archive/ for shared archived tracks."""
121
+ notes_root = Path(cfg["notes_root"]).expanduser()
122
+ out = []
123
+ if notes_root.exists():
124
+ for md_path in sorted(notes_root.rglob("*.md")):
125
+ if "archive" not in md_path.parts:
126
+ continue
127
+ if md_path.name.startswith((".", "_")):
128
+ continue
129
+ out.append(_build_track(md_path, notes_root, cfg))
130
+
131
+ # Also scan shared repos' .work-plan/archive/
132
+ out.extend(_discover_shared_tracks(cfg, include_archive=True,
133
+ archive_only=True))
134
+ return out
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Private helpers
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def _discover_private_tracks(cfg: dict, include_archive: bool) -> list[Track]:
142
+ notes_root = Path(cfg["notes_root"]).expanduser()
143
+ if not notes_root.exists():
144
+ return []
145
+ return _walk(notes_root, cfg, include_archive=include_archive)
146
+
147
+
148
+ def _discover_shared_tracks(cfg: dict, include_archive: bool = False,
149
+ archive_only: bool = False) -> list[Track]:
150
+ """Walk each configured repo's local clone .work-plan/ directory."""
151
+ out: list[Track] = []
152
+ repos = cfg.get("repos", {})
153
+ for folder_key, entry in repos.items():
154
+ if not entry or not entry.get("local"):
155
+ continue
156
+ local_path = Path(entry["local"]).expanduser()
157
+ if not is_valid_git_repo(local_path):
158
+ continue
159
+ github_repo = entry.get("github")
160
+ notes_dir = local_path / ".work-plan"
161
+ if not notes_dir.is_dir():
162
+ continue
163
+ for md_path in sorted(notes_dir.rglob("*.md")):
164
+ # Skip dotfiles and README
165
+ if md_path.name.startswith(".") or md_path.name == "README.md":
166
+ continue
167
+ in_archive = "archive" in md_path.relative_to(notes_dir).parts
168
+ if archive_only and not in_archive:
169
+ continue
170
+ if not include_archive and in_archive:
171
+ continue
172
+ out.append(_build_shared_track(
173
+ md_path, folder_key, github_repo, local_path
174
+ ))
175
+ return out
176
+
177
+
178
+ def _build_shared_track(md_path: Path, folder_key: str,
179
+ github_repo: Optional[str], local_path: Path) -> Track:
180
+ """Build a Track from a shared .work-plan/ markdown file."""
181
+ meta, body = parse_file(md_path)
182
+ has_fm = bool(meta)
183
+
184
+ # Single-owner rule: if frontmatter disagrees with folder config, warn and
185
+ # use the folder's configured github repo (never the frontmatter value).
186
+ if has_fm and meta.get("github", {}).get("repo"):
187
+ fm_repo = meta["github"]["repo"]
188
+ if fm_repo != github_repo:
189
+ print(
190
+ f"WARN: shared track {md_path.name!r} frontmatter github.repo"
191
+ f" differs from folder config; using folder {github_repo!r}",
192
+ file=sys.stderr,
193
+ )
194
+
195
+ return Track(
196
+ path=md_path,
197
+ name=md_path.stem,
198
+ has_frontmatter=has_fm,
199
+ needs_init=False,
200
+ needs_filing=False,
201
+ repo=github_repo,
202
+ folder=folder_key,
203
+ local_path=local_path,
204
+ meta=meta,
205
+ body=body,
206
+ tier="shared",
207
+ )
208
+
209
+
210
+ def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
211
+ out = []
212
+ for md_path in sorted(notes_root.rglob("*.md")):
213
+ if not include_archive and "archive" in md_path.parts:
214
+ continue
215
+ if md_path.name.startswith((".", "_")):
216
+ continue
217
+ out.append(_build_track(md_path, notes_root, cfg))
218
+ return out
219
+
220
+
221
+ def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> "Track":
222
+ meta, body = parse_file(md_path)
223
+ has_fm = bool(meta)
224
+ rel = md_path.relative_to(notes_root)
225
+ in_subfolder = len(rel.parts) > 1
226
+ folder_name = rel.parts[0] if in_subfolder else None
227
+
228
+ repo = None
229
+ if has_fm and meta.get("github", {}).get("repo"):
230
+ repo = meta["github"]["repo"]
231
+ elif folder_name:
232
+ repo = resolve_github_for_folder(folder_name, cfg)
233
+
234
+ local = resolve_local_path_for_folder(folder_name, cfg) if folder_name else None
235
+
236
+ return Track(
237
+ path=md_path,
238
+ name=md_path.stem,
239
+ has_frontmatter=has_fm,
240
+ needs_init=in_subfolder and not has_fm,
241
+ needs_filing=not in_subfolder,
242
+ repo=repo,
243
+ folder=folder_name,
244
+ local_path=local,
245
+ meta=meta,
246
+ body=body,
247
+ tier="private",
248
+ )
@@ -0,0 +1,51 @@
1
+ """Pure verdict classification over gathered evidence. No I/O — fully unit-testable.
2
+
3
+ Thresholds are module constants so a later phase can make them configurable
4
+ without touching call sites.
5
+ """
6
+ from dataclasses import dataclass
7
+ from datetime import date
8
+ from typing import Optional
9
+
10
+ SHIPPED_PCT = 80.0 # >= this % of declared files satisfied -> shipped
11
+ PARTIAL_PCT = 20.0 # >= this % -> partial
12
+ BOXES_STALE_PCT = 50.0 # checked-box % below this on a shipped plan -> "boxes stale"
13
+ DEAD_DAYS = 60 # 0 files satisfied AND untouched beyond this -> dead
14
+ FOREIGN_RATIO = 0.7 # >= this fraction of declared paths outside repo -> foreign
15
+
16
+
17
+ @dataclass
18
+ class Verdict:
19
+ label: str # shipped | partial | dead | foreign | manifest-less
20
+ glyph: str
21
+ rationale: str
22
+
23
+
24
+ def classify(
25
+ score,
26
+ checkbox_done: int,
27
+ checkbox_total: int,
28
+ last_touched: Optional[date],
29
+ today: date,
30
+ dead_days: int = DEAD_DAYS,
31
+ ) -> Verdict:
32
+ if score.total == 0:
33
+ return Verdict("manifest-less", "\U0001f47b",
34
+ "no file-manifest — needs LLM verdict (Phase 1b)")
35
+
36
+ pct = score.pct
37
+ files = f"{score.satisfied}/{score.total} declared files present"
38
+
39
+ if pct >= SHIPPED_PCT:
40
+ chk_pct = (checkbox_done / checkbox_total * 100.0) if checkbox_total else 0.0
41
+ stale = " (boxes stale)" if chk_pct < BOXES_STALE_PCT else ""
42
+ return Verdict("shipped", "✅", f"{files}{stale}")
43
+
44
+ if pct >= PARTIAL_PCT:
45
+ return Verdict("partial", "\U0001f7e1", files)
46
+
47
+ if last_touched is not None and (today - last_touched).days > dead_days:
48
+ age = (today - last_touched).days
49
+ return Verdict("dead", "\U0001f480", f"{files}, untouched {age}d")
50
+
51
+ return Verdict("partial", "\U0001f7e1", f"{files} (early)")
@@ -0,0 +1,39 @@
1
+ """Confirm-token gate so non-interactive callers (the VS Code extension) can
2
+ surface the public-repo heads-up as their own dialog instead of a TTY prompt.
3
+
4
+ needs_confirm() fails CLOSED: PUBLIC or unknown visibility both require confirm.
5
+ Unknown visibility can be opted out of via cfg["assume_private_when_unknown"]=True
6
+ (for all-private teams that want to avoid the prompt on transient gh failures).
7
+ PUBLIC is never suppressed by this flag — the leak guarantee is unconditional.
8
+
9
+ The token is a deterministic hash of (repo, track) — no randomness (3.9 stdlib,
10
+ and stable so the re-invocation matches). It is not a security boundary; it just
11
+ proves the caller saw the heads-up for THIS write."""
12
+ import hashlib
13
+ from lib.github_state import repo_visibility
14
+
15
+
16
+ def needs_confirm(repo: str, cfg: dict = None) -> bool:
17
+ """True when a write to `repo` needs the public-repo confirm heads-up.
18
+
19
+ PUBLIC → always True (never suppressed — the leak guarantee).
20
+ PRIVATE → False.
21
+ Unknown visibility (gh couldn't say / offline) → True (fail CLOSED) UNLESS
22
+ cfg opts out via `assume_private_when_unknown: true`, which lets an
23
+ all-private team avoid the prompt on transient gh-lookup failures. PUBLIC is
24
+ never affected by this flag."""
25
+ vis = repo_visibility(repo)
26
+ if vis == "PUBLIC":
27
+ return True
28
+ if vis == "PRIVATE":
29
+ return False
30
+ # vis is None → unknown / offline. Fail closed unless explicitly opted out.
31
+ if cfg and cfg.get("assume_private_when_unknown"):
32
+ return False
33
+ return True
34
+
35
+ def make_token(repo: str, track: str) -> str:
36
+ return hashlib.sha256(f"{repo}::{track}".encode("utf-8")).hexdigest()[:16]
37
+
38
+ def valid_token(token: str, repo: str, track: str) -> bool:
39
+ return bool(token) and token == make_token(repo, track)
File without changes
@@ -0,0 +1,10 @@
1
+ ---
2
+ track: old
3
+ status: shipped
4
+ launch_priority: P2
5
+ github:
6
+ repo: stylusnexus/CritForge
7
+ issues: [50]
8
+ ---
9
+
10
+ # Old shipped track
@@ -0,0 +1,11 @@
1
+ ---
2
+ track: example
3
+ status: active
4
+ launch_priority: P1
5
+ github:
6
+ repo: stylusnexus/CritForge
7
+ issues: [100, 200]
8
+ next_up: [100]
9
+ ---
10
+
11
+ # Example
@@ -0,0 +1,14 @@
1
+ ---
2
+ track: tabletop
3
+ status: active
4
+ launch_priority: P1
5
+ github:
6
+ repo: stylusnexus/CritForge
7
+ issues: [4254, 4127]
8
+ branches: []
9
+ next_up: [4254]
10
+ ---
11
+
12
+ # Tabletop
13
+
14
+ Body content.
@@ -0,0 +1,3 @@
1
+ # Some plan
2
+
3
+ Body only.
@@ -0,0 +1,9 @@
1
+ # Track
2
+
3
+ ## Issues
4
+
5
+ | # | Title | Status |
6
+ |---|---|---|
7
+ | #4254 | admin polls | 🔲 Open |
8
+ | #4127 | dice roller | ✅ Shipped |
9
+ | #925 | wild magic | 🟡 In PR (#4137) |