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,626 @@
1
+ """Tests for workstream.markdown — frontmatter, sections, load/save round-trip."""
2
+
3
+ from pathlib import Path
4
+
5
+ from workstream.markdown import (
6
+ append_idea,
7
+ append_log_entry,
8
+ append_thought,
9
+ append_thread_entry,
10
+ get_section,
11
+ load_workstream,
12
+ parse_branches,
13
+ parse_decisions,
14
+ parse_frontmatter,
15
+ parse_ideas,
16
+ parse_log,
17
+ parse_next_actions,
18
+ parse_plans,
19
+ parse_thread,
20
+ parse_thoughts,
21
+ render_body,
22
+ save_workstream,
23
+ set_section,
24
+ write_frontmatter,
25
+ )
26
+ from workstream.models import (
27
+ BranchRef,
28
+ IdeaEntry,
29
+ LogEntry,
30
+ PlanRef,
31
+ ThoughtEntry,
32
+ ThreadEntry,
33
+ Workstream,
34
+ )
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Sample data
38
+ # ---------------------------------------------------------------------------
39
+
40
+ _SAMPLE_FRONTMATTER = """\
41
+ ---
42
+ id: '2026-03-28-abc1234567'
43
+ title: Test Stream
44
+ status: active
45
+ size: day
46
+ tags:
47
+ - infra
48
+ - backend
49
+ repos:
50
+ - my-repo
51
+ created: '2026-01-15'
52
+ updated: '2026-03-28'
53
+ ---
54
+
55
+ """
56
+
57
+ _SAMPLE_BODY = """\
58
+ # Test Stream
59
+
60
+ #infra #backend
61
+
62
+ ## Thread
63
+ ### 2026-03-28
64
+ Did some work today.
65
+
66
+ Multi-line body here.
67
+
68
+ ### 2026-03-27
69
+ Started the project.
70
+
71
+ ## Decisions
72
+ - Use Python 3.11+
73
+ - Adopt face for CLI
74
+
75
+ ## Plans
76
+ - my-repo `.plans/migrate.md` (active)
77
+
78
+ ## Branches
79
+ - my-repo: `feature/ws-cli` (+3 ahead)
80
+
81
+ ## Next
82
+ - Write tests
83
+ - Ship v1
84
+ """
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Frontmatter
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ def test_parse_frontmatter_basic():
93
+ meta, body = parse_frontmatter(_SAMPLE_FRONTMATTER)
94
+ assert meta["id"] == "2026-03-28-abc1234567"
95
+ assert meta["title"] == "Test Stream"
96
+ assert meta["status"] == "active"
97
+ assert body.strip() == ""
98
+
99
+
100
+ def test_parse_frontmatter_none():
101
+ meta, body = parse_frontmatter("No frontmatter here.\nJust text.")
102
+ assert meta == {}
103
+ assert "No frontmatter here." in body
104
+
105
+
106
+ def test_parse_frontmatter_no_close():
107
+ text = "---\nid: oops\nNo closing delimiter"
108
+ meta, body = parse_frontmatter(text)
109
+ assert meta == {}
110
+ assert body == text
111
+
112
+
113
+ def test_write_frontmatter_roundtrip():
114
+ meta, _ = parse_frontmatter(_SAMPLE_FRONTMATTER)
115
+ rebuilt = write_frontmatter(meta, "")
116
+ meta2, _ = parse_frontmatter(rebuilt)
117
+ for key in ("id", "title", "status", "size"):
118
+ assert str(meta[key]) == str(meta2[key])
119
+ assert meta2["tags"] == meta["tags"]
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Thread
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def test_parse_thread_entries():
128
+ entries = parse_thread(_SAMPLE_BODY)
129
+ assert len(entries) == 2
130
+ assert entries[0].date == "2026-03-28"
131
+ assert "Did some work today." in entries[0].body
132
+ # Multi-line body preserved
133
+ assert "Multi-line body here." in entries[0].body
134
+ assert entries[1].date == "2026-03-27"
135
+
136
+
137
+ def test_parse_thread_empty():
138
+ body = "## Decisions\n- Something\n"
139
+ assert parse_thread(body) == []
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Bullets
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ def test_parse_decisions():
148
+ items = parse_decisions(_SAMPLE_BODY)
149
+ assert items == ["Use Python 3.11+", "Adopt face for CLI"]
150
+
151
+
152
+ def test_parse_plans():
153
+ plans = parse_plans(_SAMPLE_BODY)
154
+ assert len(plans) == 1
155
+ assert plans[0] == PlanRef(repo="my-repo", path="migrate.md", status="active", title='', date='')
156
+
157
+
158
+ def test_parse_branches():
159
+ branches = parse_branches(_SAMPLE_BODY)
160
+ assert len(branches) == 1
161
+ assert branches[0] == BranchRef(repo="my-repo", branch="feature/ws-cli", ahead=3)
162
+
163
+
164
+ def test_parse_next_actions():
165
+ items = parse_next_actions(_SAMPLE_BODY)
166
+ assert items == ["Write tests", "Ship v1"]
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Sections
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def test_get_section_present():
175
+ section = get_section(_SAMPLE_BODY, "Decisions")
176
+ assert section is not None
177
+ assert "Use Python 3.11+" in section
178
+
179
+
180
+ def test_get_section_absent():
181
+ assert get_section(_SAMPLE_BODY, "Nonexistent") is None
182
+
183
+
184
+ def test_set_section_replace():
185
+ body = "## Alpha\nold content\n\n## Beta\nkeep\n"
186
+ updated = set_section(body, "Alpha", "new content")
187
+ assert "new content" in updated
188
+ assert "old content" not in updated
189
+ # Beta untouched
190
+ assert "keep" in updated
191
+
192
+
193
+ def test_set_section_append():
194
+ body = "## Alpha\ncontent\n"
195
+ updated = set_section(body, "Gamma", "gamma stuff")
196
+ assert "## Gamma" in updated
197
+ assert "gamma stuff" in updated
198
+ # Original section preserved
199
+ assert "## Alpha" in updated
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Thread mutation
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ def test_append_thread_entry():
208
+ body = "## Thread\n### 2026-03-27\nOld entry.\n"
209
+ updated = append_thread_entry(body, "2026-03-28", "New entry.")
210
+ # New entry at top
211
+ thread_pos = updated.index("## Thread\n")
212
+ new_pos = updated.index("### 2026-03-28")
213
+ old_pos = updated.index("### 2026-03-27")
214
+ assert new_pos < old_pos
215
+ assert "New entry." in updated
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Full file round-trip
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ def test_load_workstream(tmp_path: Path):
224
+ content = _SAMPLE_FRONTMATTER + _SAMPLE_BODY
225
+ fp = tmp_path / "test.md"
226
+ fp.write_text(content, encoding="utf-8")
227
+
228
+ ws = load_workstream(fp)
229
+ assert ws.id == "2026-03-28-abc1234567"
230
+ assert ws.title == "Test Stream"
231
+ assert ws.status == "active"
232
+ # PyYAML may parse '2026-01-15' as datetime.date; load_workstream casts to str
233
+ assert ws.created == "2026-01-15"
234
+ assert ws.updated == "2026-03-28"
235
+ assert ws.tags == ["infra", "backend"]
236
+ assert ws.repos == ["my-repo"]
237
+ assert len(ws.thread) == 2
238
+ assert len(ws.decisions) == 2
239
+ assert len(ws.plans) == 1
240
+ assert ws.plans[0].repo == "my-repo"
241
+ assert len(ws.branches) == 1
242
+ assert len(ws.next_actions) == 2
243
+ assert ws.source_path == fp
244
+
245
+
246
+ def test_save_and_reload(tmp_path: Path):
247
+ ws = Workstream(
248
+ id="2026-03-28-0000000000",
249
+ title="Round Trip",
250
+ status="active",
251
+ size="week",
252
+ tags=["cli"],
253
+ repos=["core"],
254
+ created="2026-01-01",
255
+ updated="2026-03-28",
256
+ thread=[ThreadEntry(date="2026-03-28", body="Entry body.")],
257
+ decisions=["Decision A"],
258
+ plans=[PlanRef(repo="core", path="plan.md", status="draft", title='', date='')],
259
+ branches=[BranchRef(repo="core", branch="feat/x", ahead=5)],
260
+ next_actions=["Do something"],
261
+ )
262
+ fp = tmp_path / ws.filename
263
+ save_workstream(ws, fp)
264
+
265
+ loaded = load_workstream(fp)
266
+ assert loaded.id == ws.id
267
+ assert loaded.title == ws.title
268
+ assert loaded.status == ws.status
269
+ assert loaded.size == ws.size
270
+ assert loaded.tags == ws.tags
271
+ assert loaded.repos == ws.repos
272
+ assert loaded.created == ws.created
273
+ assert loaded.updated == ws.updated
274
+ assert len(loaded.thread) == 1
275
+ assert loaded.thread[0].date == "2026-03-28"
276
+ assert "Entry body." in loaded.thread[0].body
277
+ assert loaded.decisions == ["Decision A"]
278
+ assert len(loaded.plans) == 1
279
+ assert loaded.plans[0].repo == "core"
280
+ assert len(loaded.branches) == 1
281
+ assert loaded.branches[0].ahead == 5
282
+ assert loaded.next_actions == ["Do something"]
283
+
284
+
285
+ def test_render_body():
286
+ ws = Workstream(
287
+ id="x",
288
+ title="Render Test",
289
+ tags=["t1"],
290
+ thread=[ThreadEntry(date="2026-03-28", body="Note.")],
291
+ next_actions=["Act"],
292
+ )
293
+ body = render_body(ws)
294
+ assert body.startswith("# Render Test\n")
295
+ assert "#t1" in body
296
+ assert "## Thread" in body
297
+ assert "### 2026-03-28" in body
298
+ assert "Note." in body
299
+ assert "## Next" in body
300
+ assert "- Act" in body
301
+ # Empty optional sections omitted
302
+ assert "## Decisions" not in body
303
+ assert "## Plans" not in body
304
+ assert "## Branches" not in body
305
+
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Thoughts section
310
+ # ---------------------------------------------------------------------------
311
+
312
+
313
+ def test_parse_thoughts_basic():
314
+ body = '## Thoughts\n- [2026-03-28] Should add confetti\n- [2026-03-27] Rethink API versioning\n'
315
+ thoughts = parse_thoughts(body)
316
+ assert len(thoughts) == 2
317
+ assert thoughts[0].date == '2026-03-28'
318
+ assert thoughts[0].text == 'Should add confetti'
319
+ assert thoughts[0].snooze_until == ''
320
+ assert thoughts[0].snooze_count == 0
321
+ assert thoughts[1].date == '2026-03-27'
322
+ assert thoughts[1].text == 'Rethink API versioning'
323
+
324
+
325
+ def test_parse_thoughts_with_snooze():
326
+ body = '## Thoughts\n- [2026-03-28] Some idea (snooze: 2026-04-04, count: 2)\n'
327
+ thoughts = parse_thoughts(body)
328
+ assert len(thoughts) == 1
329
+ assert thoughts[0].text == 'Some idea'
330
+ assert thoughts[0].snooze_until == '2026-04-04'
331
+ assert thoughts[0].snooze_count == 2
332
+
333
+
334
+ def test_parse_thoughts_empty():
335
+ body = '## Thread\n### 2026-03-28\nSome work.\n'
336
+ assert parse_thoughts(body) == []
337
+
338
+
339
+ def test_append_thought_creates_section():
340
+ body = '## Thread\nSome content\n'
341
+ t = ThoughtEntry(date='2026-03-28', text='New idea')
342
+ updated = append_thought(body, t)
343
+ assert '## Thoughts' in updated
344
+ assert '- [2026-03-28] New idea' in updated
345
+ # Original content preserved
346
+ assert '## Thread' in updated
347
+
348
+
349
+ def test_append_thought_to_existing():
350
+ body = '## Thoughts\n- [2026-03-27] Old idea\n'
351
+ t = ThoughtEntry(date='2026-03-28', text='New idea')
352
+ updated = append_thought(body, t)
353
+ assert '- [2026-03-27] Old idea' in updated
354
+ assert '- [2026-03-28] New idea' in updated
355
+
356
+
357
+ def test_render_body_with_thoughts():
358
+ ws = Workstream(
359
+ id='x',
360
+ title='Test',
361
+ thoughts=[ThoughtEntry(date='2026-03-28', text='Cool idea')],
362
+ next_actions=['Do something'],
363
+ )
364
+ body = render_body(ws)
365
+ assert '## Thoughts' in body
366
+ assert '- [2026-03-28] Cool idea' in body
367
+
368
+
369
+ def test_render_body_omits_empty_thoughts():
370
+ ws = Workstream(id='x', title='Test', next_actions=['Do something'])
371
+ body = render_body(ws)
372
+ assert '## Thoughts' not in body
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Hierarchy fields round-trip
377
+ # ---------------------------------------------------------------------------
378
+
379
+
380
+ def test_load_workstream_with_parent(tmp_path: Path):
381
+ content = '''---
382
+ id: '2026-03-28-abc1234567'
383
+ title: Child Stream
384
+ status: active
385
+ parent: '2026-01-01-parent123'
386
+ snooze_until: '2026-04-15'
387
+ snooze_count: 2
388
+ ---
389
+
390
+ # Child Stream
391
+
392
+ ## Thread
393
+ ### 2026-03-28
394
+ Some work.
395
+
396
+ ## Next
397
+ - Do something
398
+ '''
399
+ fp = tmp_path / 'test.md'
400
+ fp.write_text(content, encoding='utf-8')
401
+ ws = load_workstream(fp)
402
+ assert ws.parent == '2026-01-01-parent123'
403
+ assert ws.snooze_until == '2026-04-15'
404
+ assert ws.snooze_count == 2
405
+
406
+
407
+ def test_save_and_reload_with_parent_and_thoughts(tmp_path: Path):
408
+ ws = Workstream(
409
+ id='2026-03-28-0000000000',
410
+ title='Round Trip',
411
+ status='active',
412
+ parent='2026-01-01-parent123',
413
+ snooze_until='2026-05-01',
414
+ snooze_count=1,
415
+ thread=[ThreadEntry(date='2026-03-28', body='Entry body.')],
416
+ thoughts=[
417
+ ThoughtEntry(date='2026-03-28', text='A thought'),
418
+ ThoughtEntry(date='2026-03-27', text='Snoozed thought', snooze_until='2026-04-10', snooze_count=3),
419
+ ],
420
+ next_actions=['Do something'],
421
+ )
422
+ fp = tmp_path / ws.filename
423
+ save_workstream(ws, fp)
424
+
425
+ loaded = load_workstream(fp)
426
+ assert loaded.parent == '2026-01-01-parent123'
427
+ assert loaded.snooze_until == '2026-05-01'
428
+ assert loaded.snooze_count == 1
429
+ assert len(loaded.thoughts) == 2
430
+ assert loaded.thoughts[0].date == '2026-03-28'
431
+ assert loaded.thoughts[0].text == 'A thought'
432
+ assert loaded.thoughts[0].snooze_until == ''
433
+ assert loaded.thoughts[1].text == 'Snoozed thought'
434
+ assert loaded.thoughts[1].snooze_until == '2026-04-10'
435
+ assert loaded.thoughts[1].snooze_count == 3
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # Ideas section
440
+ # ---------------------------------------------------------------------------
441
+
442
+
443
+ def test_parse_ideas_basic():
444
+ body = '## Ideas\n- [2026-03-28] Build a rate alert system\n- [2026-03-27] Add a leaderboard\n'
445
+ ideas = parse_ideas(body)
446
+ assert len(ideas) == 2
447
+ assert ideas[0].date == '2026-03-28'
448
+ assert ideas[0].text == 'Build a rate alert system'
449
+ assert ideas[0].snooze_until == ''
450
+
451
+
452
+ def test_parse_ideas_with_snooze():
453
+ body = '## Ideas\n- [2026-03-28] Some idea (snooze: 2026-04-04, count: 1)\n'
454
+ ideas = parse_ideas(body)
455
+ assert len(ideas) == 1
456
+ assert ideas[0].snooze_until == '2026-04-04'
457
+ assert ideas[0].snooze_count == 1
458
+
459
+
460
+ def test_parse_ideas_empty():
461
+ body = '## Thread\n### 2026-03-28\nSome work.\n'
462
+ assert parse_ideas(body) == []
463
+
464
+
465
+ def test_append_idea_creates_section():
466
+ body = '## Thread\nSome content\n'
467
+ idea = IdeaEntry(date='2026-03-28', text='New idea')
468
+ updated = append_idea(body, idea)
469
+ assert '## Ideas' in updated
470
+ assert '- [2026-03-28] New idea' in updated
471
+ assert '## Thread' in updated
472
+
473
+
474
+ def test_append_idea_to_existing():
475
+ body = '## Ideas\n- [2026-03-27] Old idea\n'
476
+ idea = IdeaEntry(date='2026-03-28', text='New idea')
477
+ updated = append_idea(body, idea)
478
+ assert '- [2026-03-27] Old idea' in updated
479
+ assert '- [2026-03-28] New idea' in updated
480
+
481
+
482
+ # ---------------------------------------------------------------------------
483
+ # Log section
484
+ # ---------------------------------------------------------------------------
485
+
486
+
487
+ def test_parse_log_basic():
488
+ body = '## Log\n- [2026-03-28] created (active, day)\n- [2026-03-29] snoozed until 2026-04-07: Waiting on review\n'
489
+ log = parse_log(body)
490
+ assert len(log) == 2
491
+ assert log[0].date == '2026-03-28'
492
+ assert log[0].event == 'created'
493
+ assert log[0].detail == '(active, day)'
494
+ assert log[1].event == 'snoozed'
495
+ assert 'Waiting on review' in log[1].detail
496
+
497
+
498
+ def test_parse_log_empty():
499
+ body = '## Thread\nSome work.\n'
500
+ assert parse_log(body) == []
501
+
502
+
503
+ def test_append_log_entry_creates_section():
504
+ body = '## Thread\nSome content\n'
505
+ entry = LogEntry(date='2026-03-28', event='created', detail='(active, day)')
506
+ updated = append_log_entry(body, entry)
507
+ assert '## Log' in updated
508
+ assert '- [2026-03-28] created (active, day)' in updated
509
+
510
+
511
+ def test_append_log_entry_to_existing():
512
+ body = '## Log\n- [2026-03-28] created (active, day)\n'
513
+ entry = LogEntry(date='2026-03-29', event='snoozed', detail='until 2026-04-07: Blocked')
514
+ updated = append_log_entry(body, entry)
515
+ assert '- [2026-03-28] created' in updated
516
+ assert '- [2026-03-29] snoozed until 2026-04-07: Blocked' in updated
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Render body with ideas and log
521
+ # ---------------------------------------------------------------------------
522
+
523
+
524
+ def test_render_body_with_ideas_and_log():
525
+ ws = Workstream(
526
+ id='x',
527
+ title='Test',
528
+ ideas=[IdeaEntry(date='2026-03-28', text='Cool idea')],
529
+ log=[LogEntry(date='2026-03-28', event='created', detail='(active, day)')],
530
+ next_actions=['Do something'],
531
+ )
532
+ body = render_body(ws)
533
+ assert '## Ideas' in body
534
+ assert '- [2026-03-28] Cool idea' in body
535
+ assert '## Log' in body
536
+ assert '- [2026-03-28] created (active, day)' in body
537
+ # Log should appear after Next
538
+ next_pos = body.index('## Next')
539
+ log_pos = body.index('## Log')
540
+ assert log_pos > next_pos
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # Plans 30-day terminal window
545
+ # ---------------------------------------------------------------------------
546
+
547
+
548
+ def test_render_body_plans_30day_window():
549
+ """All plans are rendered regardless of age — no truncation."""
550
+ from datetime import date, timedelta
551
+
552
+ today = date.today()
553
+ recent = (today - timedelta(days=10)).isoformat()
554
+ old = (today - timedelta(days=60)).isoformat()
555
+ ancient = (today - timedelta(days=120)).isoformat()
556
+
557
+ ws = Workstream(
558
+ id='x', title='Test',
559
+ plans=[
560
+ PlanRef(repo='r', path='active.md', status='active', title='Active', date=today.isoformat()),
561
+ PlanRef(repo='r', path='recent-impl.md', status='implemented', title='Recent', date=recent),
562
+ PlanRef(repo='r', path='old-impl.md', status='implemented', title='Old Impl', date=old),
563
+ PlanRef(repo='r', path='old-obs.md', status='obsolete', title='Old Obs', date=ancient),
564
+ PlanRef(repo='r', path='tabled.md', status='tabled', title='Tabled', date=old),
565
+ ],
566
+ )
567
+ body = render_body(ws)
568
+
569
+ # All plans visible — no truncation
570
+ assert 'active.md' in body
571
+ assert 'tabled.md' in body
572
+ assert 'recent-impl.md' in body
573
+ assert 'old-impl.md' in body
574
+ assert 'old-obs.md' in body
575
+ # No summary comment
576
+ assert 'older plans' not in body
577
+
578
+
579
+ def test_render_body_plans_no_old_terminal():
580
+ """No summary comment when all terminal plans are recent."""
581
+ from datetime import date, timedelta
582
+
583
+ recent = (date.today() - timedelta(days=5)).isoformat()
584
+ ws = Workstream(
585
+ id='x', title='Test',
586
+ plans=[
587
+ PlanRef(repo='r', path='done.md', status='implemented', title='Done', date=recent),
588
+ ],
589
+ )
590
+ body = render_body(ws)
591
+ assert 'done.md' in body
592
+ assert 'older plans' not in body
593
+
594
+
595
+ def test_plan_roundtrip_plan_type_guidance():
596
+ """render_body -> parse_plans round-trips plan_type and guidance fields."""
597
+ ws = Workstream(
598
+ id='x', title='Test',
599
+ plans=[
600
+ PlanRef(repo='r', path='proj.md', status='active', title='Big Project',
601
+ date='2026-03-01', signal='', plan_type='project', guidance='new-peer'),
602
+ PlanRef(repo='r', path='tac.md', status='draft', title='Quick Fix',
603
+ date='2026-03-02', plan_type='tactical', guidance='keep-current'),
604
+ PlanRef(repo='r', path='plain.md', status='active', title='Plain',
605
+ date='2026-03-03'),
606
+ ],
607
+ )
608
+ body = render_body(ws)
609
+
610
+ # Verify serialized format
611
+ assert 'type:project' in body
612
+ assert 'guidance:new-peer' in body
613
+ assert 'type:tactical' in body
614
+ assert 'guidance:keep-current' in body
615
+
616
+ # Round-trip
617
+ plans = parse_plans(body)
618
+ assert len(plans) == 3
619
+
620
+ by_path = {p.path: p for p in plans}
621
+ assert by_path['proj.md'].plan_type == 'project'
622
+ assert by_path['proj.md'].guidance == 'new-peer'
623
+ assert by_path['tac.md'].plan_type == 'tactical'
624
+ assert by_path['tac.md'].guidance == 'keep-current'
625
+ assert by_path['plain.md'].plan_type == ''
626
+ assert by_path['plain.md'].guidance == ''