@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.
- package/LICENSE +21 -0
- package/README.md +554 -0
- package/VERSION +1 -0
- package/bin/work-plan +59 -0
- package/bin/work-plan.cmd +9 -0
- package/package.json +43 -0
- package/scripts/npm-check-deps.js +44 -0
- package/skills/work-plan/SKILL.md +152 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/auto_triage.py +230 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +139 -0
- package/skills/work-plan/commands/close.py +98 -0
- package/skills/work-plan/commands/coverage.py +100 -0
- package/skills/work-plan/commands/duplicates.py +124 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +272 -0
- package/skills/work-plan/commands/handoff.py +867 -0
- package/skills/work-plan/commands/hygiene.py +128 -0
- package/skills/work-plan/commands/init.py +128 -0
- package/skills/work-plan/commands/init_repo.py +132 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +225 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +225 -0
- package/skills/work-plan/commands/refresh_md.py +145 -0
- package/skills/work-plan/commands/set_field.py +61 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +154 -0
- package/skills/work-plan/commands/suggest_priorities.py +132 -0
- package/skills/work-plan/commands/where_was_i.py +325 -0
- package/skills/work-plan/lib/__init__.py +0 -0
- package/skills/work-plan/lib/closure.py +72 -0
- package/skills/work-plan/lib/config.py +88 -0
- package/skills/work-plan/lib/doc_discovery.py +41 -0
- package/skills/work-plan/lib/drift.py +32 -0
- package/skills/work-plan/lib/export_model.py +42 -0
- package/skills/work-plan/lib/frontmatter.py +48 -0
- package/skills/work-plan/lib/git_state.py +180 -0
- package/skills/work-plan/lib/github_state.py +296 -0
- package/skills/work-plan/lib/llm_evidence.py +45 -0
- package/skills/work-plan/lib/manifest.py +164 -0
- package/skills/work-plan/lib/new_issues.py +69 -0
- package/skills/work-plan/lib/next_up.py +98 -0
- package/skills/work-plan/lib/notes_readme.py +38 -0
- package/skills/work-plan/lib/prompts.py +68 -0
- package/skills/work-plan/lib/reconcile_actions.py +34 -0
- package/skills/work-plan/lib/render.py +83 -0
- package/skills/work-plan/lib/scratch.py +14 -0
- package/skills/work-plan/lib/session_log.py +39 -0
- package/skills/work-plan/lib/status_header.py +60 -0
- package/skills/work-plan/lib/status_table.py +227 -0
- package/skills/work-plan/lib/tracks.py +248 -0
- package/skills/work-plan/lib/verdict.py +51 -0
- package/skills/work-plan/lib/write_guard.py +39 -0
- package/skills/work-plan/tests/__init__.py +0 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
- package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
- package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
- package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
- package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
- package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
- package/skills/work-plan/tests/test_auto_triage.py +324 -0
- package/skills/work-plan/tests/test_close.py +273 -0
- package/skills/work-plan/tests/test_close_tier.py +166 -0
- package/skills/work-plan/tests/test_closure.py +51 -0
- package/skills/work-plan/tests/test_config.py +85 -0
- package/skills/work-plan/tests/test_config_seed.py +41 -0
- 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_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +169 -0
- package/skills/work-plan/tests/test_export_command.py +295 -0
- package/skills/work-plan/tests/test_frontmatter.py +52 -0
- package/skills/work-plan/tests/test_git_state.py +51 -0
- package/skills/work-plan/tests/test_git_state_paths.py +51 -0
- package/skills/work-plan/tests/test_github_state.py +508 -0
- package/skills/work-plan/tests/test_group_apply.py +348 -0
- package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
- package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
- package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
- package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
- package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
- package/skills/work-plan/tests/test_init.py +289 -0
- package/skills/work-plan/tests/test_init_repo.py +379 -0
- package/skills/work-plan/tests/test_init_shared.py +185 -0
- package/skills/work-plan/tests/test_llm_evidence.py +77 -0
- package/skills/work-plan/tests/test_manifest.py +162 -0
- package/skills/work-plan/tests/test_new_issues.py +130 -0
- package/skills/work-plan/tests/test_new_track.py +610 -0
- package/skills/work-plan/tests/test_next_up.py +149 -0
- package/skills/work-plan/tests/test_notes_readme.py +78 -0
- package/skills/work-plan/tests/test_plan_status.py +68 -0
- package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
- package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
- package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
- package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
- package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
- package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
- package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
- package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
- package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
- package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
- package/skills/work-plan/tests/test_refresh_md.py +98 -0
- package/skills/work-plan/tests/test_render.py +110 -0
- package/skills/work-plan/tests/test_repo_filter.py +52 -0
- package/skills/work-plan/tests/test_security_hardening.py +117 -0
- package/skills/work-plan/tests/test_session_log.py +39 -0
- package/skills/work-plan/tests/test_set_field.py +77 -0
- package/skills/work-plan/tests/test_set_notes_root.py +292 -0
- package/skills/work-plan/tests/test_slot.py +243 -0
- package/skills/work-plan/tests/test_slot_move.py +128 -0
- package/skills/work-plan/tests/test_smoke.py +46 -0
- package/skills/work-plan/tests/test_status_header.py +79 -0
- package/skills/work-plan/tests/test_status_table.py +162 -0
- package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
- package/skills/work-plan/tests/test_track_resolution.py +295 -0
- package/skills/work-plan/tests/test_tracks.py +385 -0
- package/skills/work-plan/tests/test_verdict.py +60 -0
- package/skills/work-plan/tests/test_where_was_i.py +382 -0
- package/skills/work-plan/tests/test_write_guard.py +53 -0
- 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 @@
|
|
|
1
|
+
# No frontmatter
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Loose
|