@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,128 @@
|
|
|
1
|
+
"""hygiene subcommand: weekly cleanup wrapper.
|
|
2
|
+
|
|
3
|
+
Runs in sequence:
|
|
4
|
+
1. refresh-md --all --yes (drift in body status tables)
|
|
5
|
+
2. reconcile --all (sync track/<slug> labels ↔ frontmatter)
|
|
6
|
+
3. duplicates (find consolidation candidates)
|
|
7
|
+
|
|
8
|
+
One command for the standard weekly maintenance pass.
|
|
9
|
+
|
|
10
|
+
Pass --repo=<key> to scope steps 1 and 2 to a single repo. Step 3 (duplicates)
|
|
11
|
+
is per-repo, so:
|
|
12
|
+
- when --repo is set, it's scoped to that repo;
|
|
13
|
+
- when --repo is absent and config has exactly one repo, it runs against
|
|
14
|
+
that repo;
|
|
15
|
+
- when --repo is absent and config has multiple repos, it's skipped cleanly
|
|
16
|
+
(rather than letting duplicates exit non-zero on the ambiguous case).
|
|
17
|
+
|
|
18
|
+
Pass --timeout=N to set the gh subprocess timeout for the duplicates step
|
|
19
|
+
(default 30s).
|
|
20
|
+
"""
|
|
21
|
+
from commands import refresh_md, reconcile, duplicates
|
|
22
|
+
from lib.config import load_config, ConfigError
|
|
23
|
+
from lib.prompts import parse_flags
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_repo_folder(repo_key: str, cfg: dict):
|
|
28
|
+
"""Translate hygiene's --repo arg (folder key OR org/repo slug) to a config
|
|
29
|
+
folder key, which is what duplicates expects. Returns None if unresolvable.
|
|
30
|
+
"""
|
|
31
|
+
repos = cfg.get("repos", {})
|
|
32
|
+
if repo_key in repos:
|
|
33
|
+
return repo_key
|
|
34
|
+
k = repo_key.lower()
|
|
35
|
+
for folder, entry in repos.items():
|
|
36
|
+
if entry.get("github", "").lower() == k:
|
|
37
|
+
return folder
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run(args: list[str]) -> int:
|
|
42
|
+
flags, _ = parse_flags(args, {"--yes", "--no-duplicates", "--repo", "--timeout"})
|
|
43
|
+
skip_dups = flags.get("--no-duplicates", False)
|
|
44
|
+
yes = flags.get("--yes", False)
|
|
45
|
+
repo_key = flags.get("--repo")
|
|
46
|
+
if repo_key is True:
|
|
47
|
+
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]")
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
gh_timeout = None
|
|
51
|
+
raw_timeout = flags.get("--timeout")
|
|
52
|
+
if raw_timeout is not None and raw_timeout is not True:
|
|
53
|
+
try:
|
|
54
|
+
gh_timeout = int(raw_timeout)
|
|
55
|
+
except ValueError:
|
|
56
|
+
print(f"WARNING: invalid --timeout value '{raw_timeout}'; using default")
|
|
57
|
+
elif raw_timeout is True:
|
|
58
|
+
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>] [--timeout=N]")
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
scope_label = f" --repo={repo_key}" if repo_key else " --all"
|
|
62
|
+
|
|
63
|
+
t0 = time.time()
|
|
64
|
+
print("=" * 60)
|
|
65
|
+
print(f"WEEKLY HYGIENE — step 1 of 3: refresh-md{scope_label}")
|
|
66
|
+
print("=" * 60)
|
|
67
|
+
refresh_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
|
|
68
|
+
if yes:
|
|
69
|
+
refresh_args.append("--yes")
|
|
70
|
+
rc = refresh_md.run(refresh_args)
|
|
71
|
+
if rc != 0:
|
|
72
|
+
print(f"\n⚠ refresh-md exited with code {rc}; continuing.")
|
|
73
|
+
print(f" (step 1/3 done in {time.time() - t0:.1f}s)")
|
|
74
|
+
|
|
75
|
+
t1 = time.time()
|
|
76
|
+
print()
|
|
77
|
+
print("=" * 60)
|
|
78
|
+
print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}")
|
|
79
|
+
print("=" * 60)
|
|
80
|
+
reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
|
|
81
|
+
rc = reconcile.run(reconcile_args)
|
|
82
|
+
if rc != 0:
|
|
83
|
+
print(f"\n⚠ reconcile exited with code {rc}; continuing.")
|
|
84
|
+
print(f" (step 2/3 done in {time.time() - t1:.1f}s)")
|
|
85
|
+
|
|
86
|
+
if skip_dups:
|
|
87
|
+
print()
|
|
88
|
+
print("(skipping duplicates per --no-duplicates)")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
t2 = time.time()
|
|
92
|
+
print()
|
|
93
|
+
print("=" * 60)
|
|
94
|
+
print("WEEKLY HYGIENE — step 3 of 3: duplicates")
|
|
95
|
+
print("=" * 60)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
cfg = load_config()
|
|
99
|
+
except ConfigError as e:
|
|
100
|
+
print(f"⚠ could not load config for duplicates step: {e}")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
dupes_args: list[str] = []
|
|
104
|
+
repos = cfg.get("repos", {})
|
|
105
|
+
if repo_key:
|
|
106
|
+
folder = _resolve_repo_folder(repo_key, cfg)
|
|
107
|
+
if folder is None:
|
|
108
|
+
print(f"(skipping duplicates: --repo={repo_key} not found in config.yml)")
|
|
109
|
+
return 0
|
|
110
|
+
dupes_args = [f"--repo={folder}"]
|
|
111
|
+
elif len(repos) > 1:
|
|
112
|
+
print("(skipping duplicates: multiple repos in config and no --repo passed.")
|
|
113
|
+
print(" run `/work-plan duplicates --repo=<folder-name>` per repo to scan them.)")
|
|
114
|
+
return 0
|
|
115
|
+
# else: 0 or 1 repos → duplicates handles both (errors / single-repo auto-pick)
|
|
116
|
+
|
|
117
|
+
if gh_timeout is not None:
|
|
118
|
+
dupes_args.append(f"--timeout={gh_timeout}")
|
|
119
|
+
|
|
120
|
+
rc = duplicates.run(dupes_args)
|
|
121
|
+
if rc != 0:
|
|
122
|
+
print(f"\n⚠ duplicates exited with code {rc}.")
|
|
123
|
+
print(f" (step 3/3 done in {time.time() - t2:.1f}s)")
|
|
124
|
+
|
|
125
|
+
print()
|
|
126
|
+
print(f"✓ Weekly hygiene complete ({time.time() - t0:.1f}s total). Review the duplicate candidates above and "
|
|
127
|
+
"consolidate any real dupes via `gh issue close`.")
|
|
128
|
+
return 0
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""init subcommand — non-interactive, flag-driven."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from lib.config import load_config, ConfigError, resolve_github_for_folder
|
|
9
|
+
from lib.frontmatter import parse_file, write_file
|
|
10
|
+
from lib.prompts import parse_flags
|
|
11
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
12
|
+
|
|
13
|
+
_VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_repo_for_shared_path(path: Path, cfg: dict) -> Optional[str]:
|
|
17
|
+
"""If path is inside a .work-plan/ dir, find the configured github repo for that clone."""
|
|
18
|
+
# Walk up the path looking for a .work-plan ancestor
|
|
19
|
+
for parent in path.parents:
|
|
20
|
+
if parent.name == ".work-plan":
|
|
21
|
+
clone_root = parent.parent
|
|
22
|
+
for folder, entry in cfg.get("repos", {}).items():
|
|
23
|
+
if entry.get("local"):
|
|
24
|
+
local = Path(entry["local"]).expanduser().resolve()
|
|
25
|
+
if local == clone_root.resolve():
|
|
26
|
+
return entry.get("github")
|
|
27
|
+
return None # In .work-plan/ but not registered
|
|
28
|
+
return None # Not in a .work-plan/
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run(args: list[str]) -> int:
|
|
32
|
+
flags, positional = parse_flags(args, {"--priority", "--milestone", "--confirm"})
|
|
33
|
+
|
|
34
|
+
if not positional:
|
|
35
|
+
print("usage: work_plan.py init <path-to-md>")
|
|
36
|
+
return 2
|
|
37
|
+
|
|
38
|
+
path = Path(positional[0]).expanduser().resolve()
|
|
39
|
+
if not path.exists():
|
|
40
|
+
print(f"ERROR: file not found: {path}")
|
|
41
|
+
return 1
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
cfg = load_config()
|
|
45
|
+
except ConfigError as e:
|
|
46
|
+
print(f"ERROR: {e}")
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
meta, body = parse_file(path)
|
|
50
|
+
if meta:
|
|
51
|
+
print(f"{path.name} already has frontmatter.")
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
slug = re.sub(r"[^a-z0-9-]+", "-", path.stem.lower()).strip("-")
|
|
55
|
+
|
|
56
|
+
# Detect if this path is inside a .work-plan/ shared directory
|
|
57
|
+
is_shared = ".work-plan" in path.parts
|
|
58
|
+
tier = "shared" if is_shared else None
|
|
59
|
+
|
|
60
|
+
if is_shared:
|
|
61
|
+
repo = _find_repo_for_shared_path(path, cfg)
|
|
62
|
+
if repo is None:
|
|
63
|
+
print(
|
|
64
|
+
"ERROR: path is inside a .work-plan/ directory but its repo isn't"
|
|
65
|
+
" registered in config — run init-repo first"
|
|
66
|
+
)
|
|
67
|
+
return 1
|
|
68
|
+
folder = None
|
|
69
|
+
else:
|
|
70
|
+
notes_root = Path(cfg["notes_root"])
|
|
71
|
+
try:
|
|
72
|
+
rel = path.relative_to(notes_root)
|
|
73
|
+
folder = rel.parts[0] if len(rel.parts) > 1 else None
|
|
74
|
+
except ValueError:
|
|
75
|
+
folder = None
|
|
76
|
+
repo = resolve_github_for_folder(folder, cfg) if folder else None
|
|
77
|
+
|
|
78
|
+
issue_nums = sorted(set(int(m) for m in re.findall(r"#(\d+)", body)))
|
|
79
|
+
|
|
80
|
+
# Resolve priority — default P2; invalid value falls back to P2
|
|
81
|
+
raw_priority = flags.get("--priority")
|
|
82
|
+
if isinstance(raw_priority, str):
|
|
83
|
+
priority = raw_priority.upper()
|
|
84
|
+
if priority not in _VALID_PRIORITIES:
|
|
85
|
+
priority = "P2"
|
|
86
|
+
else:
|
|
87
|
+
priority = "P2"
|
|
88
|
+
|
|
89
|
+
# Resolve milestone — default v1.0.0
|
|
90
|
+
milestone_flag = flags.get("--milestone")
|
|
91
|
+
milestone = milestone_flag if isinstance(milestone_flag, str) else "v1.0.0"
|
|
92
|
+
|
|
93
|
+
# Confirm-token gate — only for real resolvable repos (not TBD/unknown).
|
|
94
|
+
# Checked before printing the info block so the gate output is the only
|
|
95
|
+
# stdout (the extension surfaces this as a modal, JSON-parse the first line).
|
|
96
|
+
if repo and repo != "TBD" and needs_confirm(repo, cfg):
|
|
97
|
+
confirm = flags.get("--confirm")
|
|
98
|
+
if not (isinstance(confirm, str) and valid_token(confirm, repo, slug)):
|
|
99
|
+
print(json.dumps({
|
|
100
|
+
"needs_confirm": True,
|
|
101
|
+
"reason": (
|
|
102
|
+
f"{repo} is PUBLIC (or visibility unknown); "
|
|
103
|
+
f"the new track '{slug}' references it."
|
|
104
|
+
),
|
|
105
|
+
"token": make_token(repo, slug),
|
|
106
|
+
}))
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
print(f"Initializing: {path.name}")
|
|
110
|
+
print(f" track: {slug}")
|
|
111
|
+
print(f" repo: {repo or '(unknown — will set TBD)'}")
|
|
112
|
+
if tier == "shared":
|
|
113
|
+
print(" tier: shared")
|
|
114
|
+
print(f" issues found in body: {issue_nums or '(none)'}")
|
|
115
|
+
|
|
116
|
+
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
117
|
+
meta = {
|
|
118
|
+
"track": slug, "status": "active",
|
|
119
|
+
"launch_priority": priority,
|
|
120
|
+
"milestone_alignment": milestone,
|
|
121
|
+
"github": {"repo": repo or "TBD", "issues": issue_nums, "branches": []},
|
|
122
|
+
"related_tracks": [],
|
|
123
|
+
"last_touched": now, "last_handoff": now,
|
|
124
|
+
"next_up": [], "blockers": [],
|
|
125
|
+
}
|
|
126
|
+
write_file(path, meta, body)
|
|
127
|
+
print(f"✓ Frontmatter added to {path.name}.")
|
|
128
|
+
return 0
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""init-repo subcommand — bootstrap a new repo block + notes folder.
|
|
2
|
+
|
|
3
|
+
Non-interactive: --github is required; --local is optional (no prompts).
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from lib.config import load_config, ConfigError, DEFAULT_CONFIG_PATH, is_valid_git_repo
|
|
11
|
+
from lib.prompts import parse_flags
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _count_shared_tracks(work_plan_dir: Path) -> int:
|
|
15
|
+
"""Count eligible .md files in a .work-plan/ directory.
|
|
16
|
+
|
|
17
|
+
Excludes: README.md, dotfiles, and anything inside archive/.
|
|
18
|
+
"""
|
|
19
|
+
count = 0
|
|
20
|
+
for p in work_plan_dir.iterdir():
|
|
21
|
+
if p.is_dir():
|
|
22
|
+
continue
|
|
23
|
+
if p.name.startswith("."):
|
|
24
|
+
continue
|
|
25
|
+
if p.name.lower() == "readme.md":
|
|
26
|
+
continue
|
|
27
|
+
if p.suffix == ".md":
|
|
28
|
+
count += 1
|
|
29
|
+
return count
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _report_shared_tracks(local_path: "Path | None") -> None:
|
|
33
|
+
"""Print a status line about shared tracks found in .work-plan/ (if any).
|
|
34
|
+
|
|
35
|
+
If local_path is None, not a valid git repo, or has no .work-plan/ dir,
|
|
36
|
+
prints the registration-only fallback message instead.
|
|
37
|
+
"""
|
|
38
|
+
if local_path is None or not is_valid_git_repo(local_path):
|
|
39
|
+
print()
|
|
40
|
+
print("ℹ No valid local clone provided — registered for future use.")
|
|
41
|
+
print(" Run 'work-plan init-repo <key> --local=<path>' to add the clone path later.")
|
|
42
|
+
return
|
|
43
|
+
work_plan_dir = local_path / ".work-plan"
|
|
44
|
+
if work_plan_dir.is_dir():
|
|
45
|
+
n = _count_shared_tracks(work_plan_dir)
|
|
46
|
+
print(
|
|
47
|
+
f"ℹ Found {n} shared track(s) in {work_plan_dir}/"
|
|
48
|
+
" — they'll appear after 'work-plan brief'."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(args: list[str]) -> int:
|
|
53
|
+
flags, positional = parse_flags(args, {"--github", "--local"})
|
|
54
|
+
if not positional:
|
|
55
|
+
print("usage: work_plan.py init-repo <key> --github=<org/repo> [--local=<path>]")
|
|
56
|
+
return 2
|
|
57
|
+
|
|
58
|
+
key = positional[0]
|
|
59
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*", key):
|
|
60
|
+
print(f"ERROR: '{key}' is not a valid key. Use lowercase letters, digits, hyphens; must start with a letter.")
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
# --github is required; no prompt fallback
|
|
64
|
+
github = flags.get("--github")
|
|
65
|
+
if not github or "/" not in github:
|
|
66
|
+
if not github:
|
|
67
|
+
print("ERROR: --github is required (e.g. --github=org/repo).")
|
|
68
|
+
else:
|
|
69
|
+
print("ERROR: github slug must be in the form 'org/repo'.")
|
|
70
|
+
return 2
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
cfg = load_config()
|
|
74
|
+
except ConfigError as e:
|
|
75
|
+
print(f"ERROR: {e}")
|
|
76
|
+
print("\nRun ./install.sh from the toolkit root to seed your config first.")
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
if key in cfg.get("repos", {}):
|
|
80
|
+
print(f"ERROR: repo '{key}' already exists in {DEFAULT_CONFIG_PATH}.")
|
|
81
|
+
print("Edit it manually, or pick a different key.")
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
# --local is optional; if absent, skip (no prompt)
|
|
85
|
+
local = flags.get("--local") or None
|
|
86
|
+
local_path = None
|
|
87
|
+
if local:
|
|
88
|
+
local_path = Path(local).expanduser()
|
|
89
|
+
if not local_path.exists():
|
|
90
|
+
print(f"WARN: {local_path} does not exist. Saving anyway — fix later if wrong.")
|
|
91
|
+
|
|
92
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
93
|
+
if not notes_root.exists():
|
|
94
|
+
print(f"ERROR: notes_root {notes_root} does not exist.")
|
|
95
|
+
print("Fix the path in ~/.claude/work-plan/config.yml or create the directory.")
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
repo_dir = notes_root / key
|
|
99
|
+
archive_shipped = repo_dir / "archive" / "shipped"
|
|
100
|
+
archive_abandoned = repo_dir / "archive" / "abandoned"
|
|
101
|
+
archive_shipped.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
archive_abandoned.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
(archive_shipped / ".gitkeep").touch()
|
|
104
|
+
(archive_abandoned / ".gitkeep").touch()
|
|
105
|
+
print(f"✓ Created notes folder: {repo_dir}/")
|
|
106
|
+
print(f" ├── archive/shipped/")
|
|
107
|
+
print(f" └── archive/abandoned/")
|
|
108
|
+
|
|
109
|
+
# Detect existing shared tracks in .work-plan/ inside the local clone
|
|
110
|
+
_report_shared_tracks(local_path)
|
|
111
|
+
|
|
112
|
+
repo_block = {"github": github}
|
|
113
|
+
if local:
|
|
114
|
+
repo_block["local"] = local
|
|
115
|
+
|
|
116
|
+
yq_expr = f'.repos.{key} = {json.dumps(repo_block)}'
|
|
117
|
+
try:
|
|
118
|
+
subprocess.run(
|
|
119
|
+
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
120
|
+
check=True, capture_output=True, text=True,
|
|
121
|
+
)
|
|
122
|
+
except subprocess.CalledProcessError as e:
|
|
123
|
+
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
124
|
+
return 1
|
|
125
|
+
print(f"✓ Added repo '{key}' to {DEFAULT_CONFIG_PATH}")
|
|
126
|
+
|
|
127
|
+
print()
|
|
128
|
+
print("Next steps:")
|
|
129
|
+
print(f" • Add a track: /work-plan init '{repo_dir}/<track-slug>.md'")
|
|
130
|
+
print(f" • AI-cluster issues: /work-plan group --repo={key} --milestone=v1.0.0")
|
|
131
|
+
print(f" • See it listed: /work-plan list")
|
|
132
|
+
return 0
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""list subcommand."""
|
|
2
|
+
from lib.config import load_config, ConfigError
|
|
3
|
+
from lib.tracks import discover_tracks, discover_archived_tracks
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run(args: list[str]) -> int:
|
|
7
|
+
show_all = "--all" in args
|
|
8
|
+
try:
|
|
9
|
+
cfg = load_config()
|
|
10
|
+
except ConfigError as e:
|
|
11
|
+
print(f"ERROR: {e}")
|
|
12
|
+
return 1
|
|
13
|
+
|
|
14
|
+
tracks = discover_tracks(cfg)
|
|
15
|
+
if not tracks and not show_all:
|
|
16
|
+
print(f"No tracks found under {cfg['notes_root']}")
|
|
17
|
+
return 0
|
|
18
|
+
|
|
19
|
+
print(f"Tracks under {cfg['notes_root']}:\n")
|
|
20
|
+
for t in tracks:
|
|
21
|
+
status = t.meta.get("status", "(no frontmatter)")
|
|
22
|
+
priority = t.meta.get("launch_priority", "—")
|
|
23
|
+
repo = t.repo or "(no repo)"
|
|
24
|
+
flags = []
|
|
25
|
+
if t.needs_init:
|
|
26
|
+
flags.append("NEEDS INIT")
|
|
27
|
+
if t.needs_filing:
|
|
28
|
+
flags.append("NEEDS FILING")
|
|
29
|
+
flag_str = f" [{', '.join(flags)}]" if flags else ""
|
|
30
|
+
print(f" {t.name:30} {status:14} {priority:3} {repo}{flag_str}")
|
|
31
|
+
|
|
32
|
+
if show_all:
|
|
33
|
+
archived = discover_archived_tracks(cfg)
|
|
34
|
+
if archived:
|
|
35
|
+
print("\nArchived:")
|
|
36
|
+
for a in archived:
|
|
37
|
+
end_state = a.meta.get("status", "?")
|
|
38
|
+
print(f" {a.name:30} {end_state:14} {a.repo or '(no repo)'}")
|
|
39
|
+
return 0
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""new-track subcommand — one-shot non-interactive track creation.
|
|
2
|
+
|
|
3
|
+
Creates a brand-new <slug>.md under notes_root/<folder>/ (private tier) or
|
|
4
|
+
<local>/.work-plan/ (shared tier) with frontmatter written from flags.
|
|
5
|
+
Designed for headless callers (e.g. the VS Code extension) that cannot run
|
|
6
|
+
interactive init + do not know notes_root upfront.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]
|
|
10
|
+
[--private] [--commit] [--confirm=<token>]
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from lib.config import load_config, ConfigError, is_valid_git_repo
|
|
20
|
+
from lib.frontmatter import write_file
|
|
21
|
+
from lib.prompts import parse_flags
|
|
22
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
23
|
+
|
|
24
|
+
_VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
|
|
25
|
+
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _git_commit_track(track_file: Path, slug: str) -> None:
|
|
29
|
+
"""Stage and commit a single shared track file (path-scoped, no git add .)."""
|
|
30
|
+
# The clone root is .work-plan/'s parent
|
|
31
|
+
clone_root = track_file.parent.parent
|
|
32
|
+
if not is_valid_git_repo(clone_root):
|
|
33
|
+
print(f"⚠ --commit ignored: track is private (not in a git repo)")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# Determine current branch name for the success message
|
|
37
|
+
branch = "HEAD"
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "-C", str(clone_root), "rev-parse", "--abbrev-ref", "HEAD"],
|
|
41
|
+
capture_output=True, text=True, check=False,
|
|
42
|
+
)
|
|
43
|
+
if result.returncode == 0:
|
|
44
|
+
branch = result.stdout.strip()
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
# Stage ONLY this file (never git add .)
|
|
49
|
+
try:
|
|
50
|
+
subprocess.run(
|
|
51
|
+
["git", "-C", str(clone_root), "add", str(track_file)],
|
|
52
|
+
capture_output=True, text=True, check=True,
|
|
53
|
+
)
|
|
54
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
55
|
+
msg = getattr(e, "stderr", str(e))
|
|
56
|
+
print(f"⚠ --commit: git add failed ({msg.strip()!r}) — continuing without commit")
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Commit with a conventional message
|
|
60
|
+
commit_msg = f"chore: add shared track '{slug}'"
|
|
61
|
+
try:
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["git", "-C", str(clone_root), "commit", "-m", commit_msg],
|
|
64
|
+
capture_output=True, text=True, check=True,
|
|
65
|
+
)
|
|
66
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
67
|
+
msg = getattr(e, "stderr", str(e))
|
|
68
|
+
print(f"⚠ --commit: git commit failed ({msg.strip()!r}) — continuing without commit")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
print(f"✓ committed '{slug}' to {branch}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run(args: list[str]) -> int:
|
|
75
|
+
flags, positional = parse_flags(
|
|
76
|
+
args, {"--priority", "--milestone", "--private", "--confirm", "--commit"}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Require exactly 2 positionals: repo and slug
|
|
80
|
+
if len(positional) < 2:
|
|
81
|
+
print(
|
|
82
|
+
"usage: work_plan.py new-track <repo> <slug>"
|
|
83
|
+
" [--priority=P0..P3] [--milestone=<m>] [--private] [--commit]"
|
|
84
|
+
" [--confirm=<token>]"
|
|
85
|
+
)
|
|
86
|
+
return 2
|
|
87
|
+
|
|
88
|
+
repo_arg = positional[0]
|
|
89
|
+
slug = positional[1]
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Resolve repo + folder from the repo argument
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
try:
|
|
95
|
+
cfg = load_config()
|
|
96
|
+
except ConfigError as e:
|
|
97
|
+
print(f"ERROR: {e}")
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
if repo_arg in cfg.get("repos", {}):
|
|
101
|
+
github = cfg["repos"][repo_arg]["github"]
|
|
102
|
+
folder = repo_arg
|
|
103
|
+
elif "/" in repo_arg:
|
|
104
|
+
github = repo_arg
|
|
105
|
+
folder = repo_arg.rsplit("/", 1)[-1]
|
|
106
|
+
else:
|
|
107
|
+
print(
|
|
108
|
+
f"ERROR: unknown repo '{repo_arg}' — pass a configured key"
|
|
109
|
+
" or an org/repo slug"
|
|
110
|
+
)
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# Validate slug: lowercase letters / digits / hyphens, starts with letter
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
if not _SLUG_RE.fullmatch(slug):
|
|
117
|
+
print(
|
|
118
|
+
f"ERROR: '{slug}' is not a valid slug."
|
|
119
|
+
" Use lowercase letters, digits, hyphens; must start with a letter."
|
|
120
|
+
)
|
|
121
|
+
return 2
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# Resolve priority (default P2, invalid → P2) and milestone (default v1.0.0)
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
raw_priority = flags.get("--priority")
|
|
127
|
+
if isinstance(raw_priority, str):
|
|
128
|
+
priority = raw_priority.upper()
|
|
129
|
+
if priority not in _VALID_PRIORITIES:
|
|
130
|
+
priority = "P2"
|
|
131
|
+
else:
|
|
132
|
+
priority = "P2"
|
|
133
|
+
|
|
134
|
+
milestone_flag = flags.get("--milestone")
|
|
135
|
+
milestone = milestone_flag if isinstance(milestone_flag, str) else "v1.0.0"
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Determine target path: shared (.work-plan/) or private (notes_root/)
|
|
139
|
+
# Shared route: repo is registered, has a local path, and it's a valid git repo.
|
|
140
|
+
# --private overrides to force the private (notes_root) route.
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
use_private = "--private" in flags
|
|
143
|
+
|
|
144
|
+
shared_path: Optional[Path] = None
|
|
145
|
+
if not use_private and folder in cfg.get("repos", {}):
|
|
146
|
+
local_raw = cfg["repos"][folder].get("local")
|
|
147
|
+
if local_raw:
|
|
148
|
+
local_path = Path(local_raw).expanduser()
|
|
149
|
+
if is_valid_git_repo(local_path):
|
|
150
|
+
shared_path = local_path / ".work-plan" / f"{slug}.md"
|
|
151
|
+
|
|
152
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
153
|
+
if shared_path is not None:
|
|
154
|
+
path = shared_path
|
|
155
|
+
is_shared = True
|
|
156
|
+
else:
|
|
157
|
+
if not notes_root.exists():
|
|
158
|
+
print(f"ERROR: notes_root {notes_root} does not exist.")
|
|
159
|
+
return 1
|
|
160
|
+
path = notes_root / folder / f"{slug}.md"
|
|
161
|
+
is_shared = False
|
|
162
|
+
|
|
163
|
+
if path.exists():
|
|
164
|
+
print(f"ERROR: track '{slug}' already exists at {path}")
|
|
165
|
+
return 2
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Confirm-token gate (BEFORE creating anything)
|
|
169
|
+
# Mirror the exact JSON shape used by init/slot/close/set.
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
if needs_confirm(github, cfg):
|
|
172
|
+
confirm = flags.get("--confirm")
|
|
173
|
+
if not (isinstance(confirm, str) and valid_token(confirm, github, slug)):
|
|
174
|
+
print(json.dumps({
|
|
175
|
+
"needs_confirm": True,
|
|
176
|
+
"reason": (
|
|
177
|
+
f"{github} is PUBLIC (or visibility unknown); "
|
|
178
|
+
f"the new track '{slug}' will be written there."
|
|
179
|
+
),
|
|
180
|
+
"token": make_token(github, slug),
|
|
181
|
+
}))
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
# Create folder if missing, then write the track file
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
|
|
189
|
+
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
190
|
+
meta = {
|
|
191
|
+
"track": slug,
|
|
192
|
+
"status": "active",
|
|
193
|
+
"launch_priority": priority,
|
|
194
|
+
"milestone_alignment": milestone,
|
|
195
|
+
"github": {"repo": github, "issues": [], "branches": []},
|
|
196
|
+
"related_tracks": [],
|
|
197
|
+
"last_touched": now,
|
|
198
|
+
"last_handoff": now,
|
|
199
|
+
"next_up": [],
|
|
200
|
+
"blockers": [],
|
|
201
|
+
}
|
|
202
|
+
if is_shared:
|
|
203
|
+
meta["tier"] = "shared"
|
|
204
|
+
|
|
205
|
+
body = f"# {slug}\n"
|
|
206
|
+
write_file(path, meta, body)
|
|
207
|
+
|
|
208
|
+
if is_shared:
|
|
209
|
+
print(f"✓ Created shared track '{slug}' for {github} at {path}")
|
|
210
|
+
else:
|
|
211
|
+
rel = path.relative_to(notes_root)
|
|
212
|
+
print(f"✓ Created track '{slug}' for {github} at {rel}")
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------------
|
|
215
|
+
# --commit: stage + commit the track file to the shared repo (non-fatal)
|
|
216
|
+
# Only meaningful for shared tracks; warn and skip for private.
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
want_commit = "--commit" in flags
|
|
219
|
+
if want_commit:
|
|
220
|
+
if is_shared:
|
|
221
|
+
_git_commit_track(path, slug)
|
|
222
|
+
else:
|
|
223
|
+
print("⚠ --commit ignored: track is private (not in a git repo)")
|
|
224
|
+
|
|
225
|
+
return 0
|