@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +36 -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 +119 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/brief.py +247 -0
  11. package/skills/work-plan/commands/canonicalize.py +122 -0
  12. package/skills/work-plan/commands/close.py +83 -0
  13. package/skills/work-plan/commands/duplicates.py +111 -0
  14. package/skills/work-plan/commands/export.py +69 -0
  15. package/skills/work-plan/commands/group.py +234 -0
  16. package/skills/work-plan/commands/handoff.py +855 -0
  17. package/skills/work-plan/commands/hygiene.py +104 -0
  18. package/skills/work-plan/commands/init.py +96 -0
  19. package/skills/work-plan/commands/init_repo.py +90 -0
  20. package/skills/work-plan/commands/list_cmd.py +39 -0
  21. package/skills/work-plan/commands/new_track.py +148 -0
  22. package/skills/work-plan/commands/plan_status.py +296 -0
  23. package/skills/work-plan/commands/reconcile.py +172 -0
  24. package/skills/work-plan/commands/refresh_md.py +132 -0
  25. package/skills/work-plan/commands/set_field.py +54 -0
  26. package/skills/work-plan/commands/set_notes_root.py +53 -0
  27. package/skills/work-plan/commands/slot.py +139 -0
  28. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  29. package/skills/work-plan/commands/where_was_i.py +325 -0
  30. package/skills/work-plan/lib/__init__.py +0 -0
  31. package/skills/work-plan/lib/closure.py +72 -0
  32. package/skills/work-plan/lib/config.py +82 -0
  33. package/skills/work-plan/lib/doc_discovery.py +41 -0
  34. package/skills/work-plan/lib/drift.py +32 -0
  35. package/skills/work-plan/lib/export_model.py +40 -0
  36. package/skills/work-plan/lib/frontmatter.py +48 -0
  37. package/skills/work-plan/lib/git_state.py +180 -0
  38. package/skills/work-plan/lib/github_state.py +296 -0
  39. package/skills/work-plan/lib/llm_evidence.py +45 -0
  40. package/skills/work-plan/lib/manifest.py +164 -0
  41. package/skills/work-plan/lib/new_issues.py +69 -0
  42. package/skills/work-plan/lib/next_up.py +98 -0
  43. package/skills/work-plan/lib/prompts.py +68 -0
  44. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  45. package/skills/work-plan/lib/render.py +83 -0
  46. package/skills/work-plan/lib/scratch.py +14 -0
  47. package/skills/work-plan/lib/session_log.py +39 -0
  48. package/skills/work-plan/lib/status_header.py +60 -0
  49. package/skills/work-plan/lib/status_table.py +227 -0
  50. package/skills/work-plan/lib/tracks.py +109 -0
  51. package/skills/work-plan/lib/verdict.py +51 -0
  52. package/skills/work-plan/lib/write_guard.py +39 -0
  53. package/skills/work-plan/tests/__init__.py +0 -0
  54. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  55. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  56. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  58. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  59. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  60. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  61. package/skills/work-plan/tests/test_close.py +273 -0
  62. package/skills/work-plan/tests/test_closure.py +51 -0
  63. package/skills/work-plan/tests/test_config.py +85 -0
  64. package/skills/work-plan/tests/test_config_seed.py +41 -0
  65. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  66. package/skills/work-plan/tests/test_drift.py +38 -0
  67. package/skills/work-plan/tests/test_export.py +91 -0
  68. package/skills/work-plan/tests/test_export_command.py +295 -0
  69. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  70. package/skills/work-plan/tests/test_git_state.py +51 -0
  71. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  72. package/skills/work-plan/tests/test_github_state.py +508 -0
  73. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  74. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  75. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  76. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  77. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  78. package/skills/work-plan/tests/test_init.py +289 -0
  79. package/skills/work-plan/tests/test_init_repo.py +251 -0
  80. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  81. package/skills/work-plan/tests/test_manifest.py +162 -0
  82. package/skills/work-plan/tests/test_new_issues.py +130 -0
  83. package/skills/work-plan/tests/test_new_track.py +445 -0
  84. package/skills/work-plan/tests/test_next_up.py +149 -0
  85. package/skills/work-plan/tests/test_plan_status.py +68 -0
  86. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  87. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  88. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  89. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  90. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  91. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  92. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  93. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  94. package/skills/work-plan/tests/test_reconcile_readonly.py +166 -0
  95. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  96. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  97. package/skills/work-plan/tests/test_render.py +110 -0
  98. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  99. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  100. package/skills/work-plan/tests/test_session_log.py +39 -0
  101. package/skills/work-plan/tests/test_set_field.py +77 -0
  102. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  103. package/skills/work-plan/tests/test_slot.py +243 -0
  104. package/skills/work-plan/tests/test_slot_move.py +128 -0
  105. package/skills/work-plan/tests/test_smoke.py +46 -0
  106. package/skills/work-plan/tests/test_status_header.py +79 -0
  107. package/skills/work-plan/tests/test_status_table.py +162 -0
  108. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  109. package/skills/work-plan/tests/test_tracks.py +56 -0
  110. package/skills/work-plan/tests/test_verdict.py +60 -0
  111. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  112. package/skills/work-plan/tests/test_write_guard.py +53 -0
  113. 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