@stylusnexus/work-plan 2026.6.9-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,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