@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,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