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,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