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,273 @@
|
|
|
1
|
+
"""ws focus -- set weekly priorities and identify blocked/agent-actionable items.
|
|
2
|
+
|
|
3
|
+
Gathers deterministic context (active workstreams, blocked items, expiring
|
|
4
|
+
snoozes, previous focus), writes a manifest, and execs into omp with the
|
|
5
|
+
focus skill. The skill drives the interactive session; the focus artifact
|
|
6
|
+
captures decisions.
|
|
7
|
+
|
|
8
|
+
Execution mode: handoff (direct -> omp exec). Falls back to printing
|
|
9
|
+
the manifest when already inside an agent session (tool mode).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
from datetime import date, timedelta
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from face import Command
|
|
21
|
+
|
|
22
|
+
from workstream.cli import load_all_workstreams
|
|
23
|
+
from workstream.commands.init import _read_skill_content
|
|
24
|
+
from workstream.config import Config
|
|
25
|
+
from workstream.focus_artifact import (
|
|
26
|
+
FocusArtifact,
|
|
27
|
+
load_current_week_focus,
|
|
28
|
+
load_latest_focus,
|
|
29
|
+
)
|
|
30
|
+
from workstream.harness import exec_or_fallback, find_harness
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Helpers ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_within(within: str | None) -> int | None:
|
|
37
|
+
"""Parse a duration string like '2d' into days, or None for calendar week default."""
|
|
38
|
+
if within is None:
|
|
39
|
+
return None
|
|
40
|
+
m = re.fullmatch(r'(\d+)d', within.strip())
|
|
41
|
+
if not m:
|
|
42
|
+
raise ValueError(f'Invalid --within value {within!r}; expected e.g. "2d", "7d"')
|
|
43
|
+
return int(m.group(1))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_current_focus(
|
|
47
|
+
ws_dir: Path, within: str | None = None,
|
|
48
|
+
) -> FocusArtifact | None:
|
|
49
|
+
"""Find the most recent non-expired focus doc within the freshness window.
|
|
50
|
+
|
|
51
|
+
If *within* is provided (e.g. '3d'), looks back that many days.
|
|
52
|
+
Otherwise uses calendar-week semantics via load_current_week_focus.
|
|
53
|
+
"""
|
|
54
|
+
days = _parse_within(within)
|
|
55
|
+
if days is not None:
|
|
56
|
+
# Custom window: find most recent focus doc within N days
|
|
57
|
+
focus_dir = ws_dir / 'focus'
|
|
58
|
+
if not focus_dir.is_dir():
|
|
59
|
+
return None
|
|
60
|
+
cutoff = (date.today() - timedelta(days=days)).isoformat()
|
|
61
|
+
today_str = date.today().isoformat()
|
|
62
|
+
files = sorted(focus_dir.glob('*.md'), reverse=True)
|
|
63
|
+
for f in files:
|
|
64
|
+
stem = f.stem # YYYY-MM-DD
|
|
65
|
+
if stem < cutoff:
|
|
66
|
+
break # sorted descending; no point continuing
|
|
67
|
+
from workstream.focus_artifact import load_focus
|
|
68
|
+
|
|
69
|
+
artifact = load_focus(f)
|
|
70
|
+
# Skip expired docs
|
|
71
|
+
if artifact.expires and artifact.expires < today_str:
|
|
72
|
+
continue
|
|
73
|
+
return artifact
|
|
74
|
+
return None
|
|
75
|
+
else:
|
|
76
|
+
return load_current_week_focus(ws_dir)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Manifest builder ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_focus_manifest(
|
|
83
|
+
config: Config,
|
|
84
|
+
ws_dir: Path,
|
|
85
|
+
existing: FocusArtifact | None,
|
|
86
|
+
previous: FocusArtifact | None,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Build a structured context manifest for the focus skill."""
|
|
89
|
+
today = date.today()
|
|
90
|
+
today_str = today.isoformat()
|
|
91
|
+
workstreams = load_all_workstreams(ws_dir)
|
|
92
|
+
|
|
93
|
+
active = [w for w in workstreams if w.status == 'active']
|
|
94
|
+
blocked = [w for w in workstreams if w.status == 'blocked']
|
|
95
|
+
stale = [w for w in active if w.days_idle() > 7]
|
|
96
|
+
directionless = [w for w in workstreams if w.needs_direction()]
|
|
97
|
+
|
|
98
|
+
# Expiring snoozes (within 48h)
|
|
99
|
+
cutoff_snooze = (today + timedelta(days=2)).isoformat()
|
|
100
|
+
expiring_snoozes = [
|
|
101
|
+
w for w in workstreams
|
|
102
|
+
if w.snooze_until and w.snooze_until <= cutoff_snooze and w.status == 'snoozed'
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
lines: list[str] = [
|
|
106
|
+
'## Focus Session Context',
|
|
107
|
+
'',
|
|
108
|
+
f'Today: {today_str}',
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
# Mode
|
|
112
|
+
if existing:
|
|
113
|
+
lines.append('Mode: Iterating on existing focus doc')
|
|
114
|
+
else:
|
|
115
|
+
lines.append('Mode: New focus session')
|
|
116
|
+
lines.append('')
|
|
117
|
+
|
|
118
|
+
# State summary
|
|
119
|
+
lines.append(
|
|
120
|
+
f'State: {len(active)} active, {len(blocked)} blocked, '
|
|
121
|
+
f'{len(stale)} stale, {len(directionless)} needs direction, '
|
|
122
|
+
f'{len(expiring_snoozes)} expiring snoozes'
|
|
123
|
+
)
|
|
124
|
+
lines.append('')
|
|
125
|
+
|
|
126
|
+
# ── Existing focus doc (iterating) ──────────────────────────────
|
|
127
|
+
if existing:
|
|
128
|
+
lines.append('### Current Focus Doc')
|
|
129
|
+
lines.append(f'Date: {existing.date}')
|
|
130
|
+
if existing.expires:
|
|
131
|
+
lines.append(f'Expires: {existing.expires}')
|
|
132
|
+
if existing.priorities:
|
|
133
|
+
lines.append('')
|
|
134
|
+
lines.append('**Priorities:**')
|
|
135
|
+
for p in existing.priorities:
|
|
136
|
+
reason = f' — {p["reason"]}' if p.get('reason') else ''
|
|
137
|
+
lines.append(f'- {p["id"]}{reason}')
|
|
138
|
+
if existing.blocked:
|
|
139
|
+
lines.append('')
|
|
140
|
+
lines.append('**Blocked:**')
|
|
141
|
+
for b in existing.blocked:
|
|
142
|
+
on = f' — {b["on"]}' if b.get('on') else ''
|
|
143
|
+
lines.append(f'- {b["id"]}{on}')
|
|
144
|
+
if existing.context:
|
|
145
|
+
lines.append('')
|
|
146
|
+
lines.append(f'**Context:** {existing.context}')
|
|
147
|
+
if existing.body:
|
|
148
|
+
lines.append('')
|
|
149
|
+
lines.append(existing.body)
|
|
150
|
+
lines.append('')
|
|
151
|
+
|
|
152
|
+
# ── Previous focus doc (new session, for continuity) ────────────
|
|
153
|
+
if previous and not existing:
|
|
154
|
+
lines.append('### Previous Focus Doc (for continuity)')
|
|
155
|
+
lines.append(f'Date: {previous.date}')
|
|
156
|
+
if previous.priorities:
|
|
157
|
+
lines.append('')
|
|
158
|
+
lines.append('**Priorities:**')
|
|
159
|
+
for p in previous.priorities:
|
|
160
|
+
reason = f' — {p["reason"]}' if p.get('reason') else ''
|
|
161
|
+
lines.append(f'- {p["id"]}{reason}')
|
|
162
|
+
if previous.body:
|
|
163
|
+
lines.append('')
|
|
164
|
+
body_preview = '\n'.join(previous.body.splitlines()[:20])
|
|
165
|
+
lines.append(body_preview)
|
|
166
|
+
lines.append('')
|
|
167
|
+
|
|
168
|
+
# ── Active workstreams overview ─────────────────────────────────
|
|
169
|
+
active.sort(key=lambda w: w.days_idle(), reverse=True)
|
|
170
|
+
lines.append(f'### Active Workstreams ({len(active)})')
|
|
171
|
+
for w in active:
|
|
172
|
+
summary = f' — {w.summary}' if w.summary else ''
|
|
173
|
+
idle_str = f'{w.days_idle()}d idle' if w.days_idle() > 0 else 'active today'
|
|
174
|
+
entry = w.last_thread_entry
|
|
175
|
+
last_entry = (
|
|
176
|
+
f', last: [{entry.date}] {entry.body.splitlines()[0][:60]}'
|
|
177
|
+
if entry else ''
|
|
178
|
+
)
|
|
179
|
+
lines.append(f'- **{w.title}** ({idle_str}{last_entry}{summary})')
|
|
180
|
+
lines.append('')
|
|
181
|
+
|
|
182
|
+
# ── Blocked workstreams ─────────────────────────────────────────
|
|
183
|
+
lines.append(f'### Blocked ({len(blocked)})')
|
|
184
|
+
if blocked:
|
|
185
|
+
for w in blocked:
|
|
186
|
+
summary = f' — {w.summary}' if w.summary else ''
|
|
187
|
+
lines.append(f'- **{w.title}**{summary}')
|
|
188
|
+
else:
|
|
189
|
+
lines.append('(none)')
|
|
190
|
+
lines.append('')
|
|
191
|
+
|
|
192
|
+
# ── Needs direction ─────────────────────────────────────────────
|
|
193
|
+
lines.append(f'### Needs Direction ({len(directionless)})')
|
|
194
|
+
if directionless:
|
|
195
|
+
for w in directionless:
|
|
196
|
+
lines.append(f'- **{w.title}** (no next actions, no active plans)')
|
|
197
|
+
else:
|
|
198
|
+
lines.append('(none)')
|
|
199
|
+
lines.append('')
|
|
200
|
+
|
|
201
|
+
# ── Expiring snoozes ────────────────────────────────────────────
|
|
202
|
+
lines.append(f'### Expiring Snoozes ({len(expiring_snoozes)})')
|
|
203
|
+
if expiring_snoozes:
|
|
204
|
+
expiring_snoozes.sort(key=lambda w: w.snooze_until)
|
|
205
|
+
for w in expiring_snoozes:
|
|
206
|
+
reason = f' — {w.snooze_reason}' if w.snooze_reason else ''
|
|
207
|
+
lines.append(f'- **{w.title}** (until {w.snooze_until}{reason})')
|
|
208
|
+
else:
|
|
209
|
+
lines.append('(none)')
|
|
210
|
+
lines.append('')
|
|
211
|
+
|
|
212
|
+
return '\n'.join(lines)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── Command handler ─────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _focus_handler(config: Config, new: bool = False, within: str | None = None) -> None:
|
|
219
|
+
ws_dir = config.workstreams_path
|
|
220
|
+
|
|
221
|
+
# 1. Find existing focus doc (unless --new forces fresh)
|
|
222
|
+
existing: FocusArtifact | None = None
|
|
223
|
+
previous: FocusArtifact | None = None
|
|
224
|
+
if not new:
|
|
225
|
+
existing = _find_current_focus(ws_dir, within=within)
|
|
226
|
+
|
|
227
|
+
# 2. If creating new, offer previous doc for continuity
|
|
228
|
+
if existing is None:
|
|
229
|
+
previous = load_latest_focus(ws_dir)
|
|
230
|
+
|
|
231
|
+
# 3. Build the context manifest
|
|
232
|
+
manifest = _build_focus_manifest(config, ws_dir, existing, previous)
|
|
233
|
+
|
|
234
|
+
# 4. Find harness
|
|
235
|
+
harness = find_harness()
|
|
236
|
+
if not harness:
|
|
237
|
+
print('No interactive harness (omp or claude) found on PATH.')
|
|
238
|
+
print('Printing focus manifest for manual use:')
|
|
239
|
+
print()
|
|
240
|
+
print(manifest)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
notes_root = ws_dir.parent
|
|
245
|
+
|
|
246
|
+
# 5. Write manifest to temp file (with skill content prepended)
|
|
247
|
+
skill = _read_skill_content('workstream_focus')
|
|
248
|
+
full_prompt = skill + '\n\n---\n\n' + manifest
|
|
249
|
+
fd, tmp_name = tempfile.mkstemp(suffix='.md', prefix='ws-focus-')
|
|
250
|
+
os.close(fd)
|
|
251
|
+
tmp_path = Path(tmp_name)
|
|
252
|
+
tmp_path.write_text(full_prompt, encoding='utf-8')
|
|
253
|
+
|
|
254
|
+
# 6. Exec into harness (or print manifest if already in agent session)
|
|
255
|
+
cmd = [
|
|
256
|
+
harness,
|
|
257
|
+
'--append-system-prompt', f'@{tmp_path}',
|
|
258
|
+
'Begin the focus session. Read the manifest in your system prompt and follow the focus skill protocol.',
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
exec_or_fallback(harness, cmd, tmp_name, cwd=notes_root)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_command() -> Command:
|
|
265
|
+
cmd = Command(
|
|
266
|
+
_focus_handler,
|
|
267
|
+
name='focus',
|
|
268
|
+
doc='Set weekly priorities and identify blocked/agent-actionable items.',
|
|
269
|
+
)
|
|
270
|
+
cmd.add('--new', parse_as=True, doc='Force new focus doc (ignore existing this week)')
|
|
271
|
+
cmd.add('--within', parse_as=str, missing=None,
|
|
272
|
+
doc='Freshness window for existing doc (e.g. 2d); default: calendar week')
|
|
273
|
+
return cmd
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""ws idea / ws ideas / ws promote — idea management commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, date
|
|
6
|
+
|
|
7
|
+
from face import Command, CommandLineError
|
|
8
|
+
|
|
9
|
+
from workstream.cli import resolve_workstream, load_all_workstreams
|
|
10
|
+
from workstream.config import Config
|
|
11
|
+
from workstream.markdown import (
|
|
12
|
+
load_workstream,
|
|
13
|
+
parse_frontmatter,
|
|
14
|
+
write_frontmatter,
|
|
15
|
+
append_idea,
|
|
16
|
+
append_log_entry,
|
|
17
|
+
parse_ideas,
|
|
18
|
+
_render_idea_line,
|
|
19
|
+
save_workstream,
|
|
20
|
+
set_section,
|
|
21
|
+
)
|
|
22
|
+
from workstream.models import IdeaEntry, LogEntry, Workstream, generate_id
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _idea_handler(posargs_: tuple, config: Config) -> None:
|
|
26
|
+
if len(posargs_) < 2:
|
|
27
|
+
raise CommandLineError('Usage: ws idea <parent-id> <text...>')
|
|
28
|
+
|
|
29
|
+
id_prefix = posargs_[0]
|
|
30
|
+
idea_text = ' '.join(posargs_[1:])
|
|
31
|
+
now_dt = datetime.now().isoformat(timespec='seconds')
|
|
32
|
+
today_date = datetime.now().strftime('%Y-%m-%d')
|
|
33
|
+
|
|
34
|
+
ws = resolve_workstream(config, id_prefix)
|
|
35
|
+
# Re-load fresh from disk.
|
|
36
|
+
ws = load_workstream(ws.source_path)
|
|
37
|
+
|
|
38
|
+
text = ws.source_path.read_text(encoding='utf-8')
|
|
39
|
+
meta, body = parse_frontmatter(text)
|
|
40
|
+
|
|
41
|
+
idea = IdeaEntry(date=today_date, text=idea_text)
|
|
42
|
+
body = append_idea(body, idea)
|
|
43
|
+
body = append_log_entry(body, LogEntry(date=today_date, event='idea-added', detail=idea_text))
|
|
44
|
+
meta['updated'] = now_dt
|
|
45
|
+
meta['last_activity'] = now_dt
|
|
46
|
+
|
|
47
|
+
ws.source_path.write_text(
|
|
48
|
+
write_frontmatter(meta, body).rstrip('\n') + '\n',
|
|
49
|
+
encoding='utf-8',
|
|
50
|
+
)
|
|
51
|
+
print(f"Added idea to '{ws.title}': {idea_text}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ideas_handler(config: Config, all: bool = False) -> None:
|
|
55
|
+
ws_dir = config.workstreams_path
|
|
56
|
+
workstreams = load_all_workstreams(ws_dir)
|
|
57
|
+
|
|
58
|
+
today_str = datetime.now().strftime('%Y-%m-%d')
|
|
59
|
+
found_any = False
|
|
60
|
+
|
|
61
|
+
for ws in workstreams:
|
|
62
|
+
if not ws.ideas:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
visible = []
|
|
66
|
+
for i, idea in enumerate(ws.ideas, 1):
|
|
67
|
+
# Skip future-snoozed ideas unless --all.
|
|
68
|
+
if not all and idea.snooze_until and idea.snooze_until > today_str:
|
|
69
|
+
continue
|
|
70
|
+
visible.append((i, idea))
|
|
71
|
+
|
|
72
|
+
if not visible:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
found_any = True
|
|
76
|
+
print(f'{ws.title}:')
|
|
77
|
+
for idx, idea in visible:
|
|
78
|
+
line = f' {idx}. [{idea.date}] {idea.text}'
|
|
79
|
+
if idea.snooze_until:
|
|
80
|
+
line += f' (snoozed until {idea.snooze_until})'
|
|
81
|
+
print(line)
|
|
82
|
+
|
|
83
|
+
if not found_any:
|
|
84
|
+
print('No ideas found.')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _promote_handler(posargs_: tuple, config: Config) -> None:
|
|
88
|
+
if len(posargs_) < 2:
|
|
89
|
+
raise CommandLineError('Usage: ws promote <parent-id> <index>')
|
|
90
|
+
|
|
91
|
+
id_prefix = posargs_[0]
|
|
92
|
+
try:
|
|
93
|
+
index = int(posargs_[1])
|
|
94
|
+
except ValueError:
|
|
95
|
+
raise CommandLineError(f'Index must be a number, got: {posargs_[1]}')
|
|
96
|
+
|
|
97
|
+
now_dt = datetime.now().isoformat(timespec='seconds')
|
|
98
|
+
today_date = datetime.now().strftime('%Y-%m-%d')
|
|
99
|
+
ws = resolve_workstream(config, id_prefix)
|
|
100
|
+
# Re-load fresh from disk.
|
|
101
|
+
ws = load_workstream(ws.source_path)
|
|
102
|
+
|
|
103
|
+
text = ws.source_path.read_text(encoding='utf-8')
|
|
104
|
+
meta, body = parse_frontmatter(text)
|
|
105
|
+
ideas = parse_ideas(body)
|
|
106
|
+
|
|
107
|
+
if index < 1 or index > len(ideas):
|
|
108
|
+
raise CommandLineError(
|
|
109
|
+
f'Invalid idea index {index}. '
|
|
110
|
+
f'{ws.title} has {len(ideas)} idea(s).'
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
idea = ideas[index - 1]
|
|
114
|
+
# Remove the promoted idea and re-render the section.
|
|
115
|
+
remaining = [i for j, i in enumerate(ideas) if j != index - 1]
|
|
116
|
+
if remaining:
|
|
117
|
+
new_section = '\n'.join(_render_idea_line(i) for i in remaining)
|
|
118
|
+
else:
|
|
119
|
+
new_section = ''
|
|
120
|
+
body = set_section(body, 'Ideas', new_section)
|
|
121
|
+
body = append_log_entry(
|
|
122
|
+
body,
|
|
123
|
+
LogEntry(date=today_date, event='idea-promoted', detail=f'{idea.text} -> {ws.id}'),
|
|
124
|
+
)
|
|
125
|
+
meta['updated'] = now_dt
|
|
126
|
+
meta['last_activity'] = now_dt
|
|
127
|
+
ws.source_path.write_text(
|
|
128
|
+
write_frontmatter(meta, body).rstrip('\n') + '\n',
|
|
129
|
+
encoding='utf-8',
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Create the new sub-workstream.
|
|
133
|
+
new_ws = Workstream(
|
|
134
|
+
id=generate_id(),
|
|
135
|
+
title=idea.text[:80],
|
|
136
|
+
status='active',
|
|
137
|
+
size='day',
|
|
138
|
+
parent=ws.id,
|
|
139
|
+
created=today_date,
|
|
140
|
+
updated=today_date,
|
|
141
|
+
last_activity=today_date,
|
|
142
|
+
)
|
|
143
|
+
new_ws.log.append(LogEntry(
|
|
144
|
+
date=today_date, event='created',
|
|
145
|
+
detail=f'(promoted from idea on {ws.title})',
|
|
146
|
+
))
|
|
147
|
+
ws_dir = config.workstreams_path
|
|
148
|
+
save_workstream(new_ws, ws_dir / new_ws.filename)
|
|
149
|
+
print(f'Promoted idea to workstream: {new_ws.filename}')
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_idea_command() -> Command:
|
|
153
|
+
return Command(
|
|
154
|
+
_idea_handler, name='idea',
|
|
155
|
+
doc='Add an idea to a workstream.', posargs=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_ideas_command() -> Command:
|
|
160
|
+
cmd = Command(
|
|
161
|
+
_ideas_handler, name='ideas',
|
|
162
|
+
doc='List ideas across all workstreams.',
|
|
163
|
+
)
|
|
164
|
+
cmd.add('--all', parse_as=True, doc='Include snoozed ideas')
|
|
165
|
+
return cmd
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_promote_command() -> Command:
|
|
169
|
+
return Command(
|
|
170
|
+
_promote_handler, name='promote',
|
|
171
|
+
doc='Promote an idea to a sub-workstream.', posargs=True,
|
|
172
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""ws index — generate an index.md for the workstreams directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from face import Command
|
|
8
|
+
|
|
9
|
+
from workstream.config import Config
|
|
10
|
+
from workstream.cli import load_all_workstreams
|
|
11
|
+
from workstream.models import STATUSES, Workstream
|
|
12
|
+
from workstream.commands.tree import build_tree_lines
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_index(workstreams: list[Workstream]) -> str:
|
|
16
|
+
"""Build the index.md content from a list of workstreams."""
|
|
17
|
+
today = datetime.now().strftime('%Y-%m-%d')
|
|
18
|
+
lines: list[str] = [
|
|
19
|
+
'# Workstream Index',
|
|
20
|
+
'',
|
|
21
|
+
f'*Generated by `ws index` on {today}*',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Group by status, preserving STATUSES order
|
|
25
|
+
by_status: dict[str, list[Workstream]] = {}
|
|
26
|
+
for ws in workstreams:
|
|
27
|
+
by_status.setdefault(ws.status, []).append(ws)
|
|
28
|
+
|
|
29
|
+
for status in STATUSES:
|
|
30
|
+
group = by_status.get(status)
|
|
31
|
+
if not group:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
lines.append('')
|
|
35
|
+
lines.append(f'## {status.capitalize()}')
|
|
36
|
+
|
|
37
|
+
if status == 'snoozed':
|
|
38
|
+
lines.append('| ID | Title | Snooze Reason | Last Updated | Days Idle |')
|
|
39
|
+
lines.append('|----|-------|---------------|--------------|-----------|')
|
|
40
|
+
for ws in group:
|
|
41
|
+
idle = ws.days_idle()
|
|
42
|
+
lines.append(
|
|
43
|
+
f'| {ws.id} | {ws.title} | {ws.snooze_reason} '
|
|
44
|
+
f'| {ws.computed_last_activity} | {idle} |'
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
lines.append('| ID | Title | Size | Tags | Last Updated | Days Idle |')
|
|
48
|
+
lines.append('|----|-------|------|------|--------------|-----------|')
|
|
49
|
+
for ws in group:
|
|
50
|
+
tags = ', '.join(ws.tags) if ws.tags else ''
|
|
51
|
+
idle = ws.days_idle()
|
|
52
|
+
lines.append(
|
|
53
|
+
f'| {ws.id} | {ws.title} | {ws.size} '
|
|
54
|
+
f'| {tags} | {ws.computed_last_activity} | {idle} |'
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
lines.append('')
|
|
59
|
+
lines.append('## Hierarchy')
|
|
60
|
+
lines.append('')
|
|
61
|
+
lines.append('```')
|
|
62
|
+
tree_lines = build_tree_lines(workstreams, show_all=True)
|
|
63
|
+
lines.extend(tree_lines)
|
|
64
|
+
lines.append('```')
|
|
65
|
+
|
|
66
|
+
lines.append('')
|
|
67
|
+
return '\n'.join(lines)
|
|
68
|
+
|
|
69
|
+
def _index_handler(config: Config, html: bool = False) -> None:
|
|
70
|
+
ws_dir = config.workstreams_path
|
|
71
|
+
workstreams = load_all_workstreams(config.workstreams_path)
|
|
72
|
+
|
|
73
|
+
# Always generate markdown index
|
|
74
|
+
content = _build_index(workstreams)
|
|
75
|
+
index_path = ws_dir / 'index.md'
|
|
76
|
+
index_path.write_text(content, encoding='utf-8')
|
|
77
|
+
print(f'Wrote index.md ({len(workstreams)} workstreams)')
|
|
78
|
+
|
|
79
|
+
# Optionally generate HTML dashboard
|
|
80
|
+
if html:
|
|
81
|
+
from workstream.web import generate_dashboard_file
|
|
82
|
+
out_path = generate_dashboard_file(ws_dir, workstreams)
|
|
83
|
+
print(f'Wrote {out_path.name}')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_command() -> Command:
|
|
87
|
+
cmd = Command(_index_handler, name='index', doc='Generate index (and optional HTML dashboard) for workstreams.')
|
|
88
|
+
cmd.add('--html', parse_as=True, doc='Also generate an HTML dashboard')
|
|
89
|
+
return cmd
|