@stylusnexus/work-plan 2026.6.11 → 2026.6.13-2

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 (51) hide show
  1. package/README.md +38 -6
  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/close_issue.py +82 -0
  7. package/skills/work-plan/commands/export.py +72 -3
  8. package/skills/work-plan/commands/group.py +5 -1
  9. package/skills/work-plan/commands/init_repo.py +84 -14
  10. package/skills/work-plan/commands/list_open_issues.py +52 -0
  11. package/skills/work-plan/commands/new_track.py +8 -2
  12. package/skills/work-plan/commands/plan_ack.py +71 -0
  13. package/skills/work-plan/commands/plan_baseline.py +85 -0
  14. package/skills/work-plan/commands/plan_branch.py +314 -0
  15. package/skills/work-plan/commands/plan_confirm.py +83 -0
  16. package/skills/work-plan/commands/plan_status.py +140 -9
  17. package/skills/work-plan/commands/push_track.py +156 -0
  18. package/skills/work-plan/commands/reconcile.py +49 -34
  19. package/skills/work-plan/commands/refresh_md.py +49 -1
  20. package/skills/work-plan/commands/remove_repo.py +69 -0
  21. package/skills/work-plan/commands/set_field.py +22 -3
  22. package/skills/work-plan/lib/export_model.py +27 -4
  23. package/skills/work-plan/lib/git_state.py +22 -0
  24. package/skills/work-plan/lib/github_state.py +63 -0
  25. package/skills/work-plan/lib/manifest.py +28 -0
  26. package/skills/work-plan/lib/plan_fm.py +71 -0
  27. package/skills/work-plan/lib/plan_worktree.py +288 -0
  28. package/skills/work-plan/lib/status_header.py +6 -2
  29. package/skills/work-plan/lib/tracks.py +6 -2
  30. package/skills/work-plan/lib/verdict.py +1 -0
  31. package/skills/work-plan/tests/test_auth_status.py +98 -0
  32. package/skills/work-plan/tests/test_close_issue.py +121 -0
  33. package/skills/work-plan/tests/test_export.py +65 -0
  34. package/skills/work-plan/tests/test_export_command.py +95 -0
  35. package/skills/work-plan/tests/test_init_repo.py +100 -1
  36. package/skills/work-plan/tests/test_list_open_issues.py +83 -0
  37. package/skills/work-plan/tests/test_manifest.py +30 -1
  38. package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
  39. package/skills/work-plan/tests/test_plan_ack.py +104 -0
  40. package/skills/work-plan/tests/test_plan_baseline.py +86 -0
  41. package/skills/work-plan/tests/test_plan_branch.py +279 -0
  42. package/skills/work-plan/tests/test_plan_confirm.py +109 -0
  43. package/skills/work-plan/tests/test_plan_status_override.py +145 -0
  44. package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
  45. package/skills/work-plan/tests/test_plan_worktree.py +378 -0
  46. package/skills/work-plan/tests/test_push_track.py +131 -0
  47. package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
  48. package/skills/work-plan/tests/test_refresh_md.py +75 -0
  49. package/skills/work-plan/tests/test_remove_repo.py +77 -0
  50. package/skills/work-plan/tests/test_set_field.py +60 -0
  51. package/skills/work-plan/work_plan.py +125 -6
@@ -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,138 @@
1
+ """Cross-repo duplicate-slug isolation in reconcile (#255).
2
+
3
+ Identical track slugs in DIFFERENT repos are explicitly supported. Reconcile's
4
+ in-flight state must be keyed by a per-track identity (repo, path), not by slug
5
+ — otherwise a later same-slug track's fetch overwrites the earlier one's, and
6
+ under `--all --yes` issues from one repo get written into the same-named track
7
+ in ANOTHER repo (membership corruption).
8
+
9
+ All gh calls are mocked; tests run offline. The fake `gh` here is REPO-AWARE
10
+ (unlike the shared move harness) so two same-slug tracks in different repos can
11
+ return different labeled issues.
12
+ """
13
+ import json
14
+ import sys
15
+ import unittest
16
+ from pathlib import Path
17
+ from types import SimpleNamespace
18
+ from unittest.mock import MagicMock, patch
19
+
20
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
21
+ sys.path.insert(0, str(SKILL_ROOT))
22
+
23
+ from commands import reconcile
24
+
25
+
26
+ def _track(*, slug, repo, issues=None):
27
+ # Path embeds the repo so two same-slug tracks have distinct file paths,
28
+ # mirroring how discover_tracks lays out per-repo note dirs.
29
+ safe_repo = repo.replace("/", "_")
30
+ return SimpleNamespace(
31
+ name=slug,
32
+ path=Path(f"/tmp/fake/{safe_repo}/{slug}.md"),
33
+ body="# fake",
34
+ meta={"track": slug, "status": "active",
35
+ "github": {"repo": repo, "issues": list(issues or [])}},
36
+ has_frontmatter=True,
37
+ repo=repo,
38
+ )
39
+
40
+
41
+ class _RepoAwareHarness:
42
+ """Drives reconcile --all where labeled issues depend on BOTH repo and label.
43
+
44
+ `labeled` maps (repo, label) -> list of issue dicts that
45
+ `gh issue/pr list --repo <repo> --label <label>` should return.
46
+ """
47
+
48
+ def __init__(self, tracks, labeled):
49
+ self.tracks = tracks
50
+ self.labeled = labeled
51
+ self.writes = [] # (path_str, issues) per write_file call
52
+
53
+ def _fake_run(self, argv, *a, **kw):
54
+ out = []
55
+ if "--label" in argv and argv[1] == "issue": # count issues once, not PRs
56
+ repo = argv[argv.index("--repo") + 1]
57
+ lab = argv[argv.index("--label") + 1]
58
+ out = self.labeled.get((repo, lab), [])
59
+ return MagicMock(returncode=0, stdout=json.dumps(out), stderr="")
60
+
61
+ def _fake_write(self, path, meta, body):
62
+ self.writes.append((str(path), list(meta.get("github", {}).get("issues") or [])))
63
+
64
+ def run(self, extra_args=None):
65
+ cfg = {"notes_root": "/tmp/n"}
66
+ with patch("commands.reconcile.subprocess.run", side_effect=self._fake_run), \
67
+ patch("commands.reconcile.load_config", return_value=cfg), \
68
+ patch("commands.reconcile.discover_tracks", return_value=self.tracks), \
69
+ patch("commands.reconcile.needs_confirm", return_value=False), \
70
+ patch("commands.reconcile.write_file", side_effect=self._fake_write), \
71
+ patch("commands.reconcile.prompt_input", return_value="y"):
72
+ rc = reconcile.run(["--all"] + (extra_args or []))
73
+ return rc
74
+
75
+
76
+ class DupSlugCrossRepoTest(unittest.TestCase):
77
+ def test_adds_land_in_the_correct_repo_track(self):
78
+ """Two tracks share slug 'core' across repos o/a and o/b. Each repo
79
+ labels a DIFFERENT issue. Under --all --yes, each issue must land in
80
+ its OWN repo's track — never bleed into the same-named sibling."""
81
+ a = _track(slug="core", repo="o/a", issues=[])
82
+ b = _track(slug="core", repo="o/b", issues=[])
83
+ labeled = {
84
+ ("o/a", "track/core"): [{"number": 11, "title": "a-issue", "state": "OPEN"}],
85
+ ("o/b", "track/core"): [{"number": 22, "title": "b-issue", "state": "OPEN"}],
86
+ }
87
+ h = _RepoAwareHarness([a, b], labeled)
88
+ rc = h.run(extra_args=["--yes"])
89
+ self.assertEqual(rc, 0)
90
+ writes = dict(h.writes)
91
+ # Key off each track's real Path string (backslashes on Windows), not a
92
+ # hardcoded POSIX literal — the collision is the slug, not the path.
93
+ self.assertEqual(writes[str(a.path)], [11])
94
+ self.assertEqual(writes[str(b.path)], [22])
95
+
96
+ def test_failed_fetch_does_not_corrupt_same_slug_sibling(self):
97
+ """If repo o/a's fetch is intact but o/b's is independent, the second
98
+ track's results must not overwrite the first's. Here o/a adds #11 and
99
+ o/b adds #22; pre-fix, the shared 'core' key meant the second fetch
100
+ clobbered the first and #11 was lost / mis-routed."""
101
+ a = _track(slug="core", repo="o/a", issues=[11]) # already has #11
102
+ b = _track(slug="core", repo="o/b", issues=[])
103
+ labeled = {
104
+ # o/a still labels #11 (no change → no write expected for a)
105
+ ("o/a", "track/core"): [{"number": 11, "title": "a", "state": "OPEN"}],
106
+ # o/b newly labels #99
107
+ ("o/b", "track/core"): [{"number": 99, "title": "b", "state": "OPEN"}],
108
+ }
109
+ h = _RepoAwareHarness([a, b], labeled)
110
+ rc = h.run(extra_args=["--yes"])
111
+ self.assertEqual(rc, 0)
112
+ writes = dict(h.writes)
113
+ # o/a unchanged → not written. o/b gains #99 only — NOT #11.
114
+ self.assertNotIn(str(a.path), writes)
115
+ self.assertEqual(writes[str(b.path)], [99])
116
+
117
+ def test_no_cross_repo_move_between_same_slug_tracks(self):
118
+ """A move only fires within ONE repo. #50 sits in o/a's 'core'
119
+ frontmatter and is labeled for o/b's 'core' — different repos, so it
120
+ must stay a FLAG on o/a, not move across the repo boundary."""
121
+ a = _track(slug="core", repo="o/a", issues=[50])
122
+ b = _track(slug="core", repo="o/b", issues=[])
123
+ labeled = {
124
+ ("o/a", "track/core"): [], # #50 lost its label in o/a
125
+ ("o/b", "track/core"): [{"number": 50, "title": "x", "state": "OPEN"}],
126
+ }
127
+ h = _RepoAwareHarness([a, b], labeled)
128
+ rc = h.run(extra_args=["--yes"])
129
+ self.assertEqual(rc, 0)
130
+ writes = dict(h.writes)
131
+ # o/a keeps #50 (no same-repo move target) → not rewritten.
132
+ self.assertNotIn(str(a.path), writes)
133
+ # o/b ADDs #50 because it now carries o/b's label — legitimate, in-repo.
134
+ self.assertEqual(writes.get(str(b.path)), [50])
135
+
136
+
137
+ if __name__ == "__main__":
138
+ unittest.main()
@@ -160,6 +160,81 @@ class CanonicalRederiveTest(unittest.TestCase):
160
160
  self.assertIn("## Notes\n\nkeep me", mw.call_args[0][2])
161
161
 
162
162
 
163
+ class PartialFetchTest(unittest.TestCase):
164
+ """A degraded GitHub fetch must never overwrite valid rows with
165
+ '(not fetched)'. The track is skipped, left untouched, and the run exits
166
+ nonzero so --yes / hygiene callers see the degradation (#256)."""
167
+
168
+ def test_partial_fetch_skips_track_and_preserves_rows(self):
169
+ """One of several frontmatter issues missing → no write, rc=1, the
170
+ existing table is left exactly as it was."""
171
+ existing = [_gh(1, "first"), _gh(2, "second")]
172
+ track = _track(name="t", repo="o/r", issues=[1, 2, 3],
173
+ body=_canon_body(existing + [_gh(3, "third")]))
174
+ original_body = track.body
175
+ # #3 fails to come back from the fetch.
176
+ rc, mw, out = _drive(track, existing, ["t", "--yes"])
177
+ self.assertEqual(rc, 1)
178
+ mw.assert_not_called()
179
+ self.assertEqual(track.body, original_body)
180
+ self.assertNotIn("(not fetched)", out)
181
+ self.assertIn("#3", out)
182
+ self.assertNotIn("All tracks in sync.", out)
183
+
184
+ def test_total_fetch_failure_skips_track(self):
185
+ """Fetch returns nothing (GitHub unreachable) → track skipped, rc=1,
186
+ no write, table untouched."""
187
+ existing = [_gh(1, "first"), _gh(2, "second")]
188
+ track = _track(name="t", repo="o/r", issues=[1, 2],
189
+ body=_canon_body(existing))
190
+ rc, mw, out = _drive(track, [], ["t", "--yes"])
191
+ self.assertEqual(rc, 1)
192
+ mw.assert_not_called()
193
+ self.assertIn("no issues", out)
194
+
195
+ def test_healthy_track_still_refreshes_alongside_degraded(self):
196
+ """In an --all batch, a complete-fetch track writes normally while a
197
+ degraded track is skipped; the run still exits nonzero overall."""
198
+ good_existing = [_gh(1, "first", "OPEN")]
199
+ good = _track(name="good", repo="o/r", issues=[1],
200
+ body=_canon_body(good_existing))
201
+ bad = _track(name="bad", repo="o/r", issues=[5, 6],
202
+ body=_canon_body([_gh(5, "fifth"), _gh(6, "sixth")]))
203
+
204
+ # good: #1 fetched (and flipped to CLOSED so there's a write); bad: #6 missing.
205
+ fetched = [_gh(1, "first", "CLOSED"), _gh(5, "fifth")]
206
+ cfg = {"notes_root": "/tmp/fake"}
207
+ with patch("commands.refresh_md.load_config", return_value=cfg), \
208
+ patch("commands.refresh_md.discover_tracks", return_value=[good, bad]), \
209
+ patch("commands.refresh_md.fetch_issues", return_value=fetched), \
210
+ patch("commands.refresh_md.write_file") as mw:
211
+ buf = io.StringIO()
212
+ with redirect_stdout(buf):
213
+ rc = refresh_md.run(["--all", "--yes"])
214
+ out = buf.getvalue()
215
+ self.assertEqual(rc, 1)
216
+ # Only the healthy track is written.
217
+ self.assertEqual(mw.call_count, 1)
218
+ written_path = mw.call_args[0][0]
219
+ self.assertEqual(written_path.name, "good.md")
220
+ self.assertIn("#6", out) # degraded track's missing issue is reported
221
+
222
+ def test_table_only_number_absent_from_frontmatter_does_not_gate(self):
223
+ """A number that appears in the body table but NOT in frontmatter
224
+ doesn't feed the rebuild, so a fetch miss on it is harmless — the track
225
+ still refreshes (rc=0)."""
226
+ # Frontmatter membership is [1, 2]; the body also references #99, which
227
+ # is not in frontmatter. #99 is not fetched, but must not block.
228
+ existing = [_gh(1, "first", "OPEN"), _gh(2, "second")]
229
+ body = _canon_body(existing) + "\nSee also #99 for context.\n"
230
+ track = _track(name="t", repo="o/r", issues=[1, 2], body=body)
231
+ rc, mw, out = _drive(track, [_gh(1, "first", "CLOSED"), _gh(2, "second")],
232
+ ["t", "--yes"])
233
+ self.assertEqual(rc, 0)
234
+ mw.assert_called_once()
235
+ self.assertNotIn("(not fetched)", mw.call_args[0][2])
236
+
237
+
163
238
  class NarrativeTableTest(unittest.TestCase):
164
239
  """Tracks with NO canonical marker keep the conservative in-place behavior."""
165
240
 
@@ -0,0 +1,77 @@
1
+ """Tests for the non-interactive remove-repo command (#290).
2
+
3
+ Covers:
4
+ - Removing an existing key → yq called with del(.repos.<key>); rc 0.
5
+ - Missing key (not in config) → rc 1, yq NOT called.
6
+ - Invalid key format → rc 2, yq NOT called.
7
+ - No positional key → rc 2.
8
+ """
9
+ import io
10
+ import sys
11
+ import unittest
12
+ from contextlib import redirect_stdout
13
+ from pathlib import Path
14
+ from unittest.mock import patch, MagicMock
15
+
16
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
17
+ sys.path.insert(0, str(SKILL_ROOT))
18
+
19
+ from commands import remove_repo
20
+
21
+
22
+ def _make_cfg(*, notes_root="/tmp/fake-notes", repos=None):
23
+ return {"notes_root": notes_root, "repos": repos or {}}
24
+
25
+
26
+ def _drive(args, *, existing_repos=None):
27
+ cfg = _make_cfg(repos=existing_repos or {})
28
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
29
+ with patch("commands.remove_repo.load_config", return_value=cfg), \
30
+ patch("commands.remove_repo.subprocess.run", return_value=mock_proc) as msub, \
31
+ patch("pathlib.Path.exists", return_value=False):
32
+ buf = io.StringIO()
33
+ with redirect_stdout(buf):
34
+ rc = remove_repo.run(args)
35
+ return rc, msub, buf.getvalue()
36
+
37
+
38
+ class RemoveRepoTest(unittest.TestCase):
39
+
40
+ def test_removes_existing_key(self):
41
+ """Existing key → yq del(.repos.<key>) called; rc 0; '✓ Removed' printed."""
42
+ existing = {"mykey": {"github": "org/myrepo", "local": "/some/path"}}
43
+ rc, msub, out = _drive(["mykey"], existing_repos=existing)
44
+ self.assertEqual(rc, 0)
45
+ msub.assert_called_once()
46
+ yq_args = msub.call_args[0][0]
47
+ self.assertEqual(yq_args[0], "yq")
48
+ self.assertEqual(yq_args[1], "-i")
49
+ self.assertEqual(yq_args[2], "del(.repos.mykey)")
50
+ self.assertIn("✓ Removed", out)
51
+ # Config-only note surfaces the untouched local clone.
52
+ self.assertIn("config-only", out)
53
+
54
+ def test_missing_key_returns_rc1(self):
55
+ """Key not in config.repos → rc 1, yq NOT called."""
56
+ existing = {"otherkey": {"github": "org/other"}}
57
+ rc, msub, out = _drive(["mykey"], existing_repos=existing)
58
+ self.assertEqual(rc, 1)
59
+ msub.assert_not_called()
60
+ self.assertIn("not found", out)
61
+
62
+ def test_invalid_key_format_returns_rc2(self):
63
+ """Uppercase key → rc 2, yq NOT called (validated before load)."""
64
+ rc, msub, out = _drive(["MyKey"], existing_repos={"MyKey": {}})
65
+ self.assertEqual(rc, 2)
66
+ msub.assert_not_called()
67
+ self.assertIn("not a valid key", out)
68
+
69
+ def test_no_key_returns_rc2(self):
70
+ """No positional key → rc 2, yq NOT called."""
71
+ rc, msub, out = _drive([], existing_repos={})
72
+ self.assertEqual(rc, 2)
73
+ msub.assert_not_called()
74
+
75
+
76
+ if __name__ == "__main__":
77
+ 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())