@stylusnexus/work-plan 2026.6.9-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/VERSION +1 -0
  4. package/bin/work-plan +59 -0
  5. package/bin/work-plan.cmd +9 -0
  6. package/package.json +43 -0
  7. package/scripts/npm-check-deps.js +44 -0
  8. package/skills/work-plan/SKILL.md +152 -0
  9. package/skills/work-plan/commands/__init__.py +0 -0
  10. package/skills/work-plan/commands/auto_triage.py +230 -0
  11. package/skills/work-plan/commands/brief.py +247 -0
  12. package/skills/work-plan/commands/canonicalize.py +139 -0
  13. package/skills/work-plan/commands/close.py +98 -0
  14. package/skills/work-plan/commands/coverage.py +100 -0
  15. package/skills/work-plan/commands/duplicates.py +124 -0
  16. package/skills/work-plan/commands/export.py +69 -0
  17. package/skills/work-plan/commands/group.py +272 -0
  18. package/skills/work-plan/commands/handoff.py +867 -0
  19. package/skills/work-plan/commands/hygiene.py +128 -0
  20. package/skills/work-plan/commands/init.py +128 -0
  21. package/skills/work-plan/commands/init_repo.py +132 -0
  22. package/skills/work-plan/commands/list_cmd.py +39 -0
  23. package/skills/work-plan/commands/new_track.py +225 -0
  24. package/skills/work-plan/commands/plan_status.py +296 -0
  25. package/skills/work-plan/commands/reconcile.py +225 -0
  26. package/skills/work-plan/commands/refresh_md.py +145 -0
  27. package/skills/work-plan/commands/set_field.py +61 -0
  28. package/skills/work-plan/commands/set_notes_root.py +53 -0
  29. package/skills/work-plan/commands/slot.py +154 -0
  30. package/skills/work-plan/commands/suggest_priorities.py +132 -0
  31. package/skills/work-plan/commands/where_was_i.py +325 -0
  32. package/skills/work-plan/lib/__init__.py +0 -0
  33. package/skills/work-plan/lib/closure.py +72 -0
  34. package/skills/work-plan/lib/config.py +88 -0
  35. package/skills/work-plan/lib/doc_discovery.py +41 -0
  36. package/skills/work-plan/lib/drift.py +32 -0
  37. package/skills/work-plan/lib/export_model.py +42 -0
  38. package/skills/work-plan/lib/frontmatter.py +48 -0
  39. package/skills/work-plan/lib/git_state.py +180 -0
  40. package/skills/work-plan/lib/github_state.py +296 -0
  41. package/skills/work-plan/lib/llm_evidence.py +45 -0
  42. package/skills/work-plan/lib/manifest.py +164 -0
  43. package/skills/work-plan/lib/new_issues.py +69 -0
  44. package/skills/work-plan/lib/next_up.py +98 -0
  45. package/skills/work-plan/lib/notes_readme.py +38 -0
  46. package/skills/work-plan/lib/prompts.py +68 -0
  47. package/skills/work-plan/lib/reconcile_actions.py +34 -0
  48. package/skills/work-plan/lib/render.py +83 -0
  49. package/skills/work-plan/lib/scratch.py +14 -0
  50. package/skills/work-plan/lib/session_log.py +39 -0
  51. package/skills/work-plan/lib/status_header.py +60 -0
  52. package/skills/work-plan/lib/status_table.py +227 -0
  53. package/skills/work-plan/lib/tracks.py +248 -0
  54. package/skills/work-plan/lib/verdict.py +51 -0
  55. package/skills/work-plan/lib/write_guard.py +39 -0
  56. package/skills/work-plan/tests/__init__.py +0 -0
  57. package/skills/work-plan/tests/fixtures/notes_root/critforge/archive/shipped/old.md +10 -0
  58. package/skills/work-plan/tests/fixtures/notes_root/critforge/example.md +11 -0
  59. package/skills/work-plan/tests/fixtures/notes_root/critforge/no_frontmatter.md +1 -0
  60. package/skills/work-plan/tests/fixtures/notes_root/loose_at_root.md +1 -0
  61. package/skills/work-plan/tests/fixtures/track_with_frontmatter.md +14 -0
  62. package/skills/work-plan/tests/fixtures/track_without_frontmatter.md +3 -0
  63. package/skills/work-plan/tests/fixtures/with_status_table.md +9 -0
  64. package/skills/work-plan/tests/test_auto_triage.py +324 -0
  65. package/skills/work-plan/tests/test_close.py +273 -0
  66. package/skills/work-plan/tests/test_close_tier.py +166 -0
  67. package/skills/work-plan/tests/test_closure.py +51 -0
  68. package/skills/work-plan/tests/test_config.py +85 -0
  69. package/skills/work-plan/tests/test_config_seed.py +41 -0
  70. package/skills/work-plan/tests/test_config_shared.py +57 -0
  71. package/skills/work-plan/tests/test_coverage.py +192 -0
  72. package/skills/work-plan/tests/test_doc_discovery.py +51 -0
  73. package/skills/work-plan/tests/test_drift.py +38 -0
  74. package/skills/work-plan/tests/test_export.py +169 -0
  75. package/skills/work-plan/tests/test_export_command.py +295 -0
  76. package/skills/work-plan/tests/test_frontmatter.py +52 -0
  77. package/skills/work-plan/tests/test_git_state.py +51 -0
  78. package/skills/work-plan/tests/test_git_state_paths.py +51 -0
  79. package/skills/work-plan/tests/test_github_state.py +508 -0
  80. package/skills/work-plan/tests/test_group_apply.py +348 -0
  81. package/skills/work-plan/tests/test_handoff_append_rows.py +73 -0
  82. package/skills/work-plan/tests/test_handoff_attribution.py +152 -0
  83. package/skills/work-plan/tests/test_handoff_auto_next_skip.py +183 -0
  84. package/skills/work-plan/tests/test_handoff_collision_warning.py +149 -0
  85. package/skills/work-plan/tests/test_handoff_set_next.py +106 -0
  86. package/skills/work-plan/tests/test_init.py +289 -0
  87. package/skills/work-plan/tests/test_init_repo.py +379 -0
  88. package/skills/work-plan/tests/test_init_shared.py +185 -0
  89. package/skills/work-plan/tests/test_llm_evidence.py +77 -0
  90. package/skills/work-plan/tests/test_manifest.py +162 -0
  91. package/skills/work-plan/tests/test_new_issues.py +130 -0
  92. package/skills/work-plan/tests/test_new_track.py +610 -0
  93. package/skills/work-plan/tests/test_next_up.py +149 -0
  94. package/skills/work-plan/tests/test_notes_readme.py +78 -0
  95. package/skills/work-plan/tests/test_plan_status.py +68 -0
  96. package/skills/work-plan/tests/test_plan_status_archive.py +61 -0
  97. package/skills/work-plan/tests/test_plan_status_foreign.py +55 -0
  98. package/skills/work-plan/tests/test_plan_status_issues.py +61 -0
  99. package/skills/work-plan/tests/test_plan_status_llm_apply.py +71 -0
  100. package/skills/work-plan/tests/test_plan_status_llm_prepare.py +66 -0
  101. package/skills/work-plan/tests/test_plan_status_stamp.py +70 -0
  102. package/skills/work-plan/tests/test_plugin_manifests.py +38 -0
  103. package/skills/work-plan/tests/test_reconcile_actions.py +60 -0
  104. package/skills/work-plan/tests/test_reconcile_readonly.py +239 -0
  105. package/skills/work-plan/tests/test_reconcile_wrappers.py +55 -0
  106. package/skills/work-plan/tests/test_refresh_md.py +98 -0
  107. package/skills/work-plan/tests/test_render.py +110 -0
  108. package/skills/work-plan/tests/test_repo_filter.py +52 -0
  109. package/skills/work-plan/tests/test_security_hardening.py +117 -0
  110. package/skills/work-plan/tests/test_session_log.py +39 -0
  111. package/skills/work-plan/tests/test_set_field.py +77 -0
  112. package/skills/work-plan/tests/test_set_notes_root.py +292 -0
  113. package/skills/work-plan/tests/test_slot.py +243 -0
  114. package/skills/work-plan/tests/test_slot_move.py +128 -0
  115. package/skills/work-plan/tests/test_smoke.py +46 -0
  116. package/skills/work-plan/tests/test_status_header.py +79 -0
  117. package/skills/work-plan/tests/test_status_table.py +162 -0
  118. package/skills/work-plan/tests/test_suggested_first_action.py +112 -0
  119. package/skills/work-plan/tests/test_track_resolution.py +295 -0
  120. package/skills/work-plan/tests/test_tracks.py +385 -0
  121. package/skills/work-plan/tests/test_verdict.py +60 -0
  122. package/skills/work-plan/tests/test_where_was_i.py +382 -0
  123. package/skills/work-plan/tests/test_write_guard.py +53 -0
  124. package/skills/work-plan/work_plan.py +220 -0
@@ -0,0 +1,289 @@
1
+ """Tests for the non-interactive init command (issue #87, Phase 3a).
2
+
3
+ Covers:
4
+ - Writes frontmatter with --priority=P1 --milestone=v2 (meta reflects them).
5
+ - Defaults P2/v1.0.0 when flags absent.
6
+ - Invalid --priority=P9 → falls back to P2.
7
+ - Body '#41 #88' refs become github.issues:[41,88].
8
+ - 'Already has frontmatter' → no write, rc 0.
9
+ - Public real repo, no token → needs_confirm JSON, no write, rc 0;
10
+ token == make_token(repo, slug).
11
+ - TBD/unknown repo → NO gate, writes normally (local-only case).
12
+ - Valid --confirm on a public repo → writes.
13
+ - No input()/prompt_input reached (patch to raise).
14
+ """
15
+ import io
16
+ import json
17
+ import sys
18
+ import unittest
19
+ from contextlib import redirect_stdout
20
+ from pathlib import Path
21
+ from types import SimpleNamespace
22
+ from unittest.mock import patch, MagicMock
23
+
24
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
25
+ sys.path.insert(0, str(SKILL_ROOT))
26
+
27
+ from commands import init
28
+ from lib.write_guard import make_token
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Helpers
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def _make_cfg(*, notes_root="/tmp/fake-notes", repos=None):
36
+ if repos is None:
37
+ repos = {"myrepo": {"github": "org/myrepo", "local": None}}
38
+ return {"notes_root": notes_root, "repos": repos}
39
+
40
+
41
+ def _drive(args, *, meta=None, body="", repo=None, slug="my-track", vis="PRIVATE"):
42
+ """Run init.run(args) with all external I/O mocked.
43
+
44
+ meta: what parse_file returns as existing frontmatter (None → {} = no fm).
45
+ body: what parse_file returns as the body text.
46
+ repo: the resolved github repo for the file's folder (None → unknown/TBD).
47
+ slug: the slug derived from path stem.
48
+ vis: what repo_visibility returns.
49
+ """
50
+ existing_meta = meta if meta is not None else {}
51
+ fake_path = Path("/tmp/fake-notes/myrepo/my-track.md")
52
+ cfg = _make_cfg()
53
+
54
+ # We patch Path.exists to return True so the "file not found" check passes,
55
+ # Path.stat, and parse_file / write_file to avoid real I/O.
56
+ with patch("commands.init.load_config", return_value=cfg), \
57
+ patch("commands.init.parse_file", return_value=(existing_meta, body)), \
58
+ patch("commands.init.write_file") as mw, \
59
+ patch("commands.init.resolve_github_for_folder", return_value=repo), \
60
+ patch("lib.write_guard.repo_visibility", return_value=vis), \
61
+ patch("pathlib.Path.exists", return_value=True), \
62
+ patch("pathlib.Path.relative_to", return_value=Path("myrepo/my-track.md")):
63
+ # Build args: prepend fake path as first positional
64
+ full_args = [str(fake_path)] + list(args)
65
+ buf = io.StringIO()
66
+ with redirect_stdout(buf):
67
+ rc = init.run(full_args)
68
+ return rc, mw, buf.getvalue()
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Test cases
73
+ # ---------------------------------------------------------------------------
74
+
75
+ class InitNonInteractiveTest(unittest.TestCase):
76
+
77
+ # ------------------------------------------------------------------
78
+ # Flag-driven priority and milestone
79
+ # ------------------------------------------------------------------
80
+
81
+ def test_writes_with_explicit_priority_and_milestone(self):
82
+ """--priority=P1 --milestone=v2 → meta has launch_priority=P1,
83
+ milestone_alignment=v2; write_file called; rc 0."""
84
+ rc, mw, out = _drive(["--priority=P1", "--milestone=v2"], repo="org/myrepo")
85
+ self.assertEqual(rc, 0)
86
+ mw.assert_called_once()
87
+ written_meta = mw.call_args[0][1]
88
+ self.assertEqual(written_meta["launch_priority"], "P1")
89
+ self.assertEqual(written_meta["milestone_alignment"], "v2")
90
+
91
+ def test_defaults_p2_and_v100_when_flags_absent(self):
92
+ """No flags → launch_priority defaults to P2, milestone to v1.0.0."""
93
+ rc, mw, out = _drive([], repo="org/myrepo", vis="PRIVATE")
94
+ self.assertEqual(rc, 0)
95
+ mw.assert_called_once()
96
+ written_meta = mw.call_args[0][1]
97
+ self.assertEqual(written_meta["launch_priority"], "P2")
98
+ self.assertEqual(written_meta["milestone_alignment"], "v1.0.0")
99
+
100
+ def test_invalid_priority_falls_back_to_p2(self):
101
+ """--priority=P9 (invalid) → launch_priority silently falls back to P2."""
102
+ rc, mw, out = _drive(["--priority=P9"], repo="org/myrepo", vis="PRIVATE")
103
+ self.assertEqual(rc, 0)
104
+ mw.assert_called_once()
105
+ written_meta = mw.call_args[0][1]
106
+ self.assertEqual(written_meta["launch_priority"], "P2")
107
+
108
+ def test_priority_uppercased(self):
109
+ """--priority=p1 (lowercase) → P1 after uppercasing."""
110
+ rc, mw, out = _drive(["--priority=p1"], repo="org/myrepo", vis="PRIVATE")
111
+ self.assertEqual(rc, 0)
112
+ mw.assert_called_once()
113
+ written_meta = mw.call_args[0][1]
114
+ self.assertEqual(written_meta["launch_priority"], "P1")
115
+
116
+ # ------------------------------------------------------------------
117
+ # Issue ref scanning
118
+ # ------------------------------------------------------------------
119
+
120
+ def test_body_issue_refs_become_github_issues(self):
121
+ """Body '#41 #88' → github.issues == [41, 88] (sorted)."""
122
+ body = "See #88 and also #41 for details.\n"
123
+ rc, mw, out = _drive([], body=body, repo="org/myrepo", vis="PRIVATE")
124
+ self.assertEqual(rc, 0)
125
+ mw.assert_called_once()
126
+ written_meta = mw.call_args[0][1]
127
+ self.assertEqual(written_meta["github"]["issues"], [41, 88])
128
+
129
+ def test_no_issue_refs_gives_empty_list(self):
130
+ """Body with no #N refs → github.issues == []."""
131
+ rc, mw, out = _drive([], body="No refs here.\n", repo="org/myrepo", vis="PRIVATE")
132
+ self.assertEqual(rc, 0)
133
+ written_meta = mw.call_args[0][1]
134
+ self.assertEqual(written_meta["github"]["issues"], [])
135
+
136
+ # ------------------------------------------------------------------
137
+ # Already has frontmatter → no-op
138
+ # ------------------------------------------------------------------
139
+
140
+ def test_already_has_frontmatter_no_write_rc0(self):
141
+ """File already has frontmatter → no write, rc 0."""
142
+ existing_meta = {"track": "my-track", "status": "active"}
143
+ rc, mw, out = _drive([], meta=existing_meta, repo="org/myrepo", vis="PRIVATE")
144
+ self.assertEqual(rc, 0)
145
+ mw.assert_not_called()
146
+ self.assertIn("already has frontmatter", out)
147
+
148
+ # ------------------------------------------------------------------
149
+ # Repo resolution in written meta
150
+ # ------------------------------------------------------------------
151
+
152
+ def test_tbd_repo_when_folder_unknown(self):
153
+ """No repo resolved for folder → meta.github.repo == 'TBD'."""
154
+ rc, mw, out = _drive([], repo=None, vis="PRIVATE")
155
+ self.assertEqual(rc, 0)
156
+ mw.assert_called_once()
157
+ written_meta = mw.call_args[0][1]
158
+ self.assertEqual(written_meta["github"]["repo"], "TBD")
159
+
160
+ def test_resolved_repo_appears_in_meta(self):
161
+ """Resolved repo → meta.github.repo == 'org/myrepo'."""
162
+ rc, mw, out = _drive([], repo="org/myrepo", vis="PRIVATE")
163
+ self.assertEqual(rc, 0)
164
+ written_meta = mw.call_args[0][1]
165
+ self.assertEqual(written_meta["github"]["repo"], "org/myrepo")
166
+
167
+ # ------------------------------------------------------------------
168
+ # Confirm-token gate — public repo
169
+ # ------------------------------------------------------------------
170
+
171
+ def test_public_repo_no_token_returns_needs_confirm_json(self):
172
+ """Public repo, no token → prints needs_confirm JSON, no write, rc 0;
173
+ token equals make_token(repo, slug)."""
174
+ rc, mw, out = _drive([], repo="org/myrepo", vis="PUBLIC")
175
+ self.assertEqual(rc, 0)
176
+ mw.assert_not_called()
177
+ data = json.loads(out.strip())
178
+ self.assertTrue(data["needs_confirm"])
179
+ # Slug is derived from 'my-track.md' stem → 'my-track'
180
+ self.assertEqual(data["token"], make_token("org/myrepo", "my-track"))
181
+
182
+ def test_unknown_visibility_returns_needs_confirm_json(self):
183
+ """Unknown visibility (None) → also requires confirm."""
184
+ rc, mw, out = _drive([], repo="org/myrepo", vis=None)
185
+ self.assertEqual(rc, 0)
186
+ mw.assert_not_called()
187
+ data = json.loads(out.strip())
188
+ self.assertTrue(data["needs_confirm"])
189
+
190
+ def test_public_repo_wrong_token_blocks_write(self):
191
+ """Wrong confirm token → blocked, no write, rc 0."""
192
+ rc, mw, out = _drive(["--confirm=wrongtoken"], repo="org/myrepo", vis="PUBLIC")
193
+ self.assertEqual(rc, 0)
194
+ mw.assert_not_called()
195
+ data = json.loads(out.strip())
196
+ self.assertTrue(data["needs_confirm"])
197
+
198
+ def test_public_repo_valid_token_writes(self):
199
+ """Valid --confirm=<token> on a public repo → writes, rc 0."""
200
+ tok = make_token("org/myrepo", "my-track")
201
+ rc, mw, out = _drive([f"--confirm={tok}"], repo="org/myrepo", vis="PUBLIC")
202
+ self.assertEqual(rc, 0)
203
+ mw.assert_called_once()
204
+
205
+ # ------------------------------------------------------------------
206
+ # TBD/unknown repo → NO confirm gate (local-only case)
207
+ # ------------------------------------------------------------------
208
+
209
+ def test_tbd_repo_skips_confirm_gate_and_writes(self):
210
+ """repo is None (→ TBD) → confirm gate is skipped entirely, writes normally."""
211
+ # Even with vis=PUBLIC, if repo is None there's no gate
212
+ rc, mw, out = _drive([], repo=None, vis="PUBLIC")
213
+ self.assertEqual(rc, 0)
214
+ mw.assert_called_once()
215
+ written_meta = mw.call_args[0][1]
216
+ self.assertEqual(written_meta["github"]["repo"], "TBD")
217
+
218
+ # ------------------------------------------------------------------
219
+ # File not found → rc 1
220
+ # ------------------------------------------------------------------
221
+
222
+ def test_file_not_found_returns_rc1(self):
223
+ """Missing file → rc 1."""
224
+ cfg = _make_cfg()
225
+ with patch("commands.init.load_config", return_value=cfg), \
226
+ patch("pathlib.Path.exists", return_value=False):
227
+ buf = io.StringIO()
228
+ with redirect_stdout(buf):
229
+ rc = init.run(["/tmp/no-such-file.md"])
230
+ self.assertEqual(rc, 1)
231
+ self.assertIn("ERROR", buf.getvalue())
232
+
233
+ # ------------------------------------------------------------------
234
+ # No input()/prompt_input on any path
235
+ # ------------------------------------------------------------------
236
+
237
+ def test_no_input_called_on_flagged_path(self):
238
+ """Flagged path never calls input() or prompt_input, even with all flags."""
239
+ fake_path = Path("/tmp/fake-notes/myrepo/my-track.md")
240
+ cfg = _make_cfg()
241
+ tok = make_token("org/myrepo", "my-track")
242
+
243
+ def _raise(*a, **kw):
244
+ raise AssertionError("input() must not be called on non-interactive path")
245
+
246
+ with patch("builtins.input", side_effect=_raise), \
247
+ patch("lib.prompts.prompt_input", side_effect=_raise):
248
+ with patch("commands.init.load_config", return_value=cfg), \
249
+ patch("commands.init.parse_file", return_value=({}, "")), \
250
+ patch("commands.init.write_file"), \
251
+ patch("commands.init.resolve_github_for_folder", return_value="org/myrepo"), \
252
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
253
+ patch("pathlib.Path.exists", return_value=True), \
254
+ patch("pathlib.Path.relative_to", return_value=Path("myrepo/my-track.md")):
255
+ buf = io.StringIO()
256
+ with redirect_stdout(buf):
257
+ rc = init.run([
258
+ str(fake_path),
259
+ "--priority=P1",
260
+ "--milestone=v2",
261
+ f"--confirm={tok}",
262
+ ])
263
+ self.assertEqual(rc, 0)
264
+
265
+ def test_no_input_called_when_no_flags(self):
266
+ """No flags on a private repo → still no input() call."""
267
+ fake_path = Path("/tmp/fake-notes/myrepo/my-track.md")
268
+ cfg = _make_cfg()
269
+
270
+ def _raise(*a, **kw):
271
+ raise AssertionError("input() must not be called — command must be non-interactive")
272
+
273
+ with patch("builtins.input", side_effect=_raise), \
274
+ patch("lib.prompts.prompt_input", side_effect=_raise):
275
+ with patch("commands.init.load_config", return_value=cfg), \
276
+ patch("commands.init.parse_file", return_value=({}, "")), \
277
+ patch("commands.init.write_file"), \
278
+ patch("commands.init.resolve_github_for_folder", return_value="org/myrepo"), \
279
+ patch("lib.write_guard.repo_visibility", return_value="PRIVATE"), \
280
+ patch("pathlib.Path.exists", return_value=True), \
281
+ patch("pathlib.Path.relative_to", return_value=Path("myrepo/my-track.md")):
282
+ buf = io.StringIO()
283
+ with redirect_stdout(buf):
284
+ rc = init.run([str(fake_path)])
285
+ self.assertEqual(rc, 0)
286
+
287
+
288
+ if __name__ == "__main__":
289
+ unittest.main()
@@ -0,0 +1,379 @@
1
+ """Tests for the non-interactive init-repo command (issue #87, Phase 3a).
2
+
3
+ Covers:
4
+ - --github=org/repo --local=/some/path → yq subprocess called with correct
5
+ expression + folders created (mocked); rc 0.
6
+ - --github only (no --local) → works, local omitted from the block; rc 0.
7
+ - Missing --github → rc 2, no yq call, no prompt.
8
+ - Invalid github (no slash) → rc 2.
9
+ - 'repo already exists' → rc 1.
10
+ - No input()/prompt_input reached (patch to raise).
11
+ """
12
+ import io
13
+ import sys
14
+ import unittest
15
+ from contextlib import redirect_stdout
16
+ from pathlib import Path
17
+ from types import SimpleNamespace
18
+ from unittest.mock import patch, MagicMock, call
19
+
20
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
21
+ sys.path.insert(0, str(SKILL_ROOT))
22
+
23
+ from commands import init_repo
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def _make_cfg(*, notes_root="/tmp/fake-notes", repos=None):
31
+ if repos is None:
32
+ repos = {}
33
+ return {"notes_root": notes_root, "repos": repos}
34
+
35
+
36
+ def _drive(args, *, existing_repos=None, notes_root_exists=True, vis="PRIVATE"):
37
+ """Run init_repo.run(args) with all external I/O mocked.
38
+
39
+ existing_repos: dict of repos already in config (default empty).
40
+ notes_root_exists: whether notes_root directory exists.
41
+ """
42
+ cfg = _make_cfg(repos=existing_repos or {})
43
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
44
+
45
+ # We need notes_root to 'exist' and notes_root / key dirs created
46
+ def _path_exists(self):
47
+ # notes_root itself exists; repo subdirs may not — that's fine for mkdir
48
+ return notes_root_exists
49
+
50
+ with patch("commands.init_repo.load_config", return_value=cfg), \
51
+ patch("commands.init_repo.subprocess.run", return_value=mock_proc) as msub, \
52
+ patch("pathlib.Path.exists", return_value=notes_root_exists), \
53
+ patch("pathlib.Path.mkdir"), \
54
+ patch("pathlib.Path.touch"):
55
+ buf = io.StringIO()
56
+ with redirect_stdout(buf):
57
+ rc = init_repo.run(args)
58
+ return rc, msub, buf.getvalue()
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Test cases
63
+ # ---------------------------------------------------------------------------
64
+
65
+ class InitRepoNonInteractiveTest(unittest.TestCase):
66
+
67
+ # ------------------------------------------------------------------
68
+ # Happy path: --github + --local
69
+ # ------------------------------------------------------------------
70
+
71
+ def test_github_and_local_writes_yq_and_creates_folders(self):
72
+ """--github=org/repo --local=/some/path → yq called with the right
73
+ expression and folder creation happens; rc 0."""
74
+ rc, msub, out = _drive(
75
+ ["mykey", "--github=org/myrepo", "--local=/some/path"],
76
+ )
77
+ self.assertEqual(rc, 0)
78
+ # yq subprocess should have been called
79
+ msub.assert_called_once()
80
+ yq_args = msub.call_args[0][0] # positional first arg is the argv list
81
+ self.assertEqual(yq_args[0], "yq")
82
+ self.assertEqual(yq_args[1], "-i")
83
+ # Expression should contain both github and local
84
+ expr = yq_args[2]
85
+ self.assertIn("org/myrepo", expr)
86
+ self.assertIn("/some/path", expr)
87
+ self.assertIn("mykey", expr)
88
+ self.assertIn("✓", out)
89
+
90
+ # ------------------------------------------------------------------
91
+ # Happy path: --github only (no --local)
92
+ # ------------------------------------------------------------------
93
+
94
+ def test_github_only_omits_local_from_block(self):
95
+ """--github=org/repo, no --local → local key absent from yq expression;
96
+ rc 0."""
97
+ rc, msub, out = _drive(
98
+ ["mykey", "--github=org/myrepo"],
99
+ )
100
+ self.assertEqual(rc, 0)
101
+ msub.assert_called_once()
102
+ expr = msub.call_args[0][0][2]
103
+ self.assertIn("org/myrepo", expr)
104
+ # local should NOT appear in the yq expression
105
+ self.assertNotIn("local", expr)
106
+
107
+ # ------------------------------------------------------------------
108
+ # Missing --github → rc 2, no yq, no prompt
109
+ # ------------------------------------------------------------------
110
+
111
+ def test_missing_github_returns_rc2(self):
112
+ """Missing --github → rc 2, yq NOT called."""
113
+ rc, msub, out = _drive(["mykey"])
114
+ self.assertEqual(rc, 2)
115
+ msub.assert_not_called()
116
+ self.assertIn("ERROR", out)
117
+
118
+ # ------------------------------------------------------------------
119
+ # Invalid github (no slash) → rc 2
120
+ # ------------------------------------------------------------------
121
+
122
+ def test_invalid_github_no_slash_returns_rc2(self):
123
+ """--github=noslash (no '/') → rc 2, yq NOT called."""
124
+ rc, msub, out = _drive(["mykey", "--github=noslash"])
125
+ self.assertEqual(rc, 2)
126
+ msub.assert_not_called()
127
+ self.assertIn("ERROR", out)
128
+
129
+ # ------------------------------------------------------------------
130
+ # Repo already exists → rc 1
131
+ # ------------------------------------------------------------------
132
+
133
+ def test_repo_already_exists_returns_rc1(self):
134
+ """Key already in config.repos → rc 1, yq NOT called."""
135
+ existing = {"mykey": {"github": "org/myrepo", "local": None}}
136
+ rc, msub, out = _drive(["mykey", "--github=org/myrepo"], existing_repos=existing)
137
+ self.assertEqual(rc, 1)
138
+ msub.assert_not_called()
139
+ self.assertIn("already exists", out)
140
+
141
+ # ------------------------------------------------------------------
142
+ # No key → rc 2
143
+ # ------------------------------------------------------------------
144
+
145
+ def test_no_key_returns_rc2(self):
146
+ """No positional key at all → rc 2."""
147
+ rc, msub, out = _drive(["--github=org/myrepo"])
148
+ self.assertEqual(rc, 2)
149
+ msub.assert_not_called()
150
+
151
+ # ------------------------------------------------------------------
152
+ # Invalid key format → rc 2
153
+ # ------------------------------------------------------------------
154
+
155
+ def test_invalid_key_format_returns_rc2(self):
156
+ """Key with uppercase letters → rc 2."""
157
+ rc, msub, out = _drive(["MyKey", "--github=org/myrepo"])
158
+ self.assertEqual(rc, 2)
159
+ msub.assert_not_called()
160
+
161
+ # ------------------------------------------------------------------
162
+ # notes_root does not exist → rc 1
163
+ # ------------------------------------------------------------------
164
+
165
+ def test_notes_root_missing_returns_rc1(self):
166
+ """notes_root dir does not exist → rc 1, yq NOT called."""
167
+ rc, msub, out = _drive(
168
+ ["mykey", "--github=org/myrepo"],
169
+ notes_root_exists=False,
170
+ )
171
+ self.assertEqual(rc, 1)
172
+ msub.assert_not_called()
173
+
174
+ # ------------------------------------------------------------------
175
+ # Local path warn when given path doesn't exist
176
+ # ------------------------------------------------------------------
177
+
178
+ def test_local_nonexistent_path_prints_warn(self):
179
+ """--local=/no/such/path (not on disk) → prints WARN but continues;
180
+ rc 0 and yq IS called."""
181
+ # We keep notes_root_exists=True but we need local path check to fail.
182
+ # Override Path.exists to: return True for notes_root, False for local path.
183
+ cfg = _make_cfg(repos={})
184
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
185
+
186
+ with patch("commands.init_repo.load_config", return_value=cfg), \
187
+ patch("commands.init_repo.subprocess.run", return_value=mock_proc) as msub, \
188
+ patch("pathlib.Path.mkdir"), \
189
+ patch("pathlib.Path.touch"):
190
+ # Patch exists to return True for notes_root, False for local path
191
+ call_count = {"n": 0}
192
+ original_exists = Path.exists
193
+
194
+ def _exists(self):
195
+ # notes_root resolves to /tmp/fake-notes; local to /no/such/path
196
+ s = str(self)
197
+ if "fake-notes" in s:
198
+ return True
199
+ return False
200
+
201
+ with patch("pathlib.Path.exists", _exists):
202
+ buf = io.StringIO()
203
+ with redirect_stdout(buf):
204
+ rc = init_repo.run(["mykey", "--github=org/myrepo", "--local=/no/such/path"])
205
+ self.assertEqual(rc, 0)
206
+ msub.assert_called_once()
207
+ self.assertIn("WARN", buf.getvalue())
208
+
209
+ # ------------------------------------------------------------------
210
+ # No input()/prompt_input on any path
211
+ # ------------------------------------------------------------------
212
+
213
+ def test_no_input_called_with_github_flag(self):
214
+ """--github provided → no input() or prompt_input call."""
215
+ cfg = _make_cfg(repos={})
216
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
217
+
218
+ def _raise(*a, **kw):
219
+ raise AssertionError("input() must not be called — command must be non-interactive")
220
+
221
+ with patch("builtins.input", side_effect=_raise), \
222
+ patch("lib.prompts.prompt_input", side_effect=_raise):
223
+ with patch("commands.init_repo.load_config", return_value=cfg), \
224
+ patch("commands.init_repo.subprocess.run", return_value=mock_proc), \
225
+ patch("pathlib.Path.exists", return_value=True), \
226
+ patch("pathlib.Path.mkdir"), \
227
+ patch("pathlib.Path.touch"):
228
+ buf = io.StringIO()
229
+ with redirect_stdout(buf):
230
+ rc = init_repo.run(["mykey", "--github=org/myrepo"])
231
+ self.assertEqual(rc, 0)
232
+
233
+ def test_no_input_called_when_github_missing(self):
234
+ """Missing --github → rc 2 without prompting."""
235
+ cfg = _make_cfg(repos={})
236
+
237
+ def _raise(*a, **kw):
238
+ raise AssertionError("input() must not be called — command must return rc 2 immediately")
239
+
240
+ with patch("builtins.input", side_effect=_raise), \
241
+ patch("lib.prompts.prompt_input", side_effect=_raise):
242
+ with patch("commands.init_repo.load_config", return_value=cfg), \
243
+ patch("pathlib.Path.exists", return_value=True):
244
+ buf = io.StringIO()
245
+ with redirect_stdout(buf):
246
+ rc = init_repo.run(["mykey"])
247
+ self.assertEqual(rc, 2)
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Phase D: detect-and-import tests
252
+ # ---------------------------------------------------------------------------
253
+
254
+ class InitRepoSharedTrackDetectionTest(unittest.TestCase):
255
+ """Tests for the .work-plan/ detection and fallback reporting added in Phase D."""
256
+
257
+ # ------------------------------------------------------------------
258
+ # Helper that lets us control .work-plan/ contents precisely
259
+ # ------------------------------------------------------------------
260
+
261
+ def _drive_with_local(self, args, *, local_is_git=True, work_plan_files=None):
262
+ """Run init_repo.run(args) with fine-grained control over the local clone.
263
+
264
+ local_is_git: whether the local path looks like a valid git repo (.git present).
265
+ work_plan_files: list of filenames inside .work-plan/ (default: no .work-plan/).
266
+ """
267
+ cfg = _make_cfg(repos={})
268
+ mock_proc = MagicMock(returncode=0, stdout="", stderr="")
269
+
270
+ def _is_dir(self):
271
+ s = str(self)
272
+ # .md files are never directories
273
+ if s.endswith(".md"):
274
+ return False
275
+ # dotfiles like .gitkeep are not directories
276
+ name = self.name
277
+ if name.startswith(".") and name != ".work-plan" and name != ".git":
278
+ return False
279
+ if name == ".git":
280
+ return True # .git is a dir
281
+ if name == ".work-plan":
282
+ return work_plan_files is not None
283
+ return True # everything else is a dir
284
+
285
+ def _exists(self):
286
+ s = str(self)
287
+ # notes_root always exists
288
+ if "fake-notes" in s:
289
+ return True
290
+ # Use Path.name so Windows backslash paths match too.
291
+ _name = Path(self).name
292
+ # .git dir exists when local_is_git=True
293
+ if _name == ".git":
294
+ return local_is_git
295
+ # local clone dir always exists
296
+ if _name == "clone":
297
+ return True
298
+ return False
299
+
300
+ # Build fake iterdir for .work-plan/
301
+ def _iterdir(self):
302
+ if work_plan_files is None:
303
+ return iter([])
304
+ root = self
305
+ fake_paths = []
306
+ for name in work_plan_files:
307
+ p = root / name
308
+ fake_paths.append(p)
309
+ return iter(fake_paths)
310
+
311
+ with patch("commands.init_repo.load_config", return_value=cfg), \
312
+ patch("commands.init_repo.subprocess.run", return_value=mock_proc), \
313
+ patch("pathlib.Path.exists", _exists), \
314
+ patch("pathlib.Path.is_dir", _is_dir), \
315
+ patch("pathlib.Path.iterdir", _iterdir), \
316
+ patch("pathlib.Path.mkdir"), \
317
+ patch("pathlib.Path.touch"):
318
+ buf = io.StringIO()
319
+ with redirect_stdout(buf):
320
+ rc = init_repo.run(args)
321
+ return rc, buf.getvalue()
322
+
323
+ # ------------------------------------------------------------------
324
+ # init-repo with existing .work-plan/ containing 3 tracks
325
+ # ------------------------------------------------------------------
326
+
327
+ def test_found_3_shared_tracks_reported(self):
328
+ """init-repo --local pointing to a git repo with 3 .md tracks in .work-plan/
329
+ → output includes 'Found 3 shared track(s)'."""
330
+ rc, out = self._drive_with_local(
331
+ ["mykey", "--github=org/myrepo", "--local=/tmp/clone"],
332
+ local_is_git=True,
333
+ work_plan_files=["alpha.md", "beta.md", "gamma.md"],
334
+ )
335
+ self.assertEqual(rc, 0)
336
+ self.assertIn("Found 3 shared track(s)", out)
337
+
338
+ def test_readme_excluded_from_shared_track_count(self):
339
+ """README.md is excluded from the track count; dotfiles are excluded too."""
340
+ rc, out = self._drive_with_local(
341
+ ["mykey", "--github=org/myrepo", "--local=/tmp/clone"],
342
+ local_is_git=True,
343
+ work_plan_files=["alpha.md", "README.md", ".gitkeep"],
344
+ )
345
+ self.assertEqual(rc, 0)
346
+ # Only alpha.md counts; README.md and .gitkeep are excluded
347
+ self.assertIn("Found 1 shared track(s)", out)
348
+
349
+ # ------------------------------------------------------------------
350
+ # init-repo with no --local → registration-only fallback
351
+ # ------------------------------------------------------------------
352
+
353
+ def test_no_local_prints_registration_only_fallback(self):
354
+ """init-repo without --local → 'No valid local clone' message."""
355
+ rc, out = self._drive_with_local(
356
+ ["mykey", "--github=org/myrepo"],
357
+ local_is_git=False,
358
+ work_plan_files=None,
359
+ )
360
+ self.assertEqual(rc, 0)
361
+ self.assertIn("No valid local clone", out)
362
+
363
+ # ------------------------------------------------------------------
364
+ # init-repo with --local pointing to non-git dir
365
+ # ------------------------------------------------------------------
366
+
367
+ def test_local_non_git_dir_prints_registration_only_fallback(self):
368
+ """--local pointing to a directory that is not a git repo → 'No valid local clone'."""
369
+ rc, out = self._drive_with_local(
370
+ ["mykey", "--github=org/myrepo", "--local=/tmp/clone"],
371
+ local_is_git=False,
372
+ work_plan_files=None,
373
+ )
374
+ self.assertEqual(rc, 0)
375
+ self.assertIn("No valid local clone", out)
376
+
377
+
378
+ if __name__ == "__main__":
379
+ unittest.main()