@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,104 @@
|
|
|
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
|
+
from commands import refresh_md, reconcile, duplicates
|
|
19
|
+
from lib.config import load_config, ConfigError
|
|
20
|
+
from lib.prompts import parse_flags
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_repo_folder(repo_key: str, cfg: dict):
|
|
24
|
+
"""Translate hygiene's --repo arg (folder key OR org/repo slug) to a config
|
|
25
|
+
folder key, which is what duplicates expects. Returns None if unresolvable.
|
|
26
|
+
"""
|
|
27
|
+
repos = cfg.get("repos", {})
|
|
28
|
+
if repo_key in repos:
|
|
29
|
+
return repo_key
|
|
30
|
+
k = repo_key.lower()
|
|
31
|
+
for folder, entry in repos.items():
|
|
32
|
+
if entry.get("github", "").lower() == k:
|
|
33
|
+
return folder
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(args: list[str]) -> int:
|
|
38
|
+
flags, _ = parse_flags(args, {"--yes", "--no-duplicates", "--repo"})
|
|
39
|
+
skip_dups = flags.get("--no-duplicates", False)
|
|
40
|
+
yes = flags.get("--yes", False)
|
|
41
|
+
repo_key = flags.get("--repo")
|
|
42
|
+
if repo_key is True:
|
|
43
|
+
print("usage: work_plan.py hygiene [--yes] [--no-duplicates] [--repo=<key>]")
|
|
44
|
+
return 2
|
|
45
|
+
|
|
46
|
+
scope_label = f" --repo={repo_key}" if repo_key else " --all"
|
|
47
|
+
|
|
48
|
+
print("=" * 60)
|
|
49
|
+
print(f"WEEKLY HYGIENE — step 1 of 3: refresh-md{scope_label}")
|
|
50
|
+
print("=" * 60)
|
|
51
|
+
refresh_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
|
|
52
|
+
if yes:
|
|
53
|
+
refresh_args.append("--yes")
|
|
54
|
+
rc = refresh_md.run(refresh_args)
|
|
55
|
+
if rc != 0:
|
|
56
|
+
print(f"\n⚠ refresh-md exited with code {rc}; continuing.")
|
|
57
|
+
|
|
58
|
+
print()
|
|
59
|
+
print("=" * 60)
|
|
60
|
+
print(f"WEEKLY HYGIENE — step 2 of 3: reconcile{scope_label}")
|
|
61
|
+
print("=" * 60)
|
|
62
|
+
reconcile_args = [f"--repo={repo_key}"] if repo_key else ["--all"]
|
|
63
|
+
rc = reconcile.run(reconcile_args)
|
|
64
|
+
if rc != 0:
|
|
65
|
+
print(f"\n⚠ reconcile exited with code {rc}; continuing.")
|
|
66
|
+
|
|
67
|
+
if skip_dups:
|
|
68
|
+
print()
|
|
69
|
+
print("(skipping duplicates per --no-duplicates)")
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
print()
|
|
73
|
+
print("=" * 60)
|
|
74
|
+
print("WEEKLY HYGIENE — step 3 of 3: duplicates")
|
|
75
|
+
print("=" * 60)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
cfg = load_config()
|
|
79
|
+
except ConfigError as e:
|
|
80
|
+
print(f"⚠ could not load config for duplicates step: {e}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
dupes_args: list[str] = []
|
|
84
|
+
repos = cfg.get("repos", {})
|
|
85
|
+
if repo_key:
|
|
86
|
+
folder = _resolve_repo_folder(repo_key, cfg)
|
|
87
|
+
if folder is None:
|
|
88
|
+
print(f"(skipping duplicates: --repo={repo_key} not found in config.yml)")
|
|
89
|
+
return 0
|
|
90
|
+
dupes_args = [f"--repo={folder}"]
|
|
91
|
+
elif len(repos) > 1:
|
|
92
|
+
print("(skipping duplicates: multiple repos in config and no --repo passed.")
|
|
93
|
+
print(" run `/work-plan duplicates --repo=<folder-name>` per repo to scan them.)")
|
|
94
|
+
return 0
|
|
95
|
+
# else: 0 or 1 repos → duplicates handles both (errors / single-repo auto-pick)
|
|
96
|
+
|
|
97
|
+
rc = duplicates.run(dupes_args)
|
|
98
|
+
if rc != 0:
|
|
99
|
+
print(f"\n⚠ duplicates exited with code {rc}.")
|
|
100
|
+
|
|
101
|
+
print()
|
|
102
|
+
print("✓ Weekly hygiene complete. Review the duplicate candidates above and "
|
|
103
|
+
"consolidate any real dupes via `gh issue close`.")
|
|
104
|
+
return 0
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""init subcommand — non-interactive, flag-driven."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lib.config import load_config, ConfigError, resolve_github_for_folder
|
|
8
|
+
from lib.frontmatter import parse_file, write_file
|
|
9
|
+
from lib.prompts import parse_flags
|
|
10
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
11
|
+
|
|
12
|
+
_VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run(args: list[str]) -> int:
|
|
16
|
+
flags, positional = parse_flags(args, {"--priority", "--milestone", "--confirm"})
|
|
17
|
+
|
|
18
|
+
if not positional:
|
|
19
|
+
print("usage: work_plan.py init <path-to-md>")
|
|
20
|
+
return 2
|
|
21
|
+
|
|
22
|
+
path = Path(positional[0]).expanduser().resolve()
|
|
23
|
+
if not path.exists():
|
|
24
|
+
print(f"ERROR: file not found: {path}")
|
|
25
|
+
return 1
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
except ConfigError as e:
|
|
30
|
+
print(f"ERROR: {e}")
|
|
31
|
+
return 1
|
|
32
|
+
|
|
33
|
+
meta, body = parse_file(path)
|
|
34
|
+
if meta:
|
|
35
|
+
print(f"{path.name} already has frontmatter.")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
slug = re.sub(r"[^a-z0-9-]+", "-", path.stem.lower()).strip("-")
|
|
39
|
+
|
|
40
|
+
notes_root = Path(cfg["notes_root"])
|
|
41
|
+
try:
|
|
42
|
+
rel = path.relative_to(notes_root)
|
|
43
|
+
folder = rel.parts[0] if len(rel.parts) > 1 else None
|
|
44
|
+
except ValueError:
|
|
45
|
+
folder = None
|
|
46
|
+
repo = resolve_github_for_folder(folder, cfg) if folder else None
|
|
47
|
+
|
|
48
|
+
issue_nums = sorted(set(int(m) for m in re.findall(r"#(\d+)", body)))
|
|
49
|
+
|
|
50
|
+
# Resolve priority — default P2; invalid value falls back to P2
|
|
51
|
+
raw_priority = flags.get("--priority")
|
|
52
|
+
if isinstance(raw_priority, str):
|
|
53
|
+
priority = raw_priority.upper()
|
|
54
|
+
if priority not in _VALID_PRIORITIES:
|
|
55
|
+
priority = "P2"
|
|
56
|
+
else:
|
|
57
|
+
priority = "P2"
|
|
58
|
+
|
|
59
|
+
# Resolve milestone — default v1.0.0
|
|
60
|
+
milestone_flag = flags.get("--milestone")
|
|
61
|
+
milestone = milestone_flag if isinstance(milestone_flag, str) else "v1.0.0"
|
|
62
|
+
|
|
63
|
+
# Confirm-token gate — only for real resolvable repos (not TBD/unknown).
|
|
64
|
+
# Checked before printing the info block so the gate output is the only
|
|
65
|
+
# stdout (the extension surfaces this as a modal, JSON-parse the first line).
|
|
66
|
+
if repo and repo != "TBD" and needs_confirm(repo, cfg):
|
|
67
|
+
confirm = flags.get("--confirm")
|
|
68
|
+
if not (isinstance(confirm, str) and valid_token(confirm, repo, slug)):
|
|
69
|
+
print(json.dumps({
|
|
70
|
+
"needs_confirm": True,
|
|
71
|
+
"reason": (
|
|
72
|
+
f"{repo} is PUBLIC (or visibility unknown); "
|
|
73
|
+
f"the new track '{slug}' references it."
|
|
74
|
+
),
|
|
75
|
+
"token": make_token(repo, slug),
|
|
76
|
+
}))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
print(f"Initializing: {path.name}")
|
|
80
|
+
print(f" track: {slug}")
|
|
81
|
+
print(f" repo: {repo or '(unknown — will set TBD)'}")
|
|
82
|
+
print(f" issues found in body: {issue_nums or '(none)'}")
|
|
83
|
+
|
|
84
|
+
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
85
|
+
meta = {
|
|
86
|
+
"track": slug, "status": "active",
|
|
87
|
+
"launch_priority": priority,
|
|
88
|
+
"milestone_alignment": milestone,
|
|
89
|
+
"github": {"repo": repo or "TBD", "issues": issue_nums, "branches": []},
|
|
90
|
+
"related_tracks": [],
|
|
91
|
+
"last_touched": now, "last_handoff": now,
|
|
92
|
+
"next_up": [], "blockers": [],
|
|
93
|
+
}
|
|
94
|
+
write_file(path, meta, body)
|
|
95
|
+
print(f"✓ Frontmatter added to {path.name}.")
|
|
96
|
+
return 0
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
|
11
|
+
from lib.prompts import parse_flags
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(args: list[str]) -> int:
|
|
15
|
+
flags, positional = parse_flags(args, {"--github", "--local"})
|
|
16
|
+
if not positional:
|
|
17
|
+
print("usage: work_plan.py init-repo <key> --github=<org/repo> [--local=<path>]")
|
|
18
|
+
return 2
|
|
19
|
+
|
|
20
|
+
key = positional[0]
|
|
21
|
+
if not re.fullmatch(r"[a-z][a-z0-9-]*", key):
|
|
22
|
+
print(f"ERROR: '{key}' is not a valid key. Use lowercase letters, digits, hyphens; must start with a letter.")
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
# --github is required; no prompt fallback
|
|
26
|
+
github = flags.get("--github")
|
|
27
|
+
if not github or "/" not in github:
|
|
28
|
+
if not github:
|
|
29
|
+
print("ERROR: --github is required (e.g. --github=org/repo).")
|
|
30
|
+
else:
|
|
31
|
+
print("ERROR: github slug must be in the form 'org/repo'.")
|
|
32
|
+
return 2
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
cfg = load_config()
|
|
36
|
+
except ConfigError as e:
|
|
37
|
+
print(f"ERROR: {e}")
|
|
38
|
+
print("\nRun ./install.sh from the toolkit root to seed your config first.")
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
if key in cfg.get("repos", {}):
|
|
42
|
+
print(f"ERROR: repo '{key}' already exists in {DEFAULT_CONFIG_PATH}.")
|
|
43
|
+
print("Edit it manually, or pick a different key.")
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
# --local is optional; if absent, skip (no prompt)
|
|
47
|
+
local = flags.get("--local") or None
|
|
48
|
+
if local:
|
|
49
|
+
local_path = Path(local).expanduser()
|
|
50
|
+
if not local_path.exists():
|
|
51
|
+
print(f"WARN: {local_path} does not exist. Saving anyway — fix later if wrong.")
|
|
52
|
+
|
|
53
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
54
|
+
if not notes_root.exists():
|
|
55
|
+
print(f"ERROR: notes_root {notes_root} does not exist.")
|
|
56
|
+
print("Fix the path in ~/.claude/work-plan/config.yml or create the directory.")
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
repo_dir = notes_root / key
|
|
60
|
+
archive_shipped = repo_dir / "archive" / "shipped"
|
|
61
|
+
archive_abandoned = repo_dir / "archive" / "abandoned"
|
|
62
|
+
archive_shipped.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
archive_abandoned.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
(archive_shipped / ".gitkeep").touch()
|
|
65
|
+
(archive_abandoned / ".gitkeep").touch()
|
|
66
|
+
print(f"✓ Created notes folder: {repo_dir}/")
|
|
67
|
+
print(f" ├── archive/shipped/")
|
|
68
|
+
print(f" └── archive/abandoned/")
|
|
69
|
+
|
|
70
|
+
repo_block = {"github": github}
|
|
71
|
+
if local:
|
|
72
|
+
repo_block["local"] = local
|
|
73
|
+
|
|
74
|
+
yq_expr = f'.repos.{key} = {json.dumps(repo_block)}'
|
|
75
|
+
try:
|
|
76
|
+
subprocess.run(
|
|
77
|
+
["yq", "-i", yq_expr, str(DEFAULT_CONFIG_PATH)],
|
|
78
|
+
check=True, capture_output=True, text=True,
|
|
79
|
+
)
|
|
80
|
+
except subprocess.CalledProcessError as e:
|
|
81
|
+
print(f"ERROR: yq failed to update config: {e.stderr}")
|
|
82
|
+
return 1
|
|
83
|
+
print(f"✓ Added repo '{key}' to {DEFAULT_CONFIG_PATH}")
|
|
84
|
+
|
|
85
|
+
print()
|
|
86
|
+
print("Next steps:")
|
|
87
|
+
print(f" • Add a track: /work-plan init '{repo_dir}/<track-slug>.md'")
|
|
88
|
+
print(f" • AI-cluster issues: /work-plan group --repo={key} --milestone=v1.0.0")
|
|
89
|
+
print(f" • See it listed: /work-plan list")
|
|
90
|
+
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,148 @@
|
|
|
1
|
+
"""new-track subcommand — one-shot non-interactive track creation.
|
|
2
|
+
|
|
3
|
+
Creates a brand-new <slug>.md under notes_root/<folder>/ with frontmatter
|
|
4
|
+
written from flags. Designed for headless callers (e.g. the VS Code extension)
|
|
5
|
+
that cannot run interactive init + do not know notes_root upfront.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
new-track <repo> <slug> [--priority=P0..P3] [--milestone=<m>]
|
|
9
|
+
[--private] [--confirm=<token>]
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from lib.config import load_config, ConfigError
|
|
17
|
+
from lib.frontmatter import write_file
|
|
18
|
+
from lib.prompts import parse_flags
|
|
19
|
+
from lib.write_guard import needs_confirm, make_token, valid_token
|
|
20
|
+
|
|
21
|
+
_VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
|
|
22
|
+
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(args: list[str]) -> int:
|
|
26
|
+
flags, positional = parse_flags(
|
|
27
|
+
args, {"--priority", "--milestone", "--private", "--confirm"}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Require exactly 2 positionals: repo and slug
|
|
31
|
+
if len(positional) < 2:
|
|
32
|
+
print(
|
|
33
|
+
"usage: work_plan.py new-track <repo> <slug>"
|
|
34
|
+
" [--priority=P0..P3] [--milestone=<m>] [--private] [--confirm=<token>]"
|
|
35
|
+
)
|
|
36
|
+
return 2
|
|
37
|
+
|
|
38
|
+
repo_arg = positional[0]
|
|
39
|
+
slug = positional[1]
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
# Resolve repo + folder from the repo argument
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
try:
|
|
45
|
+
cfg = load_config()
|
|
46
|
+
except ConfigError as e:
|
|
47
|
+
print(f"ERROR: {e}")
|
|
48
|
+
return 1
|
|
49
|
+
|
|
50
|
+
if repo_arg in cfg.get("repos", {}):
|
|
51
|
+
github = cfg["repos"][repo_arg]["github"]
|
|
52
|
+
folder = repo_arg
|
|
53
|
+
elif "/" in repo_arg:
|
|
54
|
+
github = repo_arg
|
|
55
|
+
folder = repo_arg.rsplit("/", 1)[-1]
|
|
56
|
+
else:
|
|
57
|
+
print(
|
|
58
|
+
f"ERROR: unknown repo '{repo_arg}' — pass a configured key"
|
|
59
|
+
" or an org/repo slug"
|
|
60
|
+
)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Validate slug: lowercase letters / digits / hyphens, starts with letter
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
if not _SLUG_RE.fullmatch(slug):
|
|
67
|
+
print(
|
|
68
|
+
f"ERROR: '{slug}' is not a valid slug."
|
|
69
|
+
" Use lowercase letters, digits, hyphens; must start with a letter."
|
|
70
|
+
)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Resolve priority (default P2, invalid → P2) and milestone (default v1.0.0)
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
raw_priority = flags.get("--priority")
|
|
77
|
+
if isinstance(raw_priority, str):
|
|
78
|
+
priority = raw_priority.upper()
|
|
79
|
+
if priority not in _VALID_PRIORITIES:
|
|
80
|
+
priority = "P2"
|
|
81
|
+
else:
|
|
82
|
+
priority = "P2"
|
|
83
|
+
|
|
84
|
+
milestone_flag = flags.get("--milestone")
|
|
85
|
+
milestone = milestone_flag if isinstance(milestone_flag, str) else "v1.0.0"
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Resolve target path
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
notes_root = Path(cfg["notes_root"]).expanduser()
|
|
91
|
+
if not notes_root.exists():
|
|
92
|
+
print(f"ERROR: notes_root {notes_root} does not exist.")
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
path = notes_root / folder / f"{slug}.md"
|
|
96
|
+
|
|
97
|
+
if path.exists():
|
|
98
|
+
print(f"ERROR: track '{slug}' already exists at {path}")
|
|
99
|
+
return 2
|
|
100
|
+
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
# Confirm-token gate (BEFORE creating anything)
|
|
103
|
+
# Mirror the exact JSON shape used by init/slot/close/set.
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
if needs_confirm(github, cfg):
|
|
106
|
+
confirm = flags.get("--confirm")
|
|
107
|
+
if not (isinstance(confirm, str) and valid_token(confirm, github, slug)):
|
|
108
|
+
print(json.dumps({
|
|
109
|
+
"needs_confirm": True,
|
|
110
|
+
"reason": (
|
|
111
|
+
f"{github} is PUBLIC (or visibility unknown); "
|
|
112
|
+
f"the new track '{slug}' will be written there."
|
|
113
|
+
),
|
|
114
|
+
"token": make_token(github, slug),
|
|
115
|
+
}))
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# --private flag: accepted for forward-compat but is a no-op today.
|
|
120
|
+
# Every track is effectively private now; the two-tier shared/private
|
|
121
|
+
# model is unbuilt. We accept the flag so callers don't error out.
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
# (no branch on --private beyond parsing it)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Create folder if missing, then write the track file
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
now = datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
131
|
+
meta = {
|
|
132
|
+
"track": slug,
|
|
133
|
+
"status": "active",
|
|
134
|
+
"launch_priority": priority,
|
|
135
|
+
"milestone_alignment": milestone,
|
|
136
|
+
"github": {"repo": github, "issues": [], "branches": []},
|
|
137
|
+
"related_tracks": [],
|
|
138
|
+
"last_touched": now,
|
|
139
|
+
"last_handoff": now,
|
|
140
|
+
"next_up": [],
|
|
141
|
+
"blockers": [],
|
|
142
|
+
}
|
|
143
|
+
body = f"# {slug}\n"
|
|
144
|
+
write_file(path, meta, body)
|
|
145
|
+
|
|
146
|
+
rel = path.relative_to(notes_root)
|
|
147
|
+
print(f"✓ Created track '{slug}' for {github} at {rel}")
|
|
148
|
+
return 0
|