@stylusnexus/work-plan 2026.6.13 → 2026.6.14

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 (44) hide show
  1. package/README.md +19 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/SKILL.md +3 -0
  5. package/skills/work-plan/commands/auth_status.py +35 -0
  6. package/skills/work-plan/commands/brief.py +12 -0
  7. package/skills/work-plan/commands/close_issue.py +82 -0
  8. package/skills/work-plan/commands/export.py +70 -5
  9. package/skills/work-plan/commands/in_progress.py +110 -0
  10. package/skills/work-plan/commands/plan_ack.py +71 -0
  11. package/skills/work-plan/commands/plan_baseline.py +85 -0
  12. package/skills/work-plan/commands/plan_confirm.py +83 -0
  13. package/skills/work-plan/commands/plan_status.py +65 -1
  14. package/skills/work-plan/commands/push_track.py +156 -0
  15. package/skills/work-plan/commands/set_field.py +22 -3
  16. package/skills/work-plan/commands/where_was_i.py +30 -2
  17. package/skills/work-plan/lib/export_model.py +42 -5
  18. package/skills/work-plan/lib/git_state.py +32 -0
  19. package/skills/work-plan/lib/github_state.py +132 -4
  20. package/skills/work-plan/lib/in_progress.py +23 -0
  21. package/skills/work-plan/lib/manifest.py +18 -0
  22. package/skills/work-plan/lib/plan_fm.py +71 -0
  23. package/skills/work-plan/lib/render.py +5 -0
  24. package/skills/work-plan/lib/status_header.py +6 -2
  25. package/skills/work-plan/tests/test_auth_status.py +98 -0
  26. package/skills/work-plan/tests/test_close_issue.py +121 -0
  27. package/skills/work-plan/tests/test_export.py +161 -8
  28. package/skills/work-plan/tests/test_export_command.py +103 -0
  29. package/skills/work-plan/tests/test_git_state.py +38 -1
  30. package/skills/work-plan/tests/test_github_state.py +66 -0
  31. package/skills/work-plan/tests/test_in_progress.py +43 -0
  32. package/skills/work-plan/tests/test_in_progress_command.py +166 -0
  33. package/skills/work-plan/tests/test_list_open_issues.py +8 -3
  34. package/skills/work-plan/tests/test_manifest.py +30 -1
  35. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  36. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  37. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  38. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  39. package/skills/work-plan/tests/test_push_track.py +131 -0
  40. package/skills/work-plan/tests/test_register_in_progress.py +22 -0
  41. package/skills/work-plan/tests/test_render.py +48 -0
  42. package/skills/work-plan/tests/test_set_field.py +60 -0
  43. package/skills/work-plan/tests/test_where_was_i.py +80 -0
  44. package/skills/work-plan/work_plan.py +36 -1
@@ -0,0 +1,145 @@
1
+ """verdict_override (#286): a frontmatter override pins the verdict and silences
2
+ the lie-gap on plan-status. Offline — frontmatter parse shells to real yq."""
3
+ import io
4
+ import json
5
+ import unittest
6
+ import sys
7
+ import tempfile
8
+ from contextlib import redirect_stdout
9
+ from pathlib import Path
10
+ from unittest import mock
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(SKILL_ROOT))
14
+
15
+ from commands import plan_status
16
+
17
+ # A plan whose ONE declared file exists (→ mechanical "shipped", file score 100%)
18
+ # but whose two phase checkboxes are unticked → the classic lie-gap shape.
19
+ BODY = (
20
+ "# Idea Mode UI\n\n"
21
+ "**Files:**\n"
22
+ "- Create: `src/new.ts`\n"
23
+ "- [ ] Step 1: do the thing\n"
24
+ "- [ ] Step 2: do the other thing\n"
25
+ )
26
+
27
+
28
+ def _frontmattered(override: str) -> str:
29
+ return f"---\nverdict_override: {override}\n---\n{BODY}"
30
+
31
+
32
+ def _frontmattered_kv(key: str, value: str) -> str:
33
+ return f"---\n{key}: {value}\n---\n{BODY}"
34
+
35
+
36
+ class OverrideTest(unittest.TestCase):
37
+ def _row(self, root):
38
+ with mock.patch("commands.plan_status.git_state.path_last_commit_date",
39
+ return_value=None), \
40
+ mock.patch("commands.plan_status.Path.cwd", return_value=root):
41
+ buf = io.StringIO()
42
+ with redirect_stdout(buf):
43
+ rc = plan_status.run(["--json"])
44
+ self.assertEqual(rc, 0)
45
+ return json.loads(buf.getvalue())["docs"][0]
46
+
47
+ def _repo(self, d, doc_text):
48
+ root = Path(d)
49
+ (root / "docs/superpowers/plans").mkdir(parents=True)
50
+ (root / "docs/superpowers/plans/p.md").write_text(doc_text)
51
+ (root / "src").mkdir()
52
+ (root / "src/new.ts").write_text("export const x = 1") # 1/1 declared present
53
+ return root
54
+
55
+ def test_baseline_lie_gap_without_override(self):
56
+ with tempfile.TemporaryDirectory() as d:
57
+ row = self._row(self._repo(d, BODY))
58
+ self.assertEqual(row["verdict"], "shipped")
59
+ self.assertTrue(row["lie_gap"]) # 0/2 boxes -> lie-gap fires
60
+ self.assertIsNone(row["override"])
61
+
62
+ def test_override_shipped_silences_lie_gap(self):
63
+ with tempfile.TemporaryDirectory() as d:
64
+ row = self._row(self._repo(d, _frontmattered("shipped")))
65
+ self.assertEqual(row["verdict"], "shipped")
66
+ self.assertEqual(row["override"], "shipped")
67
+ self.assertFalse(row["lie_gap"]) # human-confirmed -> no longer a lie
68
+ self.assertIn("human-confirmed", row["rationale"])
69
+
70
+ def test_override_pins_verdict_over_mechanical(self):
71
+ # Declared file MISSING -> mechanical would be partial; override pins dead.
72
+ with tempfile.TemporaryDirectory() as d:
73
+ root = Path(d)
74
+ (root / "docs/superpowers/plans").mkdir(parents=True)
75
+ (root / "docs/superpowers/plans/p.md").write_text(_frontmattered("dead"))
76
+ # no src/new.ts -> 0/1 files
77
+ row = self._row(root)
78
+ self.assertEqual(row["verdict"], "dead")
79
+ self.assertEqual(row["override"], "dead")
80
+ self.assertEqual(row["glyph"], "💀")
81
+
82
+ def test_invalid_override_value_ignored(self):
83
+ with tempfile.TemporaryDirectory() as d:
84
+ row = self._row(self._repo(d, _frontmattered("done"))) # not a valid verdict
85
+ self.assertIsNone(row["override"])
86
+ self.assertTrue(row["lie_gap"]) # falls back to mechanical
87
+
88
+ def test_acknowledged_frontmatter_emitted(self):
89
+ with tempfile.TemporaryDirectory() as d:
90
+ row = self._row(self._repo(d, f"---\nacknowledged: true\n---\n{BODY}"))
91
+ self.assertTrue(row["acknowledged"])
92
+
93
+ def test_acknowledged_false_without_frontmatter(self):
94
+ with tempfile.TemporaryDirectory() as d:
95
+ row = self._row(self._repo(d, BODY))
96
+ self.assertFalse(row["acknowledged"])
97
+
98
+ def test_baseline_matching_live_verdict_no_drift(self):
99
+ # File present → live "shipped"; baseline shipped → no drift.
100
+ with tempfile.TemporaryDirectory() as d:
101
+ row = self._row(self._repo(d, _frontmattered_kv("verdict_baseline", "shipped")))
102
+ self.assertEqual(row["verdict_baseline"], "shipped")
103
+ self.assertFalse(row["verdict_drift"])
104
+
105
+ def test_baseline_diverged_flags_drift(self):
106
+ # File MISSING → live "partial"; baseline shipped → drift (regressed).
107
+ with tempfile.TemporaryDirectory() as d:
108
+ root = Path(d)
109
+ (root / "docs/superpowers/plans").mkdir(parents=True)
110
+ (root / "docs/superpowers/plans/p.md").write_text(
111
+ f"---\nverdict_baseline: shipped\n---\n{BODY}") # no src/new.ts
112
+ row = self._row(root)
113
+ self.assertEqual(row["verdict"], "partial")
114
+ self.assertTrue(row["verdict_drift"])
115
+
116
+ def test_override_suppresses_drift(self):
117
+ # baseline partial, but a human override pins shipped → drift suppressed.
118
+ with tempfile.TemporaryDirectory() as d:
119
+ row = self._row(self._repo(
120
+ d, "---\nverdict_baseline: partial\nverdict_override: shipped\n---\n" + BODY))
121
+ self.assertEqual(row["verdict"], "shipped")
122
+ self.assertFalse(row["verdict_drift"])
123
+
124
+ def test_no_baseline_no_drift(self):
125
+ with tempfile.TemporaryDirectory() as d:
126
+ row = self._row(self._repo(d, BODY))
127
+ self.assertIsNone(row["verdict_baseline"])
128
+ self.assertFalse(row["verdict_drift"])
129
+
130
+ def test_offtree_paths_flagged(self):
131
+ # A plan declaring an out-of-tree path surfaces it read-only (#286 slice 3).
132
+ body = ("# P\n\n**Files:**\n- Create: `src/new.ts`\n"
133
+ "- Create: `../sibling/escape.ts`\n- [ ] Step 1\n")
134
+ with tempfile.TemporaryDirectory() as d:
135
+ row = self._row(self._repo(d, body))
136
+ self.assertEqual(row["offtree_paths"], ["../sibling/escape.ts"])
137
+
138
+ def test_offtree_paths_empty_for_clean_manifest(self):
139
+ with tempfile.TemporaryDirectory() as d:
140
+ row = self._row(self._repo(d, BODY))
141
+ self.assertEqual(row["offtree_paths"], [])
142
+
143
+
144
+ if __name__ == "__main__":
145
+ unittest.main()
@@ -0,0 +1,131 @@
1
+ """push-track (#306): promote a private track to the shared tier + push.
2
+ Real temp files for the move; git-worktree helpers + config mocked (offline)."""
3
+ import io
4
+ import json
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+ from contextlib import redirect_stdout, redirect_stderr
9
+ from pathlib import Path
10
+ from types import SimpleNamespace
11
+ from unittest import mock
12
+
13
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from commands import push_track
17
+ from lib.frontmatter import parse_file
18
+ from lib.write_guard import make_token
19
+
20
+
21
+ class PushTrackTest(unittest.TestCase):
22
+ def _setup(self, d, tier="private", plan_branch="work-plan/plan"):
23
+ root = Path(d)
24
+ notes = root / "notes"; notes.mkdir()
25
+ priv = notes / "my-feature.md"
26
+ priv.write_text("---\ntrack: my-feature\n---\n# My Feature\n\nbody\n")
27
+ shared = root / "repo" / ".work-plan"; shared.mkdir(parents=True)
28
+ entry = {"github": "o/r", "local": str(root / "repo")}
29
+ if plan_branch:
30
+ entry["plan_branch"] = plan_branch
31
+ cfg = {"notes_root": str(notes), "repos": {"demo": entry}}
32
+ track = SimpleNamespace(
33
+ name="my-feature", tier=tier, folder="demo", repo="o/r",
34
+ path=priv, meta={"track": "my-feature"}, body="# My Feature\n\nbody\n",
35
+ )
36
+ return root, cfg, track, shared, priv
37
+
38
+ def _drive(self, cfg, track, shared, args, vis="PRIVATE",
39
+ commit_sha="abc123", push_rc=0):
40
+ push_proc = SimpleNamespace(returncode=push_rc, stderr="")
41
+ with mock.patch("commands.push_track.load_config", return_value=cfg), \
42
+ mock.patch("commands.push_track.discover_tracks", return_value=[track]), \
43
+ mock.patch("commands.push_track.find_track_by_name", return_value=track), \
44
+ mock.patch("lib.write_guard.repo_visibility", return_value=vis), \
45
+ mock.patch("commands.push_track.pw.shared_tier_dir", return_value=shared), \
46
+ mock.patch("commands.push_track.pw.commit_shared_tier", return_value=commit_sha), \
47
+ mock.patch("commands.push_track.pw.push_plan_branch", return_value=push_proc) as mpush:
48
+ out, err = io.StringIO(), io.StringIO()
49
+ with redirect_stdout(out), redirect_stderr(err):
50
+ rc = push_track.run(args)
51
+ return rc, out.getvalue(), err.getvalue(), mpush
52
+
53
+ def test_promotes_and_pushes_private_repo(self):
54
+ with tempfile.TemporaryDirectory() as d:
55
+ _, cfg, track, shared, priv = self._setup(d)
56
+ rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"])
57
+ self.assertEqual(rc, 0)
58
+ dest = shared / "my-feature.md"
59
+ self.assertTrue(dest.is_file()) # written to shared tier
60
+ self.assertFalse(priv.exists()) # private copy removed
61
+ meta, _ = parse_file(dest)
62
+ self.assertEqual(meta["track"], "my-feature") # frontmatter preserved
63
+ mpush.assert_called_once() # pushed
64
+
65
+ def test_no_push_keeps_local(self):
66
+ with tempfile.TemporaryDirectory() as d:
67
+ _, cfg, track, shared, priv = self._setup(d)
68
+ rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature", "--no-push"])
69
+ self.assertEqual(rc, 0)
70
+ self.assertTrue((shared / "my-feature.md").is_file())
71
+ mpush.assert_not_called()
72
+ self.assertIn("plan-branch push", out)
73
+
74
+ def test_public_repo_no_token_returns_needs_confirm_no_mutation(self):
75
+ with tempfile.TemporaryDirectory() as d:
76
+ _, cfg, track, shared, priv = self._setup(d)
77
+ rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"], vis="PUBLIC")
78
+ self.assertEqual(rc, 0)
79
+ data = json.loads(out)
80
+ self.assertTrue(data["needs_confirm"])
81
+ self.assertEqual(data["token"], make_token("o/r", "my-feature"))
82
+ self.assertFalse((shared / "my-feature.md").exists()) # nothing moved
83
+ self.assertTrue(priv.exists()) # private intact
84
+ mpush.assert_not_called()
85
+
86
+ def test_public_repo_with_valid_token_proceeds(self):
87
+ with tempfile.TemporaryDirectory() as d:
88
+ _, cfg, track, shared, priv = self._setup(d)
89
+ tok = make_token("o/r", "my-feature")
90
+ rc, out, err, mpush = self._drive(
91
+ cfg, track, shared, ["my-feature", f"--confirm={tok}"], vis="PUBLIC")
92
+ self.assertEqual(rc, 0)
93
+ self.assertTrue((shared / "my-feature.md").is_file())
94
+ mpush.assert_called_once()
95
+
96
+ def test_public_no_push_skips_gate(self):
97
+ # --no-push keeps it local, so no exposure gate even on a public repo.
98
+ with tempfile.TemporaryDirectory() as d:
99
+ _, cfg, track, shared, priv = self._setup(d)
100
+ rc, out, err, mpush = self._drive(
101
+ cfg, track, shared, ["my-feature", "--no-push"], vis="PUBLIC")
102
+ self.assertEqual(rc, 0)
103
+ self.assertTrue((shared / "my-feature.md").is_file())
104
+ mpush.assert_not_called()
105
+
106
+ def test_already_shared_aborts(self):
107
+ with tempfile.TemporaryDirectory() as d:
108
+ _, cfg, track, shared, priv = self._setup(d, tier="shared")
109
+ rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"])
110
+ self.assertEqual(rc, 1)
111
+ self.assertIn("already in the shared tier", err)
112
+
113
+ def test_no_plan_branch_hints_init(self):
114
+ with tempfile.TemporaryDirectory() as d:
115
+ _, cfg, track, shared, priv = self._setup(d, plan_branch=None)
116
+ rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"])
117
+ self.assertEqual(rc, 1)
118
+ self.assertIn("plan-branch init", err)
119
+
120
+ def test_dest_exists_aborts(self):
121
+ with tempfile.TemporaryDirectory() as d:
122
+ _, cfg, track, shared, priv = self._setup(d)
123
+ (shared / "my-feature.md").write_text("# already here\n")
124
+ rc, out, err, mpush = self._drive(cfg, track, shared, ["my-feature"])
125
+ self.assertEqual(rc, 1)
126
+ self.assertIn("already exists", err)
127
+ self.assertTrue(priv.exists()) # private not removed on abort
128
+
129
+
130
+ if __name__ == "__main__":
131
+ unittest.main()
@@ -0,0 +1,22 @@
1
+ """in-progress is dispatchable + documented (#271)."""
2
+ import sys
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(SKILL_ROOT))
8
+
9
+ import work_plan
10
+
11
+
12
+ class RegisterInProgressTest(unittest.TestCase):
13
+ def test_in_subcommands(self):
14
+ self.assertEqual(work_plan.SUBCOMMANDS["in-progress"], "commands.in_progress")
15
+
16
+ def test_in_descriptions(self):
17
+ names = {row[0] for row in work_plan.DESCRIPTIONS}
18
+ self.assertIn("in-progress", names)
19
+
20
+
21
+ if __name__ == "__main__":
22
+ unittest.main()
@@ -106,5 +106,53 @@ class RenderTrackRowTest(unittest.TestCase):
106
106
  self.assertNotIn("(P3, open, ", row)
107
107
 
108
108
 
109
+ class RenderInProgressTest(unittest.TestCase):
110
+ def _block(self, next_up):
111
+ return {
112
+ "name": "alpha", "operational_status": "active", "launch_priority": "P2",
113
+ "milestone_alignment": "—", "last_touched_label": "1d ago",
114
+ "last_handoff_label": "1d ago", "next_up": next_up,
115
+ "next_up_stale_closed_count": 0, "track_slug": "alpha",
116
+ "active_branches": [], "new_issues": [], "blockers": [],
117
+ "drift_items": [], "closure_ready": False, "closure_signals_summary": None,
118
+ }
119
+
120
+ def test_in_progress_item_marked(self):
121
+ row = render_track_row(self._block([
122
+ {"number": 271, "title": "x", "priority": "P1", "state": "open",
123
+ "milestone": None, "in_progress": True},
124
+ ]))
125
+ self.assertIn("in-progress", row)
126
+ self.assertIn("#271", row)
127
+
128
+ def test_non_in_progress_item_not_marked(self):
129
+ row = render_track_row(self._block([
130
+ {"number": 9, "title": "y", "priority": "P2", "state": "open",
131
+ "milestone": None, "in_progress": False},
132
+ ]))
133
+ self.assertNotIn("in-progress", row)
134
+
135
+
136
+ class RenderBlockedByTest(unittest.TestCase):
137
+ def _block(self, item):
138
+ return {"name": "alpha", "operational_status": "active", "launch_priority": "P2",
139
+ "milestone_alignment": "—", "last_touched_label": "1d", "last_handoff_label": "1d",
140
+ "next_up": [item], "next_up_stale_closed_count": 0, "track_slug": "alpha",
141
+ "active_branches": [], "new_issues": [], "blockers": [],
142
+ "drift_items": [], "closure_ready": False, "closure_signals_summary": None}
143
+
144
+ def _item(self, blocked):
145
+ return {"number": 5, "title": "x", "priority": "P1", "state": "open",
146
+ "milestone": None, "in_progress": False, "blocked_by_display": blocked}
147
+
148
+ def test_blocked_by_annotation_rendered(self):
149
+ row = render_track_row(self._block(self._item(["#9"])))
150
+ self.assertIn("blocked by #9", row)
151
+
152
+ def test_no_annotation_when_empty(self):
153
+ row = render_track_row(self._block(self._item([])))
154
+ self.assertNotIn("blocked by", row)
155
+
156
+
109
157
  if __name__ == "__main__":
110
158
  unittest.main()
@@ -75,3 +75,63 @@ class SetFieldTest(unittest.TestCase):
75
75
  self.assertEqual(rc, 0)
76
76
  mw.assert_not_called()
77
77
  self.assertIn("needs_confirm", out)
78
+
79
+
80
+ class SetFieldPlanTest(unittest.TestCase):
81
+ """`set <track> plan=<rel>` — the #285 track↔plan frontmatter link."""
82
+
83
+ def _track(self, folder="demo", meta=None):
84
+ return SimpleNamespace(
85
+ name="ph", repo="o/r", folder=folder, path=Path("/tmp/ph.md"),
86
+ has_frontmatter=True, meta=meta if meta is not None else {"status": "active"},
87
+ body="# b")
88
+
89
+ def _drive_plan(self, args, track, cfg=None):
90
+ base_cfg = {"notes_root": "/tmp"}
91
+ if cfg:
92
+ base_cfg.update(cfg)
93
+ with patch("commands.set_field.load_config", return_value=base_cfg), \
94
+ patch("commands.set_field.discover_tracks", return_value=[track]), \
95
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
96
+ patch("commands.set_field.resolve_local_path_for_folder", return_value=None), \
97
+ patch("commands.set_field.write_file") as mw:
98
+ buf = io.StringIO()
99
+ with redirect_stdout(buf):
100
+ rc = set_field.run(args)
101
+ return rc, mw, buf.getvalue()
102
+
103
+ def test_sets_plan_path(self):
104
+ t = self._track()
105
+ rc, mw, out = self._drive_plan(["ph", "plan=docs/plans/p.md"], t)
106
+ self.assertEqual(rc, 0)
107
+ mw.assert_called_once()
108
+ self.assertEqual(mw.call_args[0][1]["plan"], "docs/plans/p.md")
109
+
110
+ def test_empty_plan_clears_link(self):
111
+ t = self._track(meta={"status": "active", "plan": "docs/plans/old.md"})
112
+ rc, mw, out = self._drive_plan(["ph", "plan="], t)
113
+ self.assertEqual(rc, 0)
114
+ mw.assert_called_once()
115
+ self.assertNotIn("plan", mw.call_args[0][1]) # key removed, not written as ""
116
+ self.assertIn("cleared", out)
117
+
118
+ def test_unresolved_plan_path_warns_but_saves(self):
119
+ # local path exists but the file doesn't -> WARN on stderr, still writes.
120
+ import tempfile
121
+ with tempfile.TemporaryDirectory() as d:
122
+ t = self._track()
123
+ with patch("commands.set_field.load_config", return_value={"notes_root": "/tmp"}), \
124
+ patch("commands.set_field.discover_tracks", return_value=[t]), \
125
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
126
+ patch("commands.set_field.resolve_local_path_for_folder",
127
+ return_value=Path(d)), \
128
+ patch("commands.set_field.write_file") as mw:
129
+ err = io.StringIO()
130
+ from contextlib import redirect_stderr
131
+ buf = io.StringIO()
132
+ with redirect_stdout(buf), redirect_stderr(err):
133
+ rc = set_field.run(["ph", "plan=docs/plans/missing.md"])
134
+ self.assertEqual(rc, 0)
135
+ mw.assert_called_once()
136
+ self.assertEqual(mw.call_args[0][1]["plan"], "docs/plans/missing.md")
137
+ self.assertIn("does not resolve", err.getvalue())
@@ -513,5 +513,85 @@ class OrientRepoFlagTest(unittest.TestCase):
513
513
  self.assertIn("ambiguous", out.lower())
514
514
 
515
515
 
516
+ class OrientInProgressTest(unittest.TestCase):
517
+ def test_next_pick_marked_in_progress(self):
518
+ from types import SimpleNamespace
519
+ track = SimpleNamespace(
520
+ name="alpha", repo="o/r", local_path=Path("/repo"), path=Path("/n/alpha.md"),
521
+ body="", meta={"track": "alpha", "launch_priority": "P1",
522
+ "milestone_alignment": "—",
523
+ "github": {"issues": [271]}, "next_up": [271]})
524
+ issue = {"number": 271, "title": "x", "state": "open", "labels": [], "milestone": None}
525
+ with mock.patch("commands.where_was_i.fetch_issues", return_value=[issue]), \
526
+ mock.patch("commands.where_was_i.hot_issue_numbers", return_value={271}), \
527
+ mock.patch("commands.where_was_i.current_branch", return_value=None), \
528
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks", return_value={}):
529
+ out = io.StringIO()
530
+ with redirect_stdout(out):
531
+ where_was_i._orient_track(track)
532
+ self.assertIn("in-progress", out.getvalue())
533
+
534
+
535
+ class OrientBlockedByTest(unittest.TestCase):
536
+ def test_next_pick_shows_blocked_by(self):
537
+ from types import SimpleNamespace
538
+ track = SimpleNamespace(name="alpha", repo="o/r", local_path=None,
539
+ path=Path("/n/alpha.md"), body="",
540
+ meta={"track": "alpha", "launch_priority": "P1",
541
+ "milestone_alignment": "—",
542
+ "github": {"issues": [5]}, "next_up": [5], "blockers": []})
543
+ issue = {"number": 5, "title": "x", "state": "open", "labels": [],
544
+ "blocked_by": [{"number": 9, "repo": "o/r", "title": "dep"}], "blocking": []}
545
+ with mock.patch("commands.where_was_i.fetch_issues", return_value=[issue]), \
546
+ mock.patch("commands.where_was_i.hot_issue_numbers", return_value=set()), \
547
+ mock.patch("commands.where_was_i.current_branch", return_value=None), \
548
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks", return_value={}):
549
+ out = io.StringIO()
550
+ with redirect_stdout(out):
551
+ from commands import where_was_i
552
+ where_was_i._orient_track(track)
553
+ self.assertIn("blocked by #9", out.getvalue())
554
+
555
+ def _orient_with_blocked_by(self, blocked_by, blockers):
556
+ from types import SimpleNamespace
557
+ track = SimpleNamespace(name="alpha", repo="o/r", local_path=None,
558
+ path=Path("/n/alpha.md"), body="",
559
+ meta={"track": "alpha", "launch_priority": "P1",
560
+ "milestone_alignment": "—",
561
+ "github": {"issues": [5]}, "next_up": [5],
562
+ "blockers": blockers})
563
+ issue = {"number": 5, "title": "x", "state": "open", "labels": [],
564
+ "blocked_by": blocked_by, "blocking": []}
565
+ with mock.patch("commands.where_was_i.fetch_issues", return_value=[issue]), \
566
+ mock.patch("commands.where_was_i.hot_issue_numbers", return_value=set()), \
567
+ mock.patch("commands.where_was_i.current_branch", return_value=None), \
568
+ mock.patch("commands.where_was_i.find_new_issues_for_tracks", return_value={}):
569
+ out = io.StringIO()
570
+ with redirect_stdout(out):
571
+ from commands import where_was_i
572
+ where_was_i._orient_track(track)
573
+ return out.getvalue()
574
+
575
+ def test_cross_repo_blocked_by_shows_qualified_ref(self):
576
+ # A cross-repo edge renders owner/repo#N, not a bare #N.
577
+ out = self._orient_with_blocked_by(
578
+ [{"number": 9, "repo": "other/repo", "title": "dep"}], [])
579
+ self.assertIn("blocked by other/repo#9", out)
580
+
581
+ def test_same_repo_edge_in_manual_blockers_is_suppressed(self):
582
+ # A same-repo edge whose number is a manual blocker is owned by the
583
+ # "Blocker:" line, so it is not re-annotated.
584
+ out = self._orient_with_blocked_by(
585
+ [{"number": 9, "repo": "o/r", "title": "dep"}], [9])
586
+ self.assertNotIn("blocked by", out)
587
+
588
+ def test_cross_repo_edge_not_suppressed_by_same_number_blocker(self):
589
+ # A manual blocker #9 means o/r#9; a cross-repo other/repo#9 is a
590
+ # different issue and must still be annotated.
591
+ out = self._orient_with_blocked_by(
592
+ [{"number": 9, "repo": "other/repo", "title": "dep"}], [9])
593
+ self.assertIn("blocked by other/repo#9", out)
594
+
595
+
516
596
  if __name__ == "__main__":
517
597
  unittest.main()
@@ -51,7 +51,13 @@ SUBCOMMANDS = {
51
51
  "--hygiene": "commands.hygiene", # flag-style alias
52
52
  "plan-status": "commands.plan_status",
53
53
  "--plan-status": "commands.plan_status", # flag-style alias
54
+ "plan-confirm": "commands.plan_confirm",
55
+ "plan-ack": "commands.plan_ack",
56
+ "plan-baseline": "commands.plan_baseline",
57
+ "close-issue": "commands.close_issue",
58
+ "in-progress": "commands.in_progress",
54
59
  "export": "commands.export",
60
+ "auth-status": "commands.auth_status",
55
61
  "list-open-issues": "commands.list_open_issues",
56
62
  "set": "commands.set_field",
57
63
  "new-track": "commands.new_track",
@@ -59,6 +65,7 @@ SUBCOMMANDS = {
59
65
  "set-notes-root": "commands.set_notes_root",
60
66
  "notes-vcs": "commands.notes_vcs",
61
67
  "plan-branch": "commands.plan_branch",
68
+ "push-track": "commands.push_track",
62
69
  }
63
70
 
64
71
  DESCRIPTIONS = [
@@ -147,6 +154,10 @@ DESCRIPTIONS = [
147
154
  "Emit the viewer-ready JSON read surface (schema 1): every frontmatter'd track with repo, tier, status, visibility, blockers, next_up, an open/closed rollup, and per-issue state/assignee/milestone. Read-only; derives live from gh. Consumed by the VS Code extension.",
148
155
  "When a tool (the VS Code viewer, or any script) needs structured track state instead of the human-facing brief/orient text.",
149
156
  "/work-plan export --json"),
157
+ ("auth-status", "[--json]",
158
+ "Report whether `gh` is installed and authenticated to GitHub. Read-only probe (`gh auth status`) — the toolkit's GitHub reads/writes all go through gh, and the fetch helpers return empty rather than erroring, so an unauthenticated session otherwise looks like an empty-but-working one. `--json` emits {gh_present, authenticated, user, error}; exit code: 0 authenticated, 1 gh present but not logged in, 2 gh not found. The VS Code viewer calls this at activation to fast-fail with a sign-in path instead of a misleadingly empty tree.",
159
+ "When you (or the viewer) need to know up front whether GitHub calls will work, instead of discovering it via empty results.",
160
+ "/work-plan auth-status --json"),
150
161
  ("list-open-issues", "--repo=<owner/name> [--exclude=<csv-issue-numbers>]",
151
162
  "Emit a repo's OPEN issues as JSON ({repo, issues:[{number,title,state,assignee,milestone}]}) — the same issue shape as export. Read-only; derives live from gh. --repo takes a bare org/repo slug; --exclude drops the given issue numbers (the viewer passes a track's current issues so already-slotted ones don't reappear). Unlike export's `untracked`, this includes issues tracked by OTHER tracks, since those are valid slot targets.",
152
163
  "When the VS Code viewer's Slot command needs the repo's open issues as a pick-list (the per-track export can't supply issues not yet in the track).",
@@ -167,6 +178,26 @@ DESCRIPTIONS = [
167
178
  "Reach a verdict on every plan/spec doc in a repo by correlating each plan's declared file-manifest (Create/Modify/Test paths) against the filesystem + git — not the unreliable checkboxes. Read-only: reports ✅ shipped / 🟡 partial / 💀 dead / 👻 manifest-less. --json for machine output. Add --stamp to write each verdict into its doc as an idempotent status header (--draft previews without writing). Add --llm for a two-step AI pass that judges prose/ambiguous docs (writes a prompt; you save JSON to the cache; re-run with --llm --apply). --archive moves dead plans to archive/abandoned/ (gated); --issues opens a GitHub issue per partial plan listing its unsatisfied files (gated). Both honor --draft.",
168
179
  "When you point at a repo and need to know what's actually done vs. half-done vs. dead among accumulated plans. Run from inside the repo, or use --repo=<key> for a configured one.",
169
180
  "/work-plan plan-status --repo=myproject"),
181
+ ("plan-confirm", "--repo=<key> --verdict=shipped|partial|dead [--clear] [--confirm=<token>] -- <rel>",
182
+ "Affirm a human verdict on ONE plan/spec doc by writing `verdict_override` into its YAML frontmatter — FRONTMATTER-ONLY (never the body, manifest, checkboxes, or status banner) (#286). plan-status then pins that verdict over the mechanical one and silences the 'shipped but boxes unchecked' lie-gap. Use when a plan genuinely shipped but its phase checkboxes were never ticked, so the red lie-gap X is a false alarm. `<rel>` is the repo-relative doc path from `plan-status --json`. On a PUBLIC repo it prints a confirm heads-up + token and exits (re-run with --confirm=<token>) — the VS Code viewer surfaces this as a modal. --clear removes the override.",
183
+ "When the Plans view flags a genuinely-done plan with a lie-gap (red X) only because nobody ticked its checkboxes — confirm it instead of hand-ticking 24 boxes.",
184
+ "/work-plan plan-confirm --repo=myproject --verdict=shipped -- docs/superpowers/plans/2026-03-16-idea-mode-ui.md"),
185
+ ("plan-ack", "--repo=<key> [--clear] [--confirm=<token>] -- <rel>",
186
+ "Persist an acknowledgment into ONE plan/spec doc's YAML **frontmatter only** (`acknowledged: true`) — never the body/manifest/checkboxes/banner (#286). Unlike the VS Code viewer's default ack (per-machine, ephemeral `workspaceState`), this is durable + shared: it's committed with the repo, and `plan-status` reads it back to demote the doc. `<rel>` is the repo-relative doc path. Public-repo gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`). `--clear` removes it.",
187
+ "When you want a 'stop flagging this plan' that sticks across machines and teammates, not just on your laptop.",
188
+ "/work-plan plan-ack --repo=myproject -- docs/superpowers/plans/2026-03-16-idea-mode-ui.md"),
189
+ ("plan-baseline", "--repo=<key> [--clear] [--confirm=<token>] -- <rel>",
190
+ "Stamp the CURRENT computed verdict into ONE plan/spec doc's YAML **frontmatter only** as a drift baseline (`verdict_baseline`) (#286). Distinct from `plan-confirm` (a human pin) and the body banner. `plan-status` then flags **drift** when the live verdict diverges from the baseline — catching a once-shipped plan that silently regressed (its declared files were deleted/moved). The baseline value is computed authoritatively here. Public-repo gated; `--clear` removes it. `verdict_override`, if present, suppresses drift.",
191
+ "When you want a tripwire on a plan you believe is done: stamp its baseline, and get alerted if it later regresses.",
192
+ "/work-plan plan-baseline --repo=myproject -- docs/superpowers/plans/2026-03-16-idea-mode-ui.md"),
193
+ ("close-issue", "--repo=<key|slug> [--reason=completed|not_planned] [--comment=<text>] -- <number>",
194
+ "⚠️ A GitHub-mutating command (others: `in-progress`, `plan-status --issues`) — closes a GitHub issue via `gh issue close` (most of the toolkit is read-only on GitHub). PRs merged to `dev` don't auto-close issues (GitHub auto-closes only from the default branch), so done-but-OPEN issues pile up; this closes one. `--reason` maps to GitHub's completed/not-planned; `--comment` posts a closing note. `--repo` takes a config key or an org/repo slug. The VS Code viewer gates this behind a mandatory 'Close on GitHub?' modal on every close.",
195
+ "When an issue is actually done but stayed open because its PR merged to dev, not main — close it without leaving the editor.",
196
+ "/work-plan close-issue --repo=stylusnexus/work-plan-toolkit --reason=completed --comment='Closed via dev merge' -- 287"),
197
+ ("in-progress", "<n> [--clear] [--repo=<key|slug>] [--confirm=<token>]",
198
+ "Mark a tracked GitHub issue as in-progress by adding the `work-plan:in-progress` label (or remove it with --clear). Repo-scoped: resolves <n> to the one tracked repo that lists it, or pass --repo to disambiguate. The label is auto-created. Writes into a PUBLIC repo only with a confirm token (prints {needs_confirm, token} otherwise — the VS Code viewer surfaces it as a modal). brief/orient/the viewer also derive in-progress for free from a hot feat/<n>- or fix/<n>- branch.",
199
+ "When you start actively working an issue that has no hot branch yet (a hot branch is detected automatically), or to clear the flag when you stop.",
200
+ "/work-plan in-progress 271"),
170
201
  ("set-notes-root", "<path>",
171
202
  "Update notes_root in ~/.claude/work-plan/config.yml to an absolute path. Creates the target directory if absent. Prints a WARN if existing frontmatter'd tracks live at the old location (they won't be moved — manual migration required). Non-interactive: safe to call from a GUI or script.",
172
203
  "VS Code viewer cold-start: user has picked a folder for their private track notes and the extension invokes this to persist the choice. Also useful on the CLI to relocate notes without hand-editing config.yml.",
@@ -179,6 +210,10 @@ DESCRIPTIONS = [
179
210
  "Set up and share a repo's canonical SHARED-tier plan branch (#260). The shared `.work-plan/` tier is pinned to ONE per-repo `plan_branch`, read/written through a dedicated git worktree, so planning never diverges across code branches or pollutes PR / deploy diffs. `init <repo>` creates that branch + a `.work-plan/` skeleton (default an ORPHAN `work-plan/plan`, zero shared history with code like gh-pages; override with --branch) and records `plan_branch` in config — or CONNECTS to a teammate's already-published branch if one exists. init is LOCAL ONLY (no push). `status <repo>` reports whether the branch exists, is published to origin, and how many commits are unpushed (--json for the machine shape). `push <repo>` shares it: on a PUBLIC repo it prints a confirm heads-up + token and exits (re-run with --confirm=<token>); --dry-run previews the commits that would push. Requires a repo registered via init-repo with a local clone path.",
180
211
  "ONE-TIME per repo when you want the shared plan to live on its own branch (off dev/main) so planning churn never lands in feature PRs or the deploy diff — yet the CLI + VS Code viewer always show the canonical plan from any checkout. `push` is the deliberate step that shares it with teammates.",
181
212
  "/work-plan plan-branch init work-plan-toolkit"),
213
+ ("push-track", "<track | track@repo> [--repo=<key>] [--no-push] [--confirm=<token>]",
214
+ "Promote a PRIVATE track (local-only, in notes_root) to the repo's SHARED tier and publish it (#306). Moves the track's `.md` into the repo's `.work-plan/` (on its `plan_branch`, via a worktree), removes the private copy so it isn't duplicated, commits to the plan branch, and pushes — unless `--no-push` (keeps it local). The tier is derived from location, so this is a file move, not a frontmatter edit. Requires the repo to have a local clone + a `plan_branch` (else hints `plan-branch init`). Pushing to a PUBLIC repo makes the track world-visible, so the push is confirm-token gated (prints `needs_confirm` + token; re-run with `--confirm=<token>`).",
215
+ "When a private track is ready to share with teammates — promote it to the shared plan branch in one step instead of hand-moving the file.",
216
+ "/work-plan push-track my-feature --repo=myproject"),
182
217
  ]
183
218
 
184
219
 
@@ -268,7 +303,7 @@ def main(argv: list[str]) -> int:
268
303
  # (Flag aliases like --brief/--plan-status normalise by stripping leading dashes.)
269
304
  _READONLY_SUBCOMMANDS = frozenset({
270
305
  "brief", "orient", "where-was-i", "list", "coverage", "duplicates",
271
- "plan-status", "export", "list-open-issues", "notes-vcs",
306
+ "plan-status", "export", "list-open-issues", "auth-status", "notes-vcs",
272
307
  # plan-branch manages its OWN commits on the plan branch (init seeds +
273
308
  # commits the skeleton itself); the auto-commit hooks must not also fire.
274
309
  "plan-branch",