@stylusnexus/work-plan 2026.6.9
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 +478 -0
- package/VERSION +1 -0
- package/bin/work-plan +36 -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 +119 -0
- package/skills/work-plan/commands/__init__.py +0 -0
- package/skills/work-plan/commands/brief.py +247 -0
- package/skills/work-plan/commands/canonicalize.py +122 -0
- package/skills/work-plan/commands/close.py +83 -0
- package/skills/work-plan/commands/duplicates.py +111 -0
- package/skills/work-plan/commands/export.py +69 -0
- package/skills/work-plan/commands/group.py +234 -0
- package/skills/work-plan/commands/handoff.py +855 -0
- package/skills/work-plan/commands/hygiene.py +104 -0
- package/skills/work-plan/commands/init.py +96 -0
- package/skills/work-plan/commands/init_repo.py +90 -0
- package/skills/work-plan/commands/list_cmd.py +39 -0
- package/skills/work-plan/commands/new_track.py +148 -0
- package/skills/work-plan/commands/plan_status.py +296 -0
- package/skills/work-plan/commands/reconcile.py +172 -0
- package/skills/work-plan/commands/refresh_md.py +132 -0
- package/skills/work-plan/commands/set_field.py +54 -0
- package/skills/work-plan/commands/set_notes_root.py +53 -0
- package/skills/work-plan/commands/slot.py +139 -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 +82 -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 +40 -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/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 +109 -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_close.py +273 -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_doc_discovery.py +51 -0
- package/skills/work-plan/tests/test_drift.py +38 -0
- package/skills/work-plan/tests/test_export.py +91 -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_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 +251 -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 +445 -0
- package/skills/work-plan/tests/test_next_up.py +149 -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 +166 -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_tracks.py +56 -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 +210 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Shared CLI helpers: prompts and arg parsing."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def prompt_input(message: str, default: str = "") -> str:
|
|
5
|
+
"""Print prompt and read a free-form line. Treats EOF (no stdin) as default.
|
|
6
|
+
|
|
7
|
+
Returns the stripped input, or `default` if EOF or blank.
|
|
8
|
+
"""
|
|
9
|
+
print(message)
|
|
10
|
+
try:
|
|
11
|
+
line = input().strip()
|
|
12
|
+
except EOFError:
|
|
13
|
+
return default
|
|
14
|
+
return line if line else default
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def prompt_lines() -> list[str]:
|
|
18
|
+
"""Read lines from stdin until blank line or EOF. Returns list of non-blank lines."""
|
|
19
|
+
out = []
|
|
20
|
+
try:
|
|
21
|
+
while True:
|
|
22
|
+
line = input().rstrip()
|
|
23
|
+
if not line:
|
|
24
|
+
break
|
|
25
|
+
out.append(line)
|
|
26
|
+
except EOFError:
|
|
27
|
+
pass
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def prompt_yes_no(message: str = "Apply? [y/N]") -> bool:
|
|
32
|
+
"""Print prompt and read y/N. Treats EOF (no stdin) as no.
|
|
33
|
+
|
|
34
|
+
Returns True only if user explicitly types 'y' (case-insensitive).
|
|
35
|
+
"""
|
|
36
|
+
print(message)
|
|
37
|
+
try:
|
|
38
|
+
choice = input().strip().lower()
|
|
39
|
+
except EOFError:
|
|
40
|
+
print("(no input — cancelled)")
|
|
41
|
+
return False
|
|
42
|
+
return choice == "y"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_flags(args: list[str], known: set[str]) -> tuple[dict, list[str]]:
|
|
46
|
+
"""Split CLI args into recognized flags + positional args.
|
|
47
|
+
|
|
48
|
+
`known` is the set of flag names this command supports (e.g. {"--all", "--yes"}).
|
|
49
|
+
For `--key=value` flags, key.split("=", 1)[0] is matched against `known`.
|
|
50
|
+
|
|
51
|
+
Returns: (flags_dict, positional_list).
|
|
52
|
+
- flags_dict: {"--all": True, "--repo": "critforge", ...} for flags found.
|
|
53
|
+
- positional_list: args that aren't flags.
|
|
54
|
+
|
|
55
|
+
Unknown flags are passed through as positional args (caller decides what to do).
|
|
56
|
+
"""
|
|
57
|
+
flags = {}
|
|
58
|
+
positional = []
|
|
59
|
+
for arg in args:
|
|
60
|
+
if not arg.startswith("--"):
|
|
61
|
+
positional.append(arg)
|
|
62
|
+
continue
|
|
63
|
+
key, _, val = arg.partition("=")
|
|
64
|
+
if key in known:
|
|
65
|
+
flags[key] = val if val else True
|
|
66
|
+
else:
|
|
67
|
+
positional.append(arg)
|
|
68
|
+
return flags, positional
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Pure helpers for the gated reconcile actions: select actionable rows, compute
|
|
2
|
+
an archive destination, and build the issue title/body for a partial plan.
|
|
3
|
+
"""
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def dead_rows(rows: list) -> list:
|
|
8
|
+
return [r for r in rows if r["verdict"] == "dead"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def partial_rows(rows: list) -> list:
|
|
12
|
+
return [r for r in rows if r["verdict"] == "partial"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def archive_dest(rel: str) -> str:
|
|
16
|
+
"""docs/.../plans/x.md -> docs/.../plans/archive/abandoned/x.md"""
|
|
17
|
+
p = PurePosixPath(rel)
|
|
18
|
+
return str(p.parent / "archive" / "abandoned" / p.name)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def issue_for(doc, row, unsatisfied) -> tuple:
|
|
22
|
+
"""Build (title, body) for a partial plan's follow-up issue."""
|
|
23
|
+
stem = PurePosixPath(doc.rel).stem
|
|
24
|
+
title = f"Finish plan: {stem}"
|
|
25
|
+
lines = [
|
|
26
|
+
f"Plan `{doc.rel}` is **partial** "
|
|
27
|
+
f"({row['files_present']}/{row['files_declared']} declared files present).",
|
|
28
|
+
"",
|
|
29
|
+
"Unsatisfied files:",
|
|
30
|
+
]
|
|
31
|
+
for d in unsatisfied:
|
|
32
|
+
lines.append(f"- [ ] {d.kind}: `{d.path}`")
|
|
33
|
+
lines += ["", "_Opened by `work-plan plan-status --issues`._"]
|
|
34
|
+
return title, "\n".join(lines)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Compose terminal output strings."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def time_aware_framing(gap_seconds: int, current_hour: int, handoff_today: bool = True) -> str:
|
|
5
|
+
"""Adapt framing to gap-since-last-activity + hour."""
|
|
6
|
+
six_hours = 6 * 3600
|
|
7
|
+
one_hour = 3600
|
|
8
|
+
|
|
9
|
+
if gap_seconds > six_hours or current_hour < 11:
|
|
10
|
+
line = "Fresh start. Here's what changed since you stepped away."
|
|
11
|
+
elif gap_seconds >= one_hour:
|
|
12
|
+
line = "Picking back up. Here's what was active when you stepped away."
|
|
13
|
+
else:
|
|
14
|
+
line = "Continuing. Drift since last brief:"
|
|
15
|
+
|
|
16
|
+
if current_hour >= 23 and not handoff_today:
|
|
17
|
+
line += "\n Want a handoff before bed? Run /work-plan handoff [track]."
|
|
18
|
+
|
|
19
|
+
return line
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_track_row(t: dict) -> str:
|
|
23
|
+
"""Render one track block in the brief output."""
|
|
24
|
+
lines = []
|
|
25
|
+
|
|
26
|
+
badge_parts = []
|
|
27
|
+
if t["operational_status"] == "in-progress":
|
|
28
|
+
badge_parts.append("in-progress")
|
|
29
|
+
elif t["operational_status"] == "blocked":
|
|
30
|
+
badge_parts.append("blocked")
|
|
31
|
+
badge_parts.append(t["launch_priority"])
|
|
32
|
+
badge_parts.append(t["milestone_alignment"])
|
|
33
|
+
badge_parts.append(f"last touched {t['last_touched_label']}, last handoff {t['last_handoff_label']}")
|
|
34
|
+
lines.append(f"▸ {t['name']} ({' · '.join(badge_parts)})")
|
|
35
|
+
|
|
36
|
+
if t["next_up"]:
|
|
37
|
+
for idx, item in enumerate(t["next_up"]):
|
|
38
|
+
bits = [item["priority"], item["state"]]
|
|
39
|
+
if item.get("milestone"):
|
|
40
|
+
bits.append(item["milestone"])
|
|
41
|
+
label = f"#{item['number']} {item['title']} ({', '.join(bits)})"
|
|
42
|
+
prefix = " Up next: " if idx == 0 else " "
|
|
43
|
+
lines.append(prefix + label)
|
|
44
|
+
elif t.get("next_up_stale_closed_count"):
|
|
45
|
+
n = t["next_up_stale_closed_count"]
|
|
46
|
+
slug = t.get("track_slug") or t["name"]
|
|
47
|
+
plural = "item has" if n == 1 else "items have"
|
|
48
|
+
lines.append(f" Up next: <all {n} {plural} shipped — "
|
|
49
|
+
f"run /work-plan handoff {slug} to rotate>")
|
|
50
|
+
else:
|
|
51
|
+
lines.append(" Up next: <empty — set 'next_up:' or all items show backlog>")
|
|
52
|
+
|
|
53
|
+
for b in t["active_branches"]:
|
|
54
|
+
ahead = f"ahead {b['ahead']}" if b["ahead"] else "no commits ahead"
|
|
55
|
+
uc = f", uncommitted: {b['uncommitted_files']} file(s)" if b["uncommitted_files"] else ""
|
|
56
|
+
lines.append(f" Active: {b['name']} ({ahead}{uc})")
|
|
57
|
+
|
|
58
|
+
for n in t["new_issues"]:
|
|
59
|
+
lines.append(f" New: #{n['number']} {n['title']} — slot? [run: /work-plan slot {n['number']}]")
|
|
60
|
+
|
|
61
|
+
if t["blockers"]:
|
|
62
|
+
for b in t["blockers"]:
|
|
63
|
+
reason = b.get("reason", "manually flagged")
|
|
64
|
+
lines.append(f" Blocker: #{b['number']} — {reason}")
|
|
65
|
+
else:
|
|
66
|
+
lines.append(" Blockers: none")
|
|
67
|
+
|
|
68
|
+
if t["drift_items"]:
|
|
69
|
+
items = ", ".join(f"#{d['issue']}" for d in t["drift_items"])
|
|
70
|
+
lines.append(f" Drift: {items} — body says open but GitHub says closed (or vice versa). "
|
|
71
|
+
f"Run /work-plan refresh-md {t['name']}")
|
|
72
|
+
|
|
73
|
+
if t["closure_ready"]:
|
|
74
|
+
lines.append(f" Closure?: YES — run /work-plan close {t['name']}")
|
|
75
|
+
elif t.get("closure_signals_summary"):
|
|
76
|
+
lines.append(f" Closure?: {t['closure_signals_summary']}")
|
|
77
|
+
|
|
78
|
+
return "\n".join(lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_archived_reopen(repo: str, slug: str, issue: dict) -> str:
|
|
82
|
+
return (f"⚠ archive/{slug}.md (shipped) — new issue #{issue['number']} "
|
|
83
|
+
f"matches this slug. Re-open or slot into a different track?")
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Per-user scratch directory for inter-invocation state.
|
|
2
|
+
|
|
3
|
+
Replaces /tmp/ for the two-step AI subcommands (`group`, `suggest-priorities`)
|
|
4
|
+
so batch + answers files can't be planted by other same-UID processes (#18).
|
|
5
|
+
"""
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cache_dir() -> Path:
|
|
10
|
+
"""Return ~/.claude/work-plan/cache/, created mode 0700 if missing."""
|
|
11
|
+
p = Path.home() / ".claude" / "work-plan" / "cache"
|
|
12
|
+
p.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
13
|
+
p.chmod(0o700)
|
|
14
|
+
return p
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Append session log entries to track body."""
|
|
2
|
+
|
|
3
|
+
SESSION_LOG_HEADER = "## Session log"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def append_session_log(body: str, timestamp: str,
|
|
7
|
+
touched: list[str], next_up: list[str],
|
|
8
|
+
blockers: list[dict]) -> str:
|
|
9
|
+
"""Append a `### Session — <timestamp>` block under the Session log section."""
|
|
10
|
+
block_lines = [f"### Session — {timestamp}\n"]
|
|
11
|
+
if touched:
|
|
12
|
+
for t in touched:
|
|
13
|
+
block_lines.append(f"- Touched: {t}")
|
|
14
|
+
else:
|
|
15
|
+
block_lines.append("- Touched: (nothing committed)")
|
|
16
|
+
if next_up:
|
|
17
|
+
for n in next_up:
|
|
18
|
+
block_lines.append(f"- Next: {n}")
|
|
19
|
+
else:
|
|
20
|
+
block_lines.append("- Next: (open)")
|
|
21
|
+
if blockers:
|
|
22
|
+
for b in blockers:
|
|
23
|
+
block_lines.append(f"- Blocker: #{b['number']} — {b['reason']}")
|
|
24
|
+
block_lines.append("")
|
|
25
|
+
block = "\n".join(block_lines)
|
|
26
|
+
|
|
27
|
+
if SESSION_LOG_HEADER in body:
|
|
28
|
+
idx = body.index(SESSION_LOG_HEADER)
|
|
29
|
+
rest = body[idx + len(SESSION_LOG_HEADER):]
|
|
30
|
+
next_h2 = rest.find("\n## ")
|
|
31
|
+
if next_h2 == -1:
|
|
32
|
+
insertion = rest + "\n" + block
|
|
33
|
+
else:
|
|
34
|
+
insertion = rest[:next_h2] + "\n" + block + rest[next_h2:]
|
|
35
|
+
return body[:idx] + SESSION_LOG_HEADER + insertion
|
|
36
|
+
|
|
37
|
+
if not body.endswith("\n"):
|
|
38
|
+
body += "\n"
|
|
39
|
+
return body + f"\n{SESSION_LOG_HEADER}\n\n{block}"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Idempotent status-header stamping for plan/spec docs.
|
|
2
|
+
|
|
3
|
+
The block is derived ENTIRELY from evidence (no volatile timestamp), so
|
|
4
|
+
re-stamping with unchanged evidence yields a byte-identical document.
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
BEGIN = "<!-- plan-status: BEGIN -->"
|
|
9
|
+
END = "<!-- plan-status: END -->"
|
|
10
|
+
|
|
11
|
+
_BLOCK_RE = re.compile(re.escape(BEGIN) + r".*?" + re.escape(END), re.DOTALL)
|
|
12
|
+
# Orphan (unpaired) BEGIN/END markers left by a truncated edit or bad merge.
|
|
13
|
+
_ORPHAN_RE = re.compile(
|
|
14
|
+
r"^[ \t]*(?:" + re.escape(BEGIN) + r"|" + re.escape(END) + r")[ \t]*\n?",
|
|
15
|
+
re.MULTILINE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def render_block(row: dict) -> str:
|
|
20
|
+
"""Render the delimited status block from an evaluated row dict."""
|
|
21
|
+
last = row.get("last_touched") or "unknown"
|
|
22
|
+
line = (
|
|
23
|
+
f"> **Status:** {row['glyph']} {row['verdict']} · "
|
|
24
|
+
f"{row['files_present']}/{row['files_declared']} files · "
|
|
25
|
+
f"last touched {last}"
|
|
26
|
+
)
|
|
27
|
+
return f"{BEGIN}\n{line}\n{END}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stamp(text: str, row: dict) -> str:
|
|
31
|
+
"""Insert or replace the status block. Idempotent for unchanged evidence.
|
|
32
|
+
|
|
33
|
+
Hardening: if a doc somehow contains multiple complete blocks, the first is
|
|
34
|
+
refreshed in place and the rest are removed (no permanently-stale duplicate).
|
|
35
|
+
If it contains an orphan (unpaired) marker, that is stripped before inserting
|
|
36
|
+
so a second block can't be stacked on top.
|
|
37
|
+
"""
|
|
38
|
+
block = render_block(row)
|
|
39
|
+
if _BLOCK_RE.search(text):
|
|
40
|
+
# Replace the first block in place (preserving surrounding whitespace),
|
|
41
|
+
# and drop any additional blocks so none goes stale.
|
|
42
|
+
seen = {"first": False}
|
|
43
|
+
|
|
44
|
+
def _sub(_m):
|
|
45
|
+
if not seen["first"]:
|
|
46
|
+
seen["first"] = True
|
|
47
|
+
return block
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
return _BLOCK_RE.sub(_sub, text)
|
|
51
|
+
|
|
52
|
+
# No complete block. Clear any orphan markers (corrupted/dangling) so we
|
|
53
|
+
# don't stack a duplicate block on top. No-op on well-formed docs.
|
|
54
|
+
text = _ORPHAN_RE.sub("", text)
|
|
55
|
+
lines = text.splitlines(keepends=True)
|
|
56
|
+
for i, ln in enumerate(lines):
|
|
57
|
+
if ln.startswith("# "):
|
|
58
|
+
lines.insert(i + 1, "\n" + block + "\n")
|
|
59
|
+
return "".join(lines)
|
|
60
|
+
return block + "\n\n" + text
|
|
@@ -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,109 @@
|
|
|
1
|
+
"""Discover tracks under notes_root."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from lib.frontmatter import parse_file
|
|
7
|
+
from lib.config import resolve_github_for_folder, resolve_local_path_for_folder
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Track:
|
|
12
|
+
path: Path
|
|
13
|
+
name: str
|
|
14
|
+
has_frontmatter: bool
|
|
15
|
+
needs_init: bool
|
|
16
|
+
needs_filing: bool
|
|
17
|
+
repo: Optional[str] = None
|
|
18
|
+
folder: Optional[str] = None
|
|
19
|
+
local_path: Optional[Path] = None
|
|
20
|
+
meta: dict = field(default_factory=dict)
|
|
21
|
+
body: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
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)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def filter_tracks_by_repo(tracks: list[Track], key: str) -> list[Track]:
|
|
33
|
+
"""Filter tracks by repo. Matches the config-key folder name OR the
|
|
34
|
+
`org/repo` GitHub slug, so users can pass either. Case-insensitive."""
|
|
35
|
+
k = key.lower()
|
|
36
|
+
return [t for t in tracks
|
|
37
|
+
if (t.folder and t.folder.lower() == k)
|
|
38
|
+
or (t.repo and t.repo.lower() == k)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_track_by_name(name: str, tracks: list[Track],
|
|
42
|
+
*, active_only: bool = False) -> Optional[Track]:
|
|
43
|
+
"""Find a single Track matching `name` (filename stem OR frontmatter `track`).
|
|
44
|
+
|
|
45
|
+
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.
|
|
47
|
+
"""
|
|
48
|
+
candidates = tracks
|
|
49
|
+
if active_only:
|
|
50
|
+
candidates = [t for t in candidates if t.has_frontmatter
|
|
51
|
+
and t.meta.get("status") in ("active", "in-progress", "blocked")]
|
|
52
|
+
matching = [t for t in candidates if t.has_frontmatter
|
|
53
|
+
and (t.name == name or t.meta.get("track") == name)]
|
|
54
|
+
return matching[0] if len(matching) == 1 else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def discover_archived_tracks(cfg: dict) -> list[Track]:
|
|
58
|
+
"""Walk notes_root for archived .md files only."""
|
|
59
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
60
|
+
if not notes_root.exists():
|
|
61
|
+
return []
|
|
62
|
+
out = []
|
|
63
|
+
for md_path in sorted(notes_root.rglob("*.md")):
|
|
64
|
+
if "archive" not in md_path.parts:
|
|
65
|
+
continue
|
|
66
|
+
if md_path.name.startswith((".", "_")):
|
|
67
|
+
continue
|
|
68
|
+
out.append(_build_track(md_path, notes_root, cfg))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _walk(notes_root: Path, cfg: dict, include_archive: bool) -> list[Track]:
|
|
73
|
+
out = []
|
|
74
|
+
for md_path in sorted(notes_root.rglob("*.md")):
|
|
75
|
+
if not include_archive and "archive" in md_path.parts:
|
|
76
|
+
continue
|
|
77
|
+
if md_path.name.startswith((".", "_")):
|
|
78
|
+
continue
|
|
79
|
+
out.append(_build_track(md_path, notes_root, cfg))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_track(md_path: Path, notes_root: Path, cfg: dict) -> Track:
|
|
84
|
+
meta, body = parse_file(md_path)
|
|
85
|
+
has_fm = bool(meta)
|
|
86
|
+
rel = md_path.relative_to(notes_root)
|
|
87
|
+
in_subfolder = len(rel.parts) > 1
|
|
88
|
+
folder_name = rel.parts[0] if in_subfolder else None
|
|
89
|
+
|
|
90
|
+
repo = None
|
|
91
|
+
if has_fm and meta.get("github", {}).get("repo"):
|
|
92
|
+
repo = meta["github"]["repo"]
|
|
93
|
+
elif folder_name:
|
|
94
|
+
repo = resolve_github_for_folder(folder_name, cfg)
|
|
95
|
+
|
|
96
|
+
local = resolve_local_path_for_folder(folder_name, cfg) if folder_name else None
|
|
97
|
+
|
|
98
|
+
return Track(
|
|
99
|
+
path=md_path,
|
|
100
|
+
name=md_path.stem,
|
|
101
|
+
has_frontmatter=has_fm,
|
|
102
|
+
needs_init=in_subfolder and not has_fm,
|
|
103
|
+
needs_filing=not in_subfolder,
|
|
104
|
+
repo=repo,
|
|
105
|
+
folder=folder_name,
|
|
106
|
+
local_path=local,
|
|
107
|
+
meta=meta,
|
|
108
|
+
body=body,
|
|
109
|
+
)
|