@stylusnexus/work-plan 2026.6.11 → 2026.6.13

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 (31) hide show
  1. package/README.md +26 -4
  2. package/VERSION +1 -1
  3. package/package.json +1 -1
  4. package/skills/work-plan/commands/export.py +20 -2
  5. package/skills/work-plan/commands/group.py +5 -1
  6. package/skills/work-plan/commands/init_repo.py +84 -14
  7. package/skills/work-plan/commands/list_open_issues.py +52 -0
  8. package/skills/work-plan/commands/new_track.py +8 -2
  9. package/skills/work-plan/commands/plan_branch.py +314 -0
  10. package/skills/work-plan/commands/plan_status.py +76 -9
  11. package/skills/work-plan/commands/reconcile.py +49 -34
  12. package/skills/work-plan/commands/refresh_md.py +49 -1
  13. package/skills/work-plan/commands/remove_repo.py +69 -0
  14. package/skills/work-plan/lib/export_model.py +21 -4
  15. package/skills/work-plan/lib/git_state.py +22 -0
  16. package/skills/work-plan/lib/manifest.py +10 -0
  17. package/skills/work-plan/lib/plan_worktree.py +288 -0
  18. package/skills/work-plan/lib/tracks.py +6 -2
  19. package/skills/work-plan/lib/verdict.py +1 -0
  20. package/skills/work-plan/tests/test_export.py +40 -0
  21. package/skills/work-plan/tests/test_export_command.py +19 -0
  22. package/skills/work-plan/tests/test_init_repo.py +100 -1
  23. package/skills/work-plan/tests/test_list_open_issues.py +83 -0
  24. package/skills/work-plan/tests/test_notes_vcs_command.py +77 -0
  25. package/skills/work-plan/tests/test_plan_branch.py +279 -0
  26. package/skills/work-plan/tests/test_plan_status_stalled.py +219 -0
  27. package/skills/work-plan/tests/test_plan_worktree.py +378 -0
  28. package/skills/work-plan/tests/test_reconcile_dup_slug.py +138 -0
  29. package/skills/work-plan/tests/test_refresh_md.py +75 -0
  30. package/skills/work-plan/tests/test_remove_repo.py +77 -0
  31. package/skills/work-plan/work_plan.py +95 -6
@@ -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()
@@ -38,6 +38,7 @@ SUBCOMMANDS = {
38
38
  "list": "commands.list_cmd",
39
39
  "init": "commands.init",
40
40
  "init-repo": "commands.init_repo",
41
+ "remove-repo": "commands.remove_repo",
41
42
  "suggest-priorities": "commands.suggest_priorities",
42
43
  "group": "commands.group",
43
44
  "auto-triage": "commands.auto_triage",
@@ -51,11 +52,13 @@ SUBCOMMANDS = {
51
52
  "plan-status": "commands.plan_status",
52
53
  "--plan-status": "commands.plan_status", # flag-style alias
53
54
  "export": "commands.export",
55
+ "list-open-issues": "commands.list_open_issues",
54
56
  "set": "commands.set_field",
55
57
  "new-track": "commands.new_track",
56
58
  "rename-track": "commands.rename_track",
57
59
  "set-notes-root": "commands.set_notes_root",
58
60
  "notes-vcs": "commands.notes_vcs",
61
+ "plan-branch": "commands.plan_branch",
59
62
  }
60
63
 
61
64
  DESCRIPTIONS = [
@@ -100,10 +103,14 @@ DESCRIPTIONS = [
100
103
  "Add frontmatter to an existing track .md file.",
101
104
  "After moving/creating a new .md file in Project Notes/<repo>/ that has no frontmatter.",
102
105
  "/work-plan init '<notes_root>/<repo-key>/foo.md'"),
103
- ("init-repo", "<key> --github=<org/repo> [--local=<path>]",
104
- "Bootstrap a new repo: create <notes_root>/<key>/archive/{shipped,abandoned}/ and add the repo block to your config.",
105
- "When you start tracking a new GitHub repo. Replaces the old 'copy the example folder' setup.",
106
+ ("init-repo", "<key> --github=<org/repo> [--local=<path>] [--update [--clear-local]]",
107
+ "Bootstrap a new repo: create <notes_root>/<key>/archive/{shipped,abandoned}/ and add the repo block to your config. With --update on an existing key, change its local/github; --update --clear-local drops the saved local path (keeps github + other fields). --clear-local and --local are mutually exclusive.",
108
+ "When you start tracking a new GitHub repo. Replaces the old 'copy the example folder' setup. Use --update --clear-local to forget a stale checkout path without removing the repo.",
106
109
  "/work-plan init-repo myproject --github=your-org/myproject"),
110
+ ("remove-repo", "<key>",
111
+ "Unregister a repo: delete its block from your config (config-only). The notes folder, any tracks, and the local clone are LEFT UNTOUCHED — if a notes folder or tracks reference it they're now orphaned and can be cleaned up by hand.",
112
+ "When you stop tracking a repo and want it out of the sidebar/brief without deleting your notes. Completes the add/update/remove trio with init-repo.",
113
+ "/work-plan remove-repo myproject"),
107
114
  ("suggest-priorities", "[--repo=<folder>] [--apply]",
108
115
  "AI-assisted batch backfill of priority/PN labels.",
109
116
  "ONE-TIME setup, or whenever a wave of new unlabeled issues piles up.",
@@ -140,6 +147,10 @@ DESCRIPTIONS = [
140
147
  "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.",
141
148
  "When a tool (the VS Code viewer, or any script) needs structured track state instead of the human-facing brief/orient text.",
142
149
  "/work-plan export --json"),
150
+ ("list-open-issues", "--repo=<owner/name> [--exclude=<csv-issue-numbers>]",
151
+ "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
+ "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).",
153
+ "/work-plan list-open-issues --repo=stylusnexus/work-plan-toolkit --exclude=87,91"),
143
154
  ("set", "<track | track@repo> field=value [field=value …] [--repo=<key>] [--confirm=<token>]",
144
155
  "Guarded edit of a track's frontmatter fields (status, launch_priority, milestone_alignment, blockers, next_up). Validates field names + status values; blockers/next_up take comma-separated issue numbers. Setting `next_up` here writes ONLY the frontmatter field — for next_up plus a session-log entry (and a body refresh), use `handoff --set-next` instead. Writes into a PUBLIC repo only with a confirm token: without one it prints {needs_confirm, reason, token} and makes no change (the VS Code viewer surfaces that as a modal, then re-invokes with --confirm=<token>).",
145
156
  "Programmatic/GUI edits that have no dedicated verb — e.g. the VS Code extension changing a status or blockers list. On the terminal you'll usually use the named verbs instead.",
@@ -164,6 +175,10 @@ DESCRIPTIONS = [
164
175
  "Opt-in LOCAL version control for the private notes_root tier — history/undo for tracks you keep on your machine, never pushed. `init` git-inits notes_root as a personal repo (initial commit of existing tracks) and turns on auto-commit; with --no-enable it inits without enabling. For safety it REFUSES a notes_root that already has a git remote or is a repo work-plan didn't create, and only ever commits the files a command changed — private notes stay un-pushable and your unrelated edits are never swept in. `enable`/`disable` toggle auto-commit (history is kept either way). `status` reports whether notes_root is a repo, whether auto-commit is on, and the last commit (add --json for the machine-readable shape the VS Code viewer polls). `undo [<sha>]` reverts a commit (default HEAD) — the last edit, by default. When auto-commit is on, every track-mutating command (slot/group/handoff/close/set/…) writes an undoable commit; the shared tier is unaffected (it's versioned by its own repo).",
165
176
  "ONE-TIME setup when you want a git safety net for private tracks — so a bulk slot or a bad edit is reversible by default instead of needing a manual /tmp backup. `undo` reverses the last edit.",
166
177
  "/work-plan notes-vcs init"),
178
+ ("plan-branch", "<init|status|push> <repo> [--branch=<name>] [--confirm=<token>] [--dry-run] [--json]",
179
+ "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
+ "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
+ "/work-plan plan-branch init work-plan-toolkit"),
167
182
  ]
168
183
 
169
184
 
@@ -236,9 +251,16 @@ def main(argv: list[str]) -> int:
236
251
  # Snapshot notes_root's dirty set BEFORE the command so we can commit only
237
252
  # what this command changes (and never fold in pre-existing edits, #244-vcs).
238
253
  pre = _notes_precommit_state(sub)
254
+ # Same discipline for each plan_branch repo's shared tier (#260): snapshot
255
+ # the dirty .work-plan/ paths per worktree BEFORE the run, commit only the
256
+ # delta AFTER — so an unrelated subcommand never sweeps in pre-existing edits.
257
+ shared_pre = _shared_precommit_state(sub)
239
258
  rc = module.run(argv[2:])
240
- if rc == 0 and pre is not None:
241
- _commit_changed_notes(pre, argv[1:])
259
+ if rc == 0:
260
+ if pre is not None:
261
+ _commit_changed_notes(pre, argv[1:])
262
+ if shared_pre:
263
+ _commit_shared_writes(shared_pre, argv[1:])
242
264
  return rc
243
265
 
244
266
 
@@ -246,7 +268,10 @@ def main(argv: list[str]) -> int:
246
268
  # (Flag aliases like --brief/--plan-status normalise by stripping leading dashes.)
247
269
  _READONLY_SUBCOMMANDS = frozenset({
248
270
  "brief", "orient", "where-was-i", "list", "coverage", "duplicates",
249
- "plan-status", "export", "notes-vcs",
271
+ "plan-status", "export", "list-open-issues", "notes-vcs",
272
+ # plan-branch manages its OWN commits on the plan branch (init seeds +
273
+ # commits the skeleton itself); the auto-commit hooks must not also fire.
274
+ "plan-branch",
250
275
  })
251
276
 
252
277
 
@@ -306,5 +331,69 @@ def _commit_changed_notes(pre, parts: list[str]) -> None:
306
331
  return
307
332
 
308
333
 
334
+ def _shared_precommit_state(sub: str):
335
+ """Snapshot each `plan_branch` repo's dirty `.work-plan/` paths BEFORE a
336
+ command, so we can later commit only what the command changed. Returns a
337
+ list of (key, branch, worktree, before_paths) — one per plan_branch repo
338
+ whose worktree could be ensured — or None. Best-effort; never raises.
339
+
340
+ Read-only verbs and legacy (no-plan_branch) repos are skipped — the latter's
341
+ shared tier is the working tree and is never auto-committed.
342
+ """
343
+ if sub.lstrip("-") in _READONLY_SUBCOMMANDS:
344
+ return None
345
+ try:
346
+ from lib.config import load_config
347
+ from lib import plan_worktree
348
+ from pathlib import Path
349
+
350
+ cfg = load_config()
351
+ states = []
352
+ for key, entry in (cfg.get("repos") or {}).items():
353
+ if not entry or not entry.get("plan_branch") or not entry.get("local"):
354
+ continue
355
+ branch = entry["plan_branch"]
356
+ worktree = plan_worktree.ensure_worktree(
357
+ Path(entry["local"]).expanduser(), branch
358
+ )
359
+ if worktree is None:
360
+ continue
361
+ before = plan_worktree.dirty_work_plan_paths(worktree)
362
+ states.append((key, branch, worktree, set(before)))
363
+ return states or None
364
+ except Exception:
365
+ return None
366
+
367
+
368
+ def _commit_shared_writes(pre_states, parts: list[str]) -> None:
369
+ """Commit ONLY the `.work-plan/` paths each command newly changed (after −
370
+ before) per plan_branch repo, on that repo's plan_branch via its worktree
371
+ (#260). Leaves pre-existing dirty plan files untouched. Best-effort; never
372
+ raises and never changes the command's exit code. Local commit only (pushing
373
+ is a deliberate follow-up step).
374
+ """
375
+ try:
376
+ from lib import plan_worktree
377
+ except Exception:
378
+ return
379
+ message = "work-plan " + " ".join(parts)
380
+ for key, branch, worktree, before in (pre_states or []):
381
+ # Per-repo isolation: one repo's git failure must not skip the others.
382
+ try:
383
+ changed = sorted(set(plan_worktree.dirty_work_plan_paths(worktree)) - before)
384
+ if not changed:
385
+ continue
386
+ sha = plan_worktree.commit_shared_tier(worktree, message, changed)
387
+ if sha:
388
+ print(f"⏺ shared plan committed {sha} on {branch} ({key}) — "
389
+ f"not yet pushed", file=sys.stderr)
390
+ else:
391
+ print(f"⚠ shared plan changes in {key} ({branch}) were NOT "
392
+ f"committed (git refused) — review the worktree.",
393
+ file=sys.stderr)
394
+ except Exception:
395
+ continue
396
+
397
+
309
398
  if __name__ == "__main__":
310
399
  sys.exit(main(sys.argv))