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.
- workstream/ARCHITECTURE.md +89 -0
- workstream/__init__.py +8 -0
- workstream/cli.py +136 -0
- workstream/commands/__init__.py +0 -0
- workstream/commands/backfill.py +139 -0
- workstream/commands/block.py +93 -0
- workstream/commands/checkin.py +51 -0
- workstream/commands/cron.py +119 -0
- workstream/commands/focus_cmd.py +273 -0
- workstream/commands/idea.py +172 -0
- workstream/commands/index.py +89 -0
- workstream/commands/init.py +567 -0
- workstream/commands/inspect_cmd.py +354 -0
- workstream/commands/list_cmd.py +99 -0
- workstream/commands/nest.py +108 -0
- workstream/commands/new.py +95 -0
- workstream/commands/next_cmd.py +333 -0
- workstream/commands/report.py +190 -0
- workstream/commands/resume.py +145 -0
- workstream/commands/review.py +227 -0
- workstream/commands/serve.py +23 -0
- workstream/commands/setup.py +178 -0
- workstream/commands/show.py +123 -0
- workstream/commands/snooze.py +117 -0
- workstream/commands/stale.py +116 -0
- workstream/commands/sweep.py +1753 -0
- workstream/commands/tree.py +105 -0
- workstream/commands/update_status.py +117 -0
- workstream/config.py +322 -0
- workstream/extensions/__init__.py +0 -0
- workstream/extensions/workstream.ts +633 -0
- workstream/focus_artifact.py +157 -0
- workstream/git.py +194 -0
- workstream/harness.py +49 -0
- workstream/llm.py +78 -0
- workstream/markdown.py +501 -0
- workstream/models.py +274 -0
- workstream/plan_index.py +88 -0
- workstream/provisioning.py +196 -0
- workstream/repo_discovery.py +158 -0
- workstream/review_artifact.py +96 -0
- workstream/scripts/migrate_statuses.py +120 -0
- workstream/skills/__init__.py +0 -0
- workstream/skills/workstream_context/SKILL.md +75 -0
- workstream/skills/workstream_context/__init__.py +0 -0
- workstream/skills/workstream_focus/SKILL.md +141 -0
- workstream/skills/workstream_init/SKILL.md +86 -0
- workstream/skills/workstream_review/SKILL.md +224 -0
- workstream/skills/workstream_sweep/SKILL.md +178 -0
- workstream/sweep_state.py +93 -0
- workstream/templates/dashboard.html +382 -0
- workstream/templates/detail.html +360 -0
- workstream/templates/plan.html +210 -0
- workstream/test/__init__.py +0 -0
- workstream/test/conftest.py +221 -0
- workstream/test/fixtures/sample_sprint_note.md +10 -0
- workstream/test/fixtures/sample_workstream.md +41 -0
- workstream/test/test_backfill.py +180 -0
- workstream/test/test_batch_writeback.py +81 -0
- workstream/test/test_commands.py +938 -0
- workstream/test/test_config.py +54 -0
- workstream/test/test_focus_artifact.py +211 -0
- workstream/test/test_git.py +88 -0
- workstream/test/test_heuristics.py +136 -0
- workstream/test/test_hierarchy.py +231 -0
- workstream/test/test_init.py +452 -0
- workstream/test/test_inspect.py +143 -0
- workstream/test/test_llm.py +78 -0
- workstream/test/test_markdown.py +626 -0
- workstream/test/test_models.py +506 -0
- workstream/test/test_next.py +206 -0
- workstream/test/test_plan_index.py +83 -0
- workstream/test/test_provisioning.py +270 -0
- workstream/test/test_repo_discovery.py +181 -0
- workstream/test/test_resume.py +71 -0
- workstream/test/test_sweep.py +1196 -0
- workstream/test/test_sweep_state.py +86 -0
- workstream/test/test_thoughts.py +516 -0
- workstream/test/test_web.py +606 -0
- workstream/thoughts.py +505 -0
- workstream/web.py +444 -0
- workstream_cli-0.0.1.dist-info/LICENSE +21 -0
- workstream_cli-0.0.1.dist-info/METADATA +93 -0
- workstream_cli-0.0.1.dist-info/RECORD +86 -0
- workstream_cli-0.0.1.dist-info/WHEEL +4 -0
- workstream_cli-0.0.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Tests for workstream.models — pure data types and helpers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from workstream.models import (
|
|
7
|
+
ACTIVE_STATUSES,
|
|
8
|
+
SIZES,
|
|
9
|
+
STATUSES,
|
|
10
|
+
TERMINAL_STATUSES,
|
|
11
|
+
IdeaEntry,
|
|
12
|
+
LogEntry,
|
|
13
|
+
PlanRef,
|
|
14
|
+
ThoughtEntry,
|
|
15
|
+
ThreadEntry,
|
|
16
|
+
Workstream,
|
|
17
|
+
generate_id,
|
|
18
|
+
slugify,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_ID_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-[0-9a-f]{10}$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_generate_id_format():
|
|
25
|
+
assert _ID_RE.match(generate_id())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_generate_id_uniqueness():
|
|
29
|
+
assert generate_id() != generate_id()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_slugify_basic():
|
|
33
|
+
assert slugify("API Redesign") == "api-redesign"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_slugify_special_chars():
|
|
37
|
+
assert slugify("Hello, World! (v2)") == "hello-world-v2"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_slugify_empty():
|
|
41
|
+
assert slugify("") == "untitled"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_slugify_unicode():
|
|
45
|
+
# Non-ASCII chars are stripped by encode('ascii','ignore')
|
|
46
|
+
assert slugify("Über Cool") == "ber-cool"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_workstream_defaults():
|
|
50
|
+
ws = Workstream(id="x", title="y")
|
|
51
|
+
assert ws.status == "active"
|
|
52
|
+
assert ws.size == "day"
|
|
53
|
+
assert ws.tags == []
|
|
54
|
+
assert ws.repos == []
|
|
55
|
+
assert ws.created == ""
|
|
56
|
+
assert ws.updated == ""
|
|
57
|
+
assert ws.snooze_reason == ""
|
|
58
|
+
assert ws.ideas == []
|
|
59
|
+
assert ws.log == []
|
|
60
|
+
assert ws.thread == []
|
|
61
|
+
assert ws.decisions == []
|
|
62
|
+
assert ws.plans == []
|
|
63
|
+
assert ws.branches == []
|
|
64
|
+
assert ws.next_actions == []
|
|
65
|
+
assert ws.source_path is None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_workstream_filename():
|
|
69
|
+
ws = Workstream(id="2026-03-28-abc1234567", title="API Redesign")
|
|
70
|
+
assert ws.filename == "2026-03-28-abc1234567-api-redesign.md"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_frontmatter_dict_minimal():
|
|
74
|
+
ws = Workstream(id="x", title="y")
|
|
75
|
+
d = ws.frontmatter_dict()
|
|
76
|
+
assert d == {"id": "x", "title": "y", "status": "active", "size": "day"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_frontmatter_dict_full():
|
|
80
|
+
ws = Workstream(
|
|
81
|
+
id="x",
|
|
82
|
+
title="y",
|
|
83
|
+
status="active",
|
|
84
|
+
size="week",
|
|
85
|
+
tags=["infra"],
|
|
86
|
+
repos=["repo-a"],
|
|
87
|
+
created="2026-01-01",
|
|
88
|
+
updated="2026-03-28",
|
|
89
|
+
snooze_reason="waiting on review",
|
|
90
|
+
)
|
|
91
|
+
d = ws.frontmatter_dict()
|
|
92
|
+
assert d == {
|
|
93
|
+
"id": "x",
|
|
94
|
+
"title": "y",
|
|
95
|
+
"status": "active",
|
|
96
|
+
"size": "week",
|
|
97
|
+
"tags": ["infra"],
|
|
98
|
+
"repos": ["repo-a"],
|
|
99
|
+
"created": "2026-01-01",
|
|
100
|
+
"updated": "2026-03-28",
|
|
101
|
+
"snooze_reason": "waiting on review",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_days_idle_recent():
|
|
106
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
107
|
+
ws = Workstream(id="x", title="y", last_activity=today)
|
|
108
|
+
assert ws.days_idle() == 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_days_idle_empty():
|
|
112
|
+
ws = Workstream(id="x", title="y")
|
|
113
|
+
assert ws.days_idle() == 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_last_thread_entry_empty():
|
|
117
|
+
ws = Workstream(id="x", title="y")
|
|
118
|
+
assert ws.last_thread_entry is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_last_thread_entry_present():
|
|
122
|
+
e1 = ThreadEntry(date="2026-03-28", body="first")
|
|
123
|
+
e2 = ThreadEntry(date="2026-03-27", body="second")
|
|
124
|
+
ws = Workstream(id="x", title="y", thread=[e1, e2])
|
|
125
|
+
assert ws.last_thread_entry is e1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_statuses_are_tuples():
|
|
129
|
+
assert isinstance(STATUSES, tuple)
|
|
130
|
+
assert isinstance(ACTIVE_STATUSES, tuple)
|
|
131
|
+
assert isinstance(TERMINAL_STATUSES, tuple)
|
|
132
|
+
assert "active" in STATUSES
|
|
133
|
+
assert "active" in ACTIVE_STATUSES
|
|
134
|
+
assert "completed" in TERMINAL_STATUSES
|
|
135
|
+
assert len(SIZES) > 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── ThoughtEntry ──────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def test_thought_entry_defaults():
|
|
142
|
+
t = ThoughtEntry(date='2026-03-28', text='Some idea')
|
|
143
|
+
assert t.snooze_until == ''
|
|
144
|
+
assert t.snooze_count == 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_thought_entry_with_snooze():
|
|
148
|
+
t = ThoughtEntry(date='2026-03-28', text='Idea', snooze_until='2026-04-04', snooze_count=2)
|
|
149
|
+
assert t.snooze_until == '2026-04-04'
|
|
150
|
+
assert t.snooze_count == 2
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Hierarchy and snooze on Workstream ────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def test_workstream_hierarchy_defaults():
|
|
156
|
+
ws = Workstream(id='x', title='y')
|
|
157
|
+
assert ws.parent == ''
|
|
158
|
+
assert ws.snooze_until == ''
|
|
159
|
+
assert ws.snooze_count == 0
|
|
160
|
+
assert ws.thoughts == []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_frontmatter_dict_with_parent_and_snooze():
|
|
164
|
+
ws = Workstream(
|
|
165
|
+
id='x', title='y',
|
|
166
|
+
parent='2026-01-01-abc1234567',
|
|
167
|
+
snooze_until='2026-04-15',
|
|
168
|
+
snooze_count=3,
|
|
169
|
+
)
|
|
170
|
+
d = ws.frontmatter_dict()
|
|
171
|
+
assert d['parent'] == '2026-01-01-abc1234567'
|
|
172
|
+
assert d['snooze_until'] == '2026-04-15'
|
|
173
|
+
assert d['snooze_count'] == 3
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_frontmatter_dict_omits_empty_parent_and_snooze():
|
|
177
|
+
ws = Workstream(id='x', title='y')
|
|
178
|
+
d = ws.frontmatter_dict()
|
|
179
|
+
assert 'parent' not in d
|
|
180
|
+
assert 'snooze_until' not in d
|
|
181
|
+
assert 'snooze_count' not in d
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── Activity dates ──────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
def test_activity_dates_defaults():
|
|
187
|
+
ws = Workstream(id='x', title='y')
|
|
188
|
+
assert ws.first_activity == ''
|
|
189
|
+
assert ws.last_activity == ''
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_frontmatter_dict_with_activity_dates():
|
|
193
|
+
ws = Workstream(
|
|
194
|
+
id='x', title='y',
|
|
195
|
+
first_activity='2026-01-15',
|
|
196
|
+
last_activity='2026-03-28',
|
|
197
|
+
)
|
|
198
|
+
d = ws.frontmatter_dict()
|
|
199
|
+
assert d['first_activity'] == '2026-01-15'
|
|
200
|
+
assert d['last_activity'] == '2026-03-28'
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_frontmatter_dict_omits_empty_activity_dates():
|
|
204
|
+
ws = Workstream(id='x', title='y')
|
|
205
|
+
d = ws.frontmatter_dict()
|
|
206
|
+
assert 'first_activity' not in d
|
|
207
|
+
assert 'last_activity' not in d
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── IdeaEntry / LogEntry ──────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def test_idea_entry_defaults():
|
|
213
|
+
idea = IdeaEntry(date='2026-03-28', text='Build a widget')
|
|
214
|
+
assert idea.snooze_until == ''
|
|
215
|
+
assert idea.snooze_count == 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_log_entry_defaults():
|
|
219
|
+
entry = LogEntry(date='2026-03-28', event='created')
|
|
220
|
+
assert entry.detail == ''
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_log_entry_with_detail():
|
|
224
|
+
entry = LogEntry(date='2026-03-28', event='snoozed', detail='until 2026-04-07: Blocked')
|
|
225
|
+
assert entry.event == 'snoozed'
|
|
226
|
+
assert entry.detail == 'until 2026-04-07: Blocked'
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Summary field ──────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
def test_summary_default():
|
|
232
|
+
ws = Workstream(id='x', title='y')
|
|
233
|
+
assert ws.summary == ''
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_frontmatter_dict_with_summary():
|
|
237
|
+
ws = Workstream(id='x', title='y', summary='Redesigning the API layer for v2 migration.')
|
|
238
|
+
d = ws.frontmatter_dict()
|
|
239
|
+
assert d['summary'] == 'Redesigning the API layer for v2 migration.'
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_frontmatter_dict_omits_empty_summary():
|
|
243
|
+
ws = Workstream(id='x', title='y')
|
|
244
|
+
d = ws.frontmatter_dict()
|
|
245
|
+
assert 'summary' not in d
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ── days_idle with last_activity ──────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def test_days_idle_uses_last_activity_when_newer():
|
|
251
|
+
"""days_idle uses last_activity exclusively; updated is ignored."""
|
|
252
|
+
today = datetime.now().strftime('%Y-%m-%d')
|
|
253
|
+
# updated is old, last_activity is today => 0 days idle
|
|
254
|
+
ws = Workstream(id='x', title='y', updated='2026-01-01', last_activity=today)
|
|
255
|
+
assert ws.days_idle() == 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_days_idle_ignores_updated():
|
|
259
|
+
"""days_idle only consults last_activity, not updated."""
|
|
260
|
+
today = datetime.now().strftime('%Y-%m-%d')
|
|
261
|
+
# updated is today but last_activity is old => days_idle reflects last_activity, not updated
|
|
262
|
+
ws = Workstream(id='x', title='y', updated=today, last_activity='2026-01-01')
|
|
263
|
+
assert ws.days_idle() > 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_days_idle_only_last_activity():
|
|
267
|
+
today = datetime.now().strftime('%Y-%m-%d')
|
|
268
|
+
ws = Workstream(id='x', title='y', last_activity=today)
|
|
269
|
+
assert ws.days_idle() == 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_days_idle_updated_only():
|
|
273
|
+
"""When only updated is set, computed_last_activity falls back to it."""
|
|
274
|
+
today = datetime.now().strftime('%Y-%m-%d')
|
|
275
|
+
ws = Workstream(id='x', title='y', updated=today)
|
|
276
|
+
assert ws.days_idle() == 0
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_last_activity_dt_valid():
|
|
280
|
+
ws = Workstream(id='x', title='y', last_activity='2026-03-28')
|
|
281
|
+
assert ws.last_activity_dt is not None
|
|
282
|
+
assert ws.last_activity_dt.year == 2026
|
|
283
|
+
assert ws.last_activity_dt.month == 3
|
|
284
|
+
assert ws.last_activity_dt.day == 28
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def test_last_activity_dt_empty():
|
|
288
|
+
ws = Workstream(id='x', title='y')
|
|
289
|
+
assert ws.last_activity_dt is None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_last_activity_dt_invalid():
|
|
293
|
+
ws = Workstream(id='x', title='y', last_activity='not-a-date')
|
|
294
|
+
assert ws.last_activity_dt is None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── computed_last_activity ────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
def test_computed_last_activity_prefers_plan_date():
|
|
300
|
+
"""Plan dates that are newer than stored last_activity are used."""
|
|
301
|
+
ws = Workstream(
|
|
302
|
+
id='x', title='y',
|
|
303
|
+
last_activity='2026-03-25',
|
|
304
|
+
plans=[PlanRef(repo='r', path='p.md', status='active',
|
|
305
|
+
title='Plan', date='2026-03-30')],
|
|
306
|
+
)
|
|
307
|
+
assert ws.computed_last_activity == '2026-03-30'
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_computed_last_activity_ignores_log_entries():
|
|
311
|
+
"""Log entries are bookkeeping (snooze/wake/import), not work signals."""
|
|
312
|
+
ws = Workstream(
|
|
313
|
+
id='x', title='y',
|
|
314
|
+
last_activity='2026-03-25',
|
|
315
|
+
log=[LogEntry(date='2026-03-29', event='snoozed')],
|
|
316
|
+
)
|
|
317
|
+
assert ws.computed_last_activity == '2026-03-25'
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_computed_last_activity_considers_all_work_sources():
|
|
321
|
+
"""The max date across work-artifact types wins; log and ideas are excluded."""
|
|
322
|
+
ws = Workstream(
|
|
323
|
+
id='x', title='y',
|
|
324
|
+
last_activity='2026-03-20',
|
|
325
|
+
thread=[ThreadEntry(date='2026-03-22', body='note')],
|
|
326
|
+
thoughts=[ThoughtEntry(date='2026-03-24', text='hmm')],
|
|
327
|
+
ideas=[IdeaEntry(date='2026-03-26', text='what if')], # excluded
|
|
328
|
+
plans=[PlanRef(repo='r', path='p.md', status='active',
|
|
329
|
+
title='Plan', date='2026-03-28')],
|
|
330
|
+
log=[LogEntry(date='2026-03-30', event='snoozed')], # excluded
|
|
331
|
+
)
|
|
332
|
+
assert ws.computed_last_activity == '2026-03-28' # plan, not log or idea
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_computed_last_activity_falls_back_to_updated():
|
|
336
|
+
"""When no dated entries exist and last_activity is empty, falls back to updated."""
|
|
337
|
+
ws = Workstream(id='x', title='y', updated='2026-03-15')
|
|
338
|
+
assert ws.computed_last_activity == '2026-03-15'
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_computed_last_activity_falls_back_to_created():
|
|
342
|
+
"""When updated is also empty, falls back to created."""
|
|
343
|
+
ws = Workstream(id='x', title='y', created='2026-03-10')
|
|
344
|
+
assert ws.computed_last_activity == '2026-03-10'
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_computed_last_activity_empty():
|
|
348
|
+
"""Returns empty string when nothing is available."""
|
|
349
|
+
ws = Workstream(id='x', title='y')
|
|
350
|
+
assert ws.computed_last_activity == ''
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_days_idle_reflects_plan_activity():
|
|
354
|
+
"""days_idle uses plan dates — the user's core bug."""
|
|
355
|
+
today = datetime.now().strftime('%Y-%m-%d')
|
|
356
|
+
ws = Workstream(
|
|
357
|
+
id='x', title='y',
|
|
358
|
+
last_activity='2026-01-01', # stale cache
|
|
359
|
+
plans=[PlanRef(repo='r', path='p.md', status='active',
|
|
360
|
+
title='Plan', date=today)], # fresh plan
|
|
361
|
+
)
|
|
362
|
+
assert ws.days_idle() == 0 # plan date overrides stale cache
|
|
363
|
+
# ── needs_direction ──────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
def test_needs_direction_true_when_directionless():
|
|
366
|
+
"""Active ws with no next_actions, no active plans, no recent thread."""
|
|
367
|
+
ws = Workstream(id='x', title='y', status='active')
|
|
368
|
+
assert ws.needs_direction() is True
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_needs_direction_false_when_snoozed():
|
|
372
|
+
ws = Workstream(id='x', title='y', status='snoozed')
|
|
373
|
+
assert ws.needs_direction() is False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_needs_direction_false_with_next_actions():
|
|
377
|
+
ws = Workstream(id='x', title='y', status='active',
|
|
378
|
+
next_actions=['Do the thing'])
|
|
379
|
+
assert ws.needs_direction() is False
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_needs_direction_false_with_active_plan():
|
|
383
|
+
ws = Workstream(id='x', title='y', status='active',
|
|
384
|
+
plans=[PlanRef(repo='r', path='p.md', status='active',
|
|
385
|
+
title='Plan', date='2026-03-01')])
|
|
386
|
+
assert ws.needs_direction() is False
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_needs_direction_false_with_recent_thread():
|
|
390
|
+
today = datetime.now().strftime('%Y-%m-%d')
|
|
391
|
+
ws = Workstream(id='x', title='y', status='active',
|
|
392
|
+
thread=[ThreadEntry(date=today, body='Working on it')])
|
|
393
|
+
assert ws.needs_direction() is False
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_needs_direction_true_with_old_thread():
|
|
397
|
+
ws = Workstream(id='x', title='y', status='active',
|
|
398
|
+
thread=[ThreadEntry(date='2026-01-01', body='Stale entry')])
|
|
399
|
+
assert ws.needs_direction() is True
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# ── computed_last_activity excludes ideas ─────────────────────────────
|
|
403
|
+
|
|
404
|
+
def test_computed_last_activity_excludes_ideas():
|
|
405
|
+
"""Ideas are design-level, not work signals — they should not count."""
|
|
406
|
+
ws = Workstream(
|
|
407
|
+
id='x', title='y',
|
|
408
|
+
last_activity='2026-03-20',
|
|
409
|
+
ideas=[IdeaEntry(date='2026-03-30', text='some idea')],
|
|
410
|
+
)
|
|
411
|
+
# Idea date (03-30) is newer but excluded; last_activity (03-20) wins
|
|
412
|
+
assert ws.computed_last_activity == '2026-03-20'
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def test_computed_last_activity_ideas_only_falls_back():
|
|
416
|
+
"""Workstream with only ideas has no work signals — falls back to updated."""
|
|
417
|
+
ws = Workstream(
|
|
418
|
+
id='x', title='y',
|
|
419
|
+
updated='2026-03-10',
|
|
420
|
+
ideas=[IdeaEntry(date='2026-03-30', text='some idea')],
|
|
421
|
+
)
|
|
422
|
+
assert ws.computed_last_activity == '2026-03-10'
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ── code_last_activity ────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
def test_code_last_activity_default_empty():
|
|
428
|
+
ws = Workstream(id='x', title='y')
|
|
429
|
+
assert ws.code_last_activity == ''
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def test_code_last_activity_in_frontmatter_dict():
|
|
433
|
+
ws = Workstream(id='x', title='y', code_last_activity='2026-03-28')
|
|
434
|
+
d = ws.frontmatter_dict()
|
|
435
|
+
assert d['code_last_activity'] == '2026-03-28'
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_code_last_activity_omitted_when_empty():
|
|
439
|
+
ws = Workstream(id='x', title='y')
|
|
440
|
+
d = ws.frontmatter_dict()
|
|
441
|
+
assert 'code_last_activity' not in d
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ── blocked status ──────────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
def test_blocked_in_statuses():
|
|
447
|
+
assert 'blocked' in STATUSES
|
|
448
|
+
assert 'blocked' in ACTIVE_STATUSES
|
|
449
|
+
assert 'blocked' not in TERMINAL_STATUSES
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_blocked_notes_default():
|
|
453
|
+
ws = Workstream(id='x', title='y')
|
|
454
|
+
assert ws.blocked_notes == ''
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_frontmatter_dict_with_blocked_notes():
|
|
458
|
+
ws = Workstream(id='x', title='y', status='blocked', blocked_notes='Waiting for API key')
|
|
459
|
+
d = ws.frontmatter_dict()
|
|
460
|
+
assert d['blocked_notes'] == 'Waiting for API key'
|
|
461
|
+
assert d['status'] == 'blocked'
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_frontmatter_dict_omits_empty_blocked_notes():
|
|
465
|
+
ws = Workstream(id='x', title='y')
|
|
466
|
+
d = ws.frontmatter_dict()
|
|
467
|
+
assert 'blocked_notes' not in d
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def test_needs_direction_false_when_blocked():
|
|
471
|
+
"""Blocked workstreams have direction (they're stuck, not directionless)."""
|
|
472
|
+
ws = Workstream(id='x', title='y', status='blocked')
|
|
473
|
+
assert ws.needs_direction() is False
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ── code_last_activity in computed_last_activity ───────────────────────────────────
|
|
477
|
+
|
|
478
|
+
def test_computed_last_activity_includes_code_last_activity():
|
|
479
|
+
"""code_last_activity is a work signal — repos with recent commits are not stale."""
|
|
480
|
+
ws = Workstream(
|
|
481
|
+
id='x', title='y',
|
|
482
|
+
last_activity='2026-03-20',
|
|
483
|
+
code_last_activity='2026-03-30',
|
|
484
|
+
)
|
|
485
|
+
assert ws.computed_last_activity == '2026-03-30'
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_computed_last_activity_code_older_than_other_sources():
|
|
489
|
+
"""code_last_activity is included but doesn't override newer sources."""
|
|
490
|
+
ws = Workstream(
|
|
491
|
+
id='x', title='y',
|
|
492
|
+
last_activity='2026-03-30',
|
|
493
|
+
code_last_activity='2026-03-20',
|
|
494
|
+
)
|
|
495
|
+
assert ws.computed_last_activity == '2026-03-30'
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_days_idle_reflects_code_activity():
|
|
499
|
+
"""A workstream with recent commits is not idle."""
|
|
500
|
+
today = datetime.now().strftime('%Y-%m-%d')
|
|
501
|
+
ws = Workstream(
|
|
502
|
+
id='x', title='y',
|
|
503
|
+
last_activity='2026-01-01', # stale cache
|
|
504
|
+
code_last_activity=today, # fresh commits
|
|
505
|
+
)
|
|
506
|
+
assert ws.days_idle() == 0
|