workstream-cli 0.0.1__py3-none-any.whl

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 (86) hide show
  1. workstream/ARCHITECTURE.md +89 -0
  2. workstream/__init__.py +8 -0
  3. workstream/cli.py +136 -0
  4. workstream/commands/__init__.py +0 -0
  5. workstream/commands/backfill.py +139 -0
  6. workstream/commands/block.py +93 -0
  7. workstream/commands/checkin.py +51 -0
  8. workstream/commands/cron.py +119 -0
  9. workstream/commands/focus_cmd.py +273 -0
  10. workstream/commands/idea.py +172 -0
  11. workstream/commands/index.py +89 -0
  12. workstream/commands/init.py +567 -0
  13. workstream/commands/inspect_cmd.py +354 -0
  14. workstream/commands/list_cmd.py +99 -0
  15. workstream/commands/nest.py +108 -0
  16. workstream/commands/new.py +95 -0
  17. workstream/commands/next_cmd.py +333 -0
  18. workstream/commands/report.py +190 -0
  19. workstream/commands/resume.py +145 -0
  20. workstream/commands/review.py +227 -0
  21. workstream/commands/serve.py +23 -0
  22. workstream/commands/setup.py +178 -0
  23. workstream/commands/show.py +123 -0
  24. workstream/commands/snooze.py +117 -0
  25. workstream/commands/stale.py +116 -0
  26. workstream/commands/sweep.py +1753 -0
  27. workstream/commands/tree.py +105 -0
  28. workstream/commands/update_status.py +117 -0
  29. workstream/config.py +322 -0
  30. workstream/extensions/__init__.py +0 -0
  31. workstream/extensions/workstream.ts +633 -0
  32. workstream/focus_artifact.py +157 -0
  33. workstream/git.py +194 -0
  34. workstream/harness.py +49 -0
  35. workstream/llm.py +78 -0
  36. workstream/markdown.py +501 -0
  37. workstream/models.py +274 -0
  38. workstream/plan_index.py +88 -0
  39. workstream/provisioning.py +196 -0
  40. workstream/repo_discovery.py +158 -0
  41. workstream/review_artifact.py +96 -0
  42. workstream/scripts/migrate_statuses.py +120 -0
  43. workstream/skills/__init__.py +0 -0
  44. workstream/skills/workstream_context/SKILL.md +75 -0
  45. workstream/skills/workstream_context/__init__.py +0 -0
  46. workstream/skills/workstream_focus/SKILL.md +141 -0
  47. workstream/skills/workstream_init/SKILL.md +86 -0
  48. workstream/skills/workstream_review/SKILL.md +224 -0
  49. workstream/skills/workstream_sweep/SKILL.md +178 -0
  50. workstream/sweep_state.py +93 -0
  51. workstream/templates/dashboard.html +382 -0
  52. workstream/templates/detail.html +360 -0
  53. workstream/templates/plan.html +210 -0
  54. workstream/test/__init__.py +0 -0
  55. workstream/test/conftest.py +221 -0
  56. workstream/test/fixtures/sample_sprint_note.md +10 -0
  57. workstream/test/fixtures/sample_workstream.md +41 -0
  58. workstream/test/test_backfill.py +180 -0
  59. workstream/test/test_batch_writeback.py +81 -0
  60. workstream/test/test_commands.py +938 -0
  61. workstream/test/test_config.py +54 -0
  62. workstream/test/test_focus_artifact.py +211 -0
  63. workstream/test/test_git.py +88 -0
  64. workstream/test/test_heuristics.py +136 -0
  65. workstream/test/test_hierarchy.py +231 -0
  66. workstream/test/test_init.py +452 -0
  67. workstream/test/test_inspect.py +143 -0
  68. workstream/test/test_llm.py +78 -0
  69. workstream/test/test_markdown.py +626 -0
  70. workstream/test/test_models.py +506 -0
  71. workstream/test/test_next.py +206 -0
  72. workstream/test/test_plan_index.py +83 -0
  73. workstream/test/test_provisioning.py +270 -0
  74. workstream/test/test_repo_discovery.py +181 -0
  75. workstream/test/test_resume.py +71 -0
  76. workstream/test/test_sweep.py +1196 -0
  77. workstream/test/test_sweep_state.py +86 -0
  78. workstream/test/test_thoughts.py +516 -0
  79. workstream/test/test_web.py +606 -0
  80. workstream/thoughts.py +505 -0
  81. workstream/web.py +444 -0
  82. workstream_cli-0.0.1.dist-info/LICENSE +21 -0
  83. workstream_cli-0.0.1.dist-info/METADATA +93 -0
  84. workstream_cli-0.0.1.dist-info/RECORD +86 -0
  85. workstream_cli-0.0.1.dist-info/WHEEL +4 -0
  86. workstream_cli-0.0.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,221 @@
1
+ """Shared pytest fixtures for workstream tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from textwrap import dedent
7
+
8
+ import pytest
9
+
10
+ from workstream.config import Config
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Raw markdown text fixture
15
+ # ---------------------------------------------------------------------------
16
+
17
+ SAMPLE_WORKSTREAM_TEXT = dedent("""\
18
+ ---
19
+ id: 2026-03-15-a1b2c3d4e5
20
+ title: API Redesign
21
+ status: active
22
+ size: week
23
+ tags:
24
+ - code
25
+ - backend
26
+ repos:
27
+ - myapp
28
+ created: '2026-03-15'
29
+ updated: '2026-03-25'
30
+ ---
31
+
32
+ # API Redesign
33
+ #code #backend
34
+
35
+ ## Thread
36
+ ### 2026-03-25
37
+ Refactored authentication middleware to use the new token format.
38
+ Endpoint tests all passing now.
39
+
40
+ ### 2026-03-20
41
+ Sketched out the new resource hierarchy. Going with /v2/ prefix
42
+ to avoid breaking existing clients.
43
+
44
+ ## Decisions
45
+ - Use /v2/ prefix for all new endpoints
46
+ - Adopt JSON:API response envelope
47
+
48
+ ## Plans
49
+ <!-- Updated by ws sweep -->
50
+ - myapp `.plans/api-v2-migration.md` (active)
51
+
52
+ ## Branches
53
+ <!-- Updated by ws sweep -->
54
+ - myapp: `feature/api-v2` (+12 ahead)
55
+
56
+ ## Next
57
+ - Write migration guide for consumers
58
+ - Add rate-limiting to /v2/ endpoints
59
+ """)
60
+
61
+
62
+ @pytest.fixture()
63
+ def sample_workstream_text() -> str:
64
+ """Complete workstream markdown with frontmatter and all sections."""
65
+ return SAMPLE_WORKSTREAM_TEXT
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Directory of workstream files
70
+ # ---------------------------------------------------------------------------
71
+
72
+ _WORKSTREAM_SPECS: list[dict] = [
73
+ {
74
+ "filename": "2026-03-15-a1b2c3d4e5-api-redesign.md",
75
+ "id": "2026-03-15-a1b2c3d4e5",
76
+ "title": "API Redesign",
77
+ "status": "active",
78
+ "size": "week",
79
+ "tags": ["code"],
80
+ "created": "2026-03-15",
81
+ "updated": "2026-03-25",
82
+ "last_activity": "2026-03-25",
83
+ "thread_date": "2026-03-25",
84
+ "thread_body": "Refactored auth middleware. Tests passing.",
85
+ "next": ["Write migration guide", "Add rate-limiting"],
86
+ },
87
+ {
88
+ "filename": "2026-03-10-b2c3d4e5f6-docs-overhaul.md",
89
+ "id": "2026-03-10-b2c3d4e5f6",
90
+ "title": "Docs Overhaul",
91
+ "status": "active",
92
+ "size": "day",
93
+ "tags": ["code", "docs"],
94
+ "created": "2026-03-10",
95
+ "updated": "2026-03-22",
96
+ "last_activity": "2026-03-22",
97
+ "thread_date": "2026-03-22",
98
+ "thread_body": "Finished API reference section. Need to add examples.",
99
+ "next": ["Add code examples to each endpoint"],
100
+ },
101
+ {
102
+ "filename": "2026-03-01-c3d4e5f6a7-conference-talk.md",
103
+ "id": "2026-03-01-c3d4e5f6a7",
104
+ "title": "Conference Talk",
105
+ "status": "snoozed",
106
+ "size": "day",
107
+ "tags": ["community"],
108
+ "snooze_reason": "Waiting on CFP results",
109
+ "created": "2026-03-01",
110
+ "updated": "2026-03-12",
111
+ "last_activity": "2026-03-12",
112
+ "thread_date": "2026-03-12",
113
+ "thread_body": "Submitted CFP abstract. Fingers crossed.",
114
+ "next": ["Prepare slide outline once accepted"],
115
+ },
116
+ {
117
+ "filename": "2026-03-20-d4e5f6a7b8-accessibility-audit.md",
118
+ "id": "2026-03-20-d4e5f6a7b8",
119
+ "title": "Accessibility Audit",
120
+ "status": "active",
121
+ "size": "hour",
122
+ "tags": ["code"],
123
+ "created": "2026-03-20",
124
+ "updated": "2026-03-20",
125
+ "last_activity": "2026-03-20",
126
+ "thread_date": "2026-03-20",
127
+ "thread_body": "Initial idea: run axe-core across all pages.",
128
+ "next": ["Pick an audit tool"],
129
+ },
130
+ {
131
+ "filename": "2026-03-05-e5f6a7b8c9-sponsorship-outreach.md",
132
+ "id": "2026-03-05-e5f6a7b8c9",
133
+ "title": "Sponsorship Outreach",
134
+ "status": "active",
135
+ "size": "week",
136
+ "tags": ["business"],
137
+ "created": "2026-03-05",
138
+ "updated": "2026-03-18",
139
+ "last_activity": "2026-03-18",
140
+ "thread_date": "2026-03-18",
141
+ "thread_body": "Sent first batch of emails. Three positive replies so far.",
142
+ "next": ["Follow up with remaining prospects", "Draft sponsorship tiers"],
143
+ },
144
+ ]
145
+
146
+
147
+ def _render_workstream_file(spec: dict) -> str:
148
+ """Build a minimal but valid workstream markdown file from a spec dict."""
149
+ lines = [
150
+ "---",
151
+ f"id: {spec['id']}",
152
+ f"title: {spec['title']}",
153
+ f"status: {spec['status']}",
154
+ f"size: {spec['size']}",
155
+ ]
156
+
157
+ tags = spec.get("tags", [])
158
+ if tags:
159
+ lines.append("tags:")
160
+ for t in tags:
161
+ lines.append(f" - {t}")
162
+
163
+ if spec.get("snooze_reason"):
164
+ lines.append(f"snooze_reason: '{spec['snooze_reason']}'")
165
+
166
+ lines.append(f"created: '{spec['created']}'")
167
+ lines.append(f"updated: '{spec['updated']}'")
168
+ if spec.get('last_activity'):
169
+ lines.append(f"last_activity: '{spec['last_activity']}'")
170
+ lines.append("---")
171
+ lines.append("")
172
+ lines.append(f"# {spec['title']}")
173
+ lines.append("")
174
+ lines.append("## Thread")
175
+ lines.append(f"### {spec['thread_date']}")
176
+ lines.append(spec["thread_body"])
177
+ lines.append("")
178
+ lines.append("## Next")
179
+ for action in spec.get("next", []):
180
+ lines.append(f"- {action}")
181
+ lines.append("")
182
+
183
+ return "\n".join(lines)
184
+
185
+
186
+ @pytest.fixture()
187
+ def workstreams_dir(tmp_path: Path) -> Path:
188
+ """Temp directory populated with 5 sample workstream files."""
189
+ ws_dir = tmp_path / "workstreams"
190
+ ws_dir.mkdir()
191
+
192
+ for spec in _WORKSTREAM_SPECS:
193
+ (ws_dir / spec["filename"]).write_text(
194
+ _render_workstream_file(spec), encoding="utf-8"
195
+ )
196
+
197
+ return ws_dir
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Config fixture
202
+ # ---------------------------------------------------------------------------
203
+
204
+ @pytest.fixture()
205
+ def test_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Config:
206
+ """Config with workstreams_dir in a temp directory.
207
+
208
+ Patches global config path and WS_DIR env to prevent leaking to the real
209
+ filesystem. Most command tests also monkeypatch ``load_config`` directly.
210
+ """
211
+ ws_dir = tmp_path / "workstreams"
212
+ ws_dir.mkdir()
213
+
214
+ # Prevent global config reads/writes from hitting the real home dir
215
+ config_file = tmp_path / "global_config.yaml"
216
+ monkeypatch.setattr("workstream.config.CONFIG_PATH", config_file)
217
+ monkeypatch.setattr("workstream.config.CONFIG_DIR", tmp_path)
218
+ # Prevent env var and cwd walk-up from finding a real project
219
+ monkeypatch.delenv("WS_DIR", raising=False)
220
+
221
+ return Config(workstreams_dir=str(ws_dir))
@@ -0,0 +1,10 @@
1
+ ## 2026-03-26
2
+ - TODO
3
+ - Fix onboarding bug
4
+ - Review PR #42
5
+ - Thoughts
6
+ - Should we add confetti to the welcome screen?
7
+ - Time to rethink the API versioning strategy
8
+ - That agent knowledge sharing idea keeps coming up
9
+ - Done
10
+ - Deployed landing page
@@ -0,0 +1,41 @@
1
+ ---
2
+ id: 2026-03-15-a1b2c3d4e5
3
+ title: API Redesign
4
+ status: active
5
+ size: week
6
+ tags:
7
+ - code
8
+ - backend
9
+ repos:
10
+ - myapp
11
+ created: '2026-03-15'
12
+ updated: '2026-03-25'
13
+ ---
14
+
15
+ # API Redesign
16
+ #code #backend
17
+
18
+ ## Thread
19
+ ### 2026-03-25
20
+ Refactored authentication middleware to use the new token format.
21
+ Endpoint tests all passing now.
22
+
23
+ ### 2026-03-20
24
+ Sketched out the new resource hierarchy. Going with /v2/ prefix
25
+ to avoid breaking existing clients.
26
+
27
+ ## Decisions
28
+ - Use /v2/ prefix for all new endpoints
29
+ - Adopt JSON:API response envelope
30
+
31
+ ## Plans
32
+ <!-- Updated by ws sweep -->
33
+ - myapp `.plans/api-v2-migration.md` (active)
34
+
35
+ ## Branches
36
+ <!-- Updated by ws sweep -->
37
+ - myapp: `feature/api-v2` (+12 ahead)
38
+
39
+ ## Next
40
+ - Write migration guide for consumers
41
+ - Add rate-limiting to /v2/ endpoints
@@ -0,0 +1,180 @@
1
+ """Tests for workstream.commands.backfill — plan ID assignment and workstream inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from textwrap import dedent
7
+
8
+ import pytest
9
+
10
+ from workstream.commands.backfill import _slug_overlap, _infer_workstream
11
+ from workstream.config import Config, RepoConfig
12
+ from workstream.markdown import parse_frontmatter
13
+ from workstream.models import Workstream
14
+
15
+
16
+ # ── Slug overlap ─────────────────────────────────────────────────────
17
+
18
+ def test_slug_overlap_match():
19
+ """Overlapping tokens return True."""
20
+ assert _slug_overlap('api-redesign-migration', 'api-redesign')
21
+
22
+
23
+ def test_slug_overlap_no_match():
24
+ """No overlapping tokens return False."""
25
+ assert not _slug_overlap('perf-improvements', 'api-redesign')
26
+
27
+
28
+ def test_slug_overlap_short_tokens_ignored():
29
+ """Single-char tokens are ignored."""
30
+ assert not _slug_overlap('a-b-c', 'a-b-d')
31
+
32
+
33
+ # ── Workstream inference ─────────────────────────────────────────────
34
+
35
+ def test_infer_single_match():
36
+ """Exactly one matching workstream returns its ID."""
37
+ ws = Workstream(id='ws-123', title='API Redesign', status='active', repos=['myapp'])
38
+ assert _infer_workstream('myapp', 'api-redesign-migration', [ws]) == 'ws-123'
39
+
40
+
41
+ def test_infer_no_repo_match():
42
+ """No matching repo returns None."""
43
+ ws = Workstream(id='ws-123', title='API Redesign', status='active', repos=['otherapp'])
44
+ assert _infer_workstream('myapp', 'api-redesign', [ws]) is None
45
+
46
+
47
+ def test_infer_no_slug_overlap():
48
+ """Matching repo but no slug overlap returns None."""
49
+ ws = Workstream(id='ws-123', title='Fundraising', status='active', repos=['myapp'])
50
+ assert _infer_workstream('myapp', 'api-redesign', [ws]) is None
51
+
52
+
53
+ def test_infer_multiple_matches_returns_none():
54
+ """Multiple matching workstreams returns None (ambiguous)."""
55
+ ws1 = Workstream(id='ws-1', title='API Redesign', status='active', repos=['myapp'])
56
+ ws2 = Workstream(id='ws-2', title='API Migration', status='active', repos=['myapp'])
57
+ assert _infer_workstream('myapp', 'api-migration-redesign', [ws1, ws2]) is None
58
+
59
+
60
+ # ── Full backfill handler ────────────────────────────────────────────
61
+
62
+ def test_backfill_adds_id(tmp_path: Path):
63
+ """Backfill adds an ID to a plan file that lacks one."""
64
+ # Set up repo with a plan missing an ID
65
+ repo = tmp_path / 'myapp'
66
+ plans_dir = repo / '.plans'
67
+ plans_dir.mkdir(parents=True)
68
+
69
+ plan_text = dedent("""\
70
+ ---
71
+ title: My Plan
72
+ status: active
73
+ ---
74
+
75
+ Plan content here.
76
+ """)
77
+ (plans_dir / 'my-plan.md').write_text(plan_text, encoding='utf-8')
78
+
79
+ # Set up empty workstreams dir
80
+ ws_dir = tmp_path / 'workstreams'
81
+ ws_dir.mkdir()
82
+
83
+ config = Config(
84
+ workstreams_dir=str(ws_dir),
85
+ repos=[RepoConfig(path=str(repo), name='myapp')],
86
+ )
87
+
88
+
89
+ from workstream.commands.backfill import _backfill_handler
90
+ _backfill_handler(config=config, dry_run=False)
91
+
92
+ # The original file should be renamed
93
+ remaining = list(plans_dir.glob('*.md'))
94
+ assert len(remaining) == 1
95
+
96
+ new_file = remaining[0]
97
+ # Should have the pattern: <id>-my-plan.md
98
+ assert 'my-plan' in new_file.name
99
+ assert len(new_file.name) > len('my-plan.md') # has ID prefix
100
+
101
+ # Verify frontmatter has an ID
102
+ meta, _ = parse_frontmatter(new_file.read_text(encoding='utf-8'))
103
+ assert 'id' in meta
104
+ assert meta['id'] in new_file.name
105
+
106
+
107
+ def test_backfill_dry_run(tmp_path: Path, capsys):
108
+ """Dry run shows what would change without modifying files."""
109
+ repo = tmp_path / 'myapp'
110
+ plans_dir = repo / '.plans'
111
+ plans_dir.mkdir(parents=True)
112
+
113
+ plan_text = dedent("""\
114
+ ---
115
+ title: Another Plan
116
+ status: active
117
+ ---
118
+
119
+ Plan content.
120
+ """)
121
+ original_name = 'another-plan.md'
122
+ (plans_dir / original_name).write_text(plan_text, encoding='utf-8')
123
+
124
+ ws_dir = tmp_path / 'workstreams'
125
+ ws_dir.mkdir()
126
+
127
+ config = Config(
128
+ workstreams_dir=str(ws_dir),
129
+ repos=[RepoConfig(path=str(repo), name='myapp')],
130
+ )
131
+
132
+
133
+ from workstream.commands.backfill import _backfill_handler
134
+ _backfill_handler(config=config, dry_run=True)
135
+
136
+ # Original file should be unchanged
137
+ assert (plans_dir / original_name).exists()
138
+ meta, _ = parse_frontmatter((plans_dir / original_name).read_text(encoding='utf-8'))
139
+ assert 'id' not in meta # not modified
140
+
141
+ out = capsys.readouterr().out
142
+ assert 'would add id' in out
143
+ assert 'dry run' in out.lower()
144
+
145
+
146
+ def test_backfill_preserves_existing_id(tmp_path: Path):
147
+ """Plans with existing IDs are not re-IDed."""
148
+ repo = tmp_path / 'myapp'
149
+ plans_dir = repo / '.plans'
150
+ plans_dir.mkdir(parents=True)
151
+
152
+ plan_text = dedent("""\
153
+ ---
154
+ id: 2026-03-20-existing123
155
+ title: Existing Plan
156
+ status: active
157
+ ---
158
+
159
+ Already has an ID.
160
+ """)
161
+ (plans_dir / '2026-03-20-existing123-existing-plan.md').write_text(plan_text, encoding='utf-8')
162
+
163
+ ws_dir = tmp_path / 'workstreams'
164
+ ws_dir.mkdir()
165
+
166
+ config = Config(
167
+ workstreams_dir=str(ws_dir),
168
+ repos=[RepoConfig(path=str(repo), name='myapp')],
169
+ )
170
+
171
+
172
+ from workstream.commands.backfill import _backfill_handler
173
+ _backfill_handler(config=config, dry_run=False)
174
+
175
+ # File should still exist with same name
176
+ assert (plans_dir / '2026-03-20-existing123-existing-plan.md').exists()
177
+ meta, _ = parse_frontmatter(
178
+ (plans_dir / '2026-03-20-existing123-existing-plan.md').read_text(encoding='utf-8')
179
+ )
180
+ assert meta['id'] == '2026-03-20-existing123'
@@ -0,0 +1,81 @@
1
+ """Tests for batch review plan status writeback mechanics."""
2
+
3
+ from datetime import date
4
+
5
+ from workstream.markdown import parse_frontmatter, write_frontmatter
6
+
7
+
8
+ def test_plan_status_writeback(tmp_path):
9
+ """Simulate batch review updating a plan file's status."""
10
+ plan_dir = tmp_path / '.plans'
11
+ plan_dir.mkdir()
12
+ plan_file = plan_dir / 'myplan.md'
13
+ plan_file.write_text(
14
+ '---\ntitle: My Plan\nstatus: active\nworkstream: test\n---\n\nPlan content.',
15
+ encoding='utf-8',
16
+ )
17
+
18
+ # Simulate classification
19
+ cl = {'plan': 'myplan.md', 'status': 'implemented', 'reason': 'Commits match', 'ideas': []}
20
+ today = date.today().isoformat()
21
+
22
+ # Apply the same logic as _batch_review_plans_handler
23
+ plan_text = plan_file.read_text(encoding='utf-8')
24
+ plan_meta, plan_body = parse_frontmatter(plan_text)
25
+ assert plan_meta.get('status') == 'active'
26
+
27
+ if plan_meta and plan_meta.get('status') != cl['status']:
28
+ plan_meta['status'] = cl['status']
29
+ plan_meta['reviewed'] = today
30
+ plan_file.write_text(
31
+ write_frontmatter(plan_meta, plan_body),
32
+ encoding='utf-8',
33
+ )
34
+
35
+ # Verify
36
+ updated_text = plan_file.read_text(encoding='utf-8')
37
+ updated_meta, _ = parse_frontmatter(updated_text)
38
+ assert updated_meta['status'] == 'implemented'
39
+ assert updated_meta['reviewed'] == today
40
+
41
+
42
+ def test_plan_status_writeback_no_change(tmp_path):
43
+ """Don't write if status already matches."""
44
+ plan_dir = tmp_path / '.plans'
45
+ plan_dir.mkdir()
46
+ plan_file = plan_dir / 'myplan.md'
47
+ original = '---\ntitle: My Plan\nstatus: active\n---\n\nContent.'
48
+ plan_file.write_text(original, encoding='utf-8')
49
+
50
+ cl = {'plan': 'myplan.md', 'status': 'active', 'reason': 'Still active', 'ideas': []}
51
+
52
+ plan_text = plan_file.read_text(encoding='utf-8')
53
+ plan_meta, plan_body = parse_frontmatter(plan_text)
54
+
55
+ # Same status — should not rewrite
56
+ if plan_meta and plan_meta.get('status') != cl['status']:
57
+ plan_meta['status'] = cl['status']
58
+ plan_file.write_text(write_frontmatter(plan_meta, plan_body), encoding='utf-8')
59
+
60
+ # File should be unchanged
61
+ assert plan_file.read_text(encoding='utf-8') == original
62
+
63
+
64
+ def test_plan_status_writeback_no_frontmatter(tmp_path):
65
+ """Plans without frontmatter should be skipped."""
66
+ plan_dir = tmp_path / '.plans'
67
+ plan_dir.mkdir()
68
+ plan_file = plan_dir / 'legacy.md'
69
+ original = '<!-- STATUS: FINALIZED -->\n\nLegacy plan.'
70
+ plan_file.write_text(original, encoding='utf-8')
71
+
72
+ plan_text = plan_file.read_text(encoding='utf-8')
73
+ plan_meta, plan_body = parse_frontmatter(plan_text)
74
+
75
+ # No frontmatter — should skip
76
+ if plan_meta and plan_meta.get('status') != 'implemented':
77
+ plan_meta['status'] = 'implemented'
78
+ plan_file.write_text(write_frontmatter(plan_meta, plan_body), encoding='utf-8')
79
+
80
+ # File unchanged
81
+ assert plan_file.read_text(encoding='utf-8') == original