@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,69 @@
|
|
|
1
|
+
"""Detect new GitHub issues that should slot into existing tracks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from lib.github_state import fetch_recent_issues
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_slug_labels(tracks) -> dict[str, list[str]]:
|
|
11
|
+
"""Build a {slug: [labels]} map from tracks with `github.labels` frontmatter.
|
|
12
|
+
|
|
13
|
+
Slugs without an explicit `github.labels` are omitted — callers fall back
|
|
14
|
+
to the default `track/<slug>` pattern in that case.
|
|
15
|
+
"""
|
|
16
|
+
out: dict[str, list[str]] = {}
|
|
17
|
+
for t in tracks:
|
|
18
|
+
if not getattr(t, "has_frontmatter", False):
|
|
19
|
+
continue
|
|
20
|
+
slug = t.meta.get("track", t.name)
|
|
21
|
+
labels = t.meta.get("github", {}).get("labels")
|
|
22
|
+
if labels:
|
|
23
|
+
out[slug] = [str(lab) for lab in labels if str(lab).strip()]
|
|
24
|
+
return out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def match_issue_to_tracks(issue: dict, track_slugs: list[str],
|
|
28
|
+
*, slug_labels: dict[str, list[str]] | None = None) -> list[str]:
|
|
29
|
+
"""Return slugs of tracks this issue might belong to.
|
|
30
|
+
|
|
31
|
+
1. Configured label match → exact match. Each slug uses its own labels from
|
|
32
|
+
`slug_labels` if provided, else falls back to `[track/<slug>]`.
|
|
33
|
+
2. Slug words appear in title → fuzzy match (all >=3-char words must appear).
|
|
34
|
+
"""
|
|
35
|
+
label_names = {l["name"] for l in issue.get("labels", [])}
|
|
36
|
+
title_lower = issue.get("title", "").lower()
|
|
37
|
+
overrides = slug_labels or {}
|
|
38
|
+
|
|
39
|
+
matches = set()
|
|
40
|
+
for slug in track_slugs:
|
|
41
|
+
labels_for_slug = overrides.get(slug) or [f"track/{slug}"]
|
|
42
|
+
if any(lab in label_names for lab in labels_for_slug):
|
|
43
|
+
matches.add(slug)
|
|
44
|
+
|
|
45
|
+
for slug in track_slugs:
|
|
46
|
+
if slug in matches:
|
|
47
|
+
continue
|
|
48
|
+
words = [w for w in re.split(r"[-_]", slug) if len(w) >= 3]
|
|
49
|
+
if not words:
|
|
50
|
+
continue
|
|
51
|
+
if all(w.lower() in title_lower for w in words):
|
|
52
|
+
matches.add(slug)
|
|
53
|
+
|
|
54
|
+
return sorted(matches)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_new_issues_for_tracks(repo: str, track_slugs: list[str],
|
|
58
|
+
*, slug_labels: dict[str, list[str]] | None = None,
|
|
59
|
+
since_days: int = 7) -> dict[str, list[dict]]:
|
|
60
|
+
"""For each track slug, return list of recent issues that match."""
|
|
61
|
+
if not track_slugs:
|
|
62
|
+
return {}
|
|
63
|
+
since_date = (datetime.now() - timedelta(days=since_days)).strftime("%Y-%m-%d")
|
|
64
|
+
recent = fetch_recent_issues(repo, since_iso=since_date)
|
|
65
|
+
out: dict[str, list[dict]] = {s: [] for s in track_slugs}
|
|
66
|
+
for issue in recent:
|
|
67
|
+
for slug in match_issue_to_tracks(issue, track_slugs, slug_labels=slug_labels):
|
|
68
|
+
out[slug].append(issue)
|
|
69
|
+
return out
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Compute a suggested `next_up` issue list for a track.
|
|
2
|
+
|
|
3
|
+
Sort policy: open issues only, exclude blockers, ranked by priority label
|
|
4
|
+
(P0 < P1 < P2 < P3 with missing label defaulting to P3), then by most-
|
|
5
|
+
recently-updated within the same priority bucket. Closed issues are
|
|
6
|
+
filtered out — `next_up` should never propose work that's already done.
|
|
7
|
+
|
|
8
|
+
Used by:
|
|
9
|
+
- `commands/handoff.py` — `--auto-next` flag prompts the user to apply
|
|
10
|
+
the suggestion (with edit/skip options).
|
|
11
|
+
- `commands/brief.py` — when a track sets `next_up_auto: true` in its
|
|
12
|
+
frontmatter, brief computes the suggestion live at display time and
|
|
13
|
+
ignores any stored `next_up` list.
|
|
14
|
+
|
|
15
|
+
The two callers share this helper so the algorithm has one home; if we
|
|
16
|
+
ever want to layer additional signals (assignee, linked PR, milestone),
|
|
17
|
+
they go here.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
from lib.github_state import extract_priority, short_milestone
|
|
25
|
+
|
|
26
|
+
PRIORITY_RANK = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
|
|
27
|
+
DEFAULT_TOP_N = 3
|
|
28
|
+
|
|
29
|
+
# Milestone alignment ranks: items on the track's declared milestone come
|
|
30
|
+
# first, items on a different milestone next, items with no milestone last.
|
|
31
|
+
MILESTONE_ALIGNED = 0
|
|
32
|
+
MILESTONE_OTHER = 1
|
|
33
|
+
MILESTONE_NONE = 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _updated_unix(issue: dict) -> float:
|
|
37
|
+
"""Parse the gh-formatted updatedAt field to a unix timestamp.
|
|
38
|
+
|
|
39
|
+
Returns 0.0 if the field is missing or unparsable — treats unknown-age
|
|
40
|
+
issues as oldest, which keeps recently-updated items on top of the
|
|
41
|
+
suggestion within the same priority bucket.
|
|
42
|
+
"""
|
|
43
|
+
raw = issue.get("updatedAt") or ""
|
|
44
|
+
if not raw:
|
|
45
|
+
return 0.0
|
|
46
|
+
try:
|
|
47
|
+
# gh emits 'Z'-suffixed UTC; fromisoformat in 3.9 wants '+00:00'.
|
|
48
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
|
|
49
|
+
except (ValueError, TypeError):
|
|
50
|
+
return 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def suggest_next_up(
|
|
54
|
+
issues: list[dict],
|
|
55
|
+
blocker_nums: Iterable[int] | None = None,
|
|
56
|
+
n: int = DEFAULT_TOP_N,
|
|
57
|
+
track_milestone: str | None = None,
|
|
58
|
+
) -> list[int]:
|
|
59
|
+
"""Return up to `n` issue numbers ranked for "what to work on next."
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
issues: issue dicts as returned by `gh issue list --json
|
|
63
|
+
number,state,labels,milestone,updatedAt,...`.
|
|
64
|
+
blocker_nums: iterable of issue numbers to exclude (a track's
|
|
65
|
+
manually-flagged blockers).
|
|
66
|
+
n: maximum items to return. Default is DEFAULT_TOP_N.
|
|
67
|
+
track_milestone: optional `milestone_alignment:` value from the
|
|
68
|
+
track's frontmatter (e.g. `"v0.4.0"`). When provided, issues
|
|
69
|
+
on this milestone rank above items on any other milestone,
|
|
70
|
+
which in turn rank above items with no milestone — keeps
|
|
71
|
+
post-launch deferrals from polluting a launch-window list.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of issue numbers, highest-ranked first. Empty if nothing
|
|
75
|
+
qualifies (e.g., everything closed or blocked).
|
|
76
|
+
"""
|
|
77
|
+
blockers = set(blocker_nums or [])
|
|
78
|
+
candidates = [
|
|
79
|
+
i for i in issues
|
|
80
|
+
if str(i.get("state", "")).upper() == "OPEN"
|
|
81
|
+
and i.get("number") not in blockers
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def milestone_rank(issue: dict) -> int:
|
|
85
|
+
ms = short_milestone(issue.get("milestone"))
|
|
86
|
+
if not ms:
|
|
87
|
+
return MILESTONE_NONE
|
|
88
|
+
if track_milestone and ms == track_milestone:
|
|
89
|
+
return MILESTONE_ALIGNED
|
|
90
|
+
return MILESTONE_OTHER
|
|
91
|
+
|
|
92
|
+
def sort_key(issue: dict) -> tuple[int, int, float]:
|
|
93
|
+
pri = extract_priority(issue.get("labels", []))
|
|
94
|
+
# Negate timestamp so newer comes first within a priority bucket.
|
|
95
|
+
return (milestone_rank(issue), PRIORITY_RANK.get(pri, 3), -_updated_unix(issue))
|
|
96
|
+
|
|
97
|
+
candidates.sort(key=sort_key)
|
|
98
|
+
return [i["number"] for i in candidates[:n]]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""First-creation-only seed for .work-plan/README.md."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
README_CONTENT = """\
|
|
5
|
+
# .work-plan/
|
|
6
|
+
|
|
7
|
+
This folder contains **shared planning tracks** managed by [`work-plan`](https://github.com/stylusnexus/work-plan-toolkit).
|
|
8
|
+
|
|
9
|
+
Each `.md` file is a planning track: a lightweight document with YAML frontmatter that
|
|
10
|
+
points at GitHub issues and captures session notes. GitHub is canonical for issue state;
|
|
11
|
+
these files are the *planning context* that travels with the code.
|
|
12
|
+
|
|
13
|
+
## Shared vs. private tracks
|
|
14
|
+
|
|
15
|
+
Tracks in this folder are the **shared tier** — they're committed and sync via `git pull`.
|
|
16
|
+
To keep a track private (personal notes, not for teammates), use `--private` when creating
|
|
17
|
+
it and it will go into your local `notes_root` folder instead.
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
Install the toolkit: [stylusnexus/work-plan-toolkit](https://github.com/stylusnexus/work-plan-toolkit)
|
|
22
|
+
Also available as a Claude/Codex plugin: [stylusnexus/agent-plugins](https://github.com/stylusnexus/agent-plugins)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def seed_readme(work_plan_dir: Path) -> bool:
|
|
27
|
+
"""Write README.md into work_plan_dir if and only if the dir was just created
|
|
28
|
+
(i.e. it did not previously contain a README.md). Returns True if written.
|
|
29
|
+
|
|
30
|
+
Rule: only seeds on first creation. If README.md already exists (even if empty),
|
|
31
|
+
leaves it alone. If the user deleted it inside an existing folder, does NOT
|
|
32
|
+
resurrect it — deletion is a respected opt-out.
|
|
33
|
+
"""
|
|
34
|
+
readme = work_plan_dir / "README.md"
|
|
35
|
+
if readme.exists():
|
|
36
|
+
return False
|
|
37
|
+
readme.write_text(README_CONTENT, encoding="utf-8")
|
|
38
|
+
return True
|
|
@@ -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
|