workforge 2.4.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.
workforge/cli.py ADDED
@@ -0,0 +1,733 @@
1
+ import asyncio
2
+ import json
3
+ import re
4
+ import unicodedata
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+ import yaml
10
+
11
+ from workforge.config import WorkspaceRuntime, load_workspace
12
+ from workforge.core.parser import parse_markdown_requirements
13
+ from workforge.models import CreatedItem, ItemStatus, Requirement
14
+ from workforge.providers.registry import build_provider
15
+
16
+ app = typer.Typer(no_args_is_help=True)
17
+ providers_app = typer.Typer(no_args_is_help=True)
18
+ app.add_typer(providers_app, name="providers")
19
+
20
+
21
+ @app.command()
22
+ def init(
23
+ workspace: Path = typer.Argument(Path(".workforge"), help="Workspace directory to create."),
24
+ name: str | None = typer.Option(None, "--name", "-n", help="Workspace name. Defaults to the directory name."),
25
+ provider: str = typer.Option("trello", "--provider", "-p", help="Default planning provider."),
26
+ namespace: str | None = typer.Option(None, "--namespace", help="Default requirement namespace."),
27
+ force: bool = typer.Option(False, "--force", help="Overwrite existing WorkForge scaffold files."),
28
+ print_gitignore: bool = typer.Option(
29
+ True,
30
+ "--print-gitignore/--no-print-gitignore",
31
+ help="Print recommended .gitignore entries for project-local workspaces.",
32
+ ),
33
+ ) -> None:
34
+ created = _init_workspace(workspace, name, provider, namespace, force)
35
+ for path in created:
36
+ typer.echo(f"Created {path}")
37
+
38
+ if print_gitignore:
39
+ typer.echo("")
40
+ typer.echo("Recommended .gitignore entries:")
41
+ typer.echo(_project_workspace_gitignore_block(workspace))
42
+
43
+
44
+ @app.command()
45
+ def preview(
46
+ input_file: Path,
47
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
48
+ save: bool = typer.Option(False, "--save", help="Save preview output under workspace output/<input-name>/preview.json."),
49
+ ) -> None:
50
+ runtime = load_workspace(workspace)
51
+ requirements = parse_markdown_requirements(input_file.read_text(), runtime.config)
52
+ output = [item.model_dump() for item in requirements]
53
+ _echo_json(output)
54
+ if save:
55
+ _save_output(runtime.path, input_file, "preview.json", output)
56
+
57
+
58
+ @app.command()
59
+ def create(
60
+ input_file: Path,
61
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
62
+ provider: str | None = typer.Option(None, "--provider", "-p"),
63
+ dry_run: bool = typer.Option(False, "--dry-run"),
64
+ execute: bool = typer.Option(False, "--execute", help="Create external provider items even when workspace dry_run is true."),
65
+ save: bool = typer.Option(False, "--save", help="Save output under workspace output/<input-name>/."),
66
+ ) -> None:
67
+ asyncio.run(_create(input_file, workspace, provider, dry_run, execute, save))
68
+
69
+
70
+ @app.command()
71
+ def update(
72
+ input_file: Path,
73
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
74
+ provider: str | None = typer.Option(None, "--provider", "-p"),
75
+ execute: bool = typer.Option(False, "--execute", help="Update task lists in the external provider."),
76
+ save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after updating."),
77
+ ) -> None:
78
+ asyncio.run(_update(input_file, workspace, provider, execute, save))
79
+
80
+
81
+ @app.command()
82
+ def discover(
83
+ input_file: Path | None = typer.Argument(
84
+ None,
85
+ help="Optional input file used to choose output/<input-name>/items.json.",
86
+ ),
87
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
88
+ provider: str | None = typer.Option(None, "--provider", "-p"),
89
+ label: str | None = typer.Option(None, "--label", "-l", help="Provider label name or ID used to filter items."),
90
+ assignee: str | None = typer.Option(None, "--assignee", "-a", help="Provider username or @me used to filter items."),
91
+ status: str | None = typer.Option(None, "--status", "-s", help="Provider status name or configured alias used to filter items."),
92
+ output_name: str = typer.Option(
93
+ "discovered",
94
+ "--output-name",
95
+ help="Output directory name when INPUT_FILE is omitted.",
96
+ ),
97
+ save: bool = typer.Option(False, "--save", help="Save discovered items as items.json."),
98
+ ) -> None:
99
+ asyncio.run(_discover(input_file, workspace, provider, label, assignee, status, output_name, save))
100
+
101
+
102
+ @app.command()
103
+ def status(
104
+ input_file: Path,
105
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
106
+ provider: str | None = typer.Option(None, "--provider", "-p"),
107
+ save: bool = typer.Option(False, "--save", help="Save status output under workspace output/<input-name>/status.json."),
108
+ ) -> None:
109
+ asyncio.run(_status(input_file, workspace, provider, save))
110
+
111
+
112
+ @app.command("agent-context")
113
+ def agent_context(
114
+ input_file: Path,
115
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
116
+ provider: str | None = typer.Option(None, "--provider", "-p"),
117
+ save: bool = typer.Option(False, "--save", help="Save agent context under workspace output/<input-name>/agent-context.md."),
118
+ ) -> None:
119
+ asyncio.run(_agent_context(input_file, workspace, provider, save))
120
+
121
+
122
+ @app.command("item-context")
123
+ def item_context(
124
+ input_file: Path,
125
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
126
+ item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
127
+ provider: str | None = typer.Option(None, "--provider", "-p"),
128
+ save: bool = typer.Option(False, "--save", help="Save focused item context under workspace output/<input-name>/items/."),
129
+ ) -> None:
130
+ asyncio.run(_item_context(input_file, workspace, item, provider, save))
131
+
132
+
133
+ @app.command("complete-task")
134
+ def complete_task(
135
+ input_file: Path,
136
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
137
+ item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
138
+ task: str = typer.Option(..., "--task", "-t", help="Task title, task ID, or unique title substring."),
139
+ provider: str | None = typer.Option(None, "--provider", "-p"),
140
+ save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after completion."),
141
+ ) -> None:
142
+ asyncio.run(_complete_task(input_file, workspace, item, task, provider, save))
143
+
144
+
145
+ @app.command("comment-item")
146
+ def comment_item(
147
+ input_file: Path,
148
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
149
+ item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
150
+ text: str = typer.Option(..., "--text", "-t", help="Comment text to add to the item."),
151
+ provider: str | None = typer.Option(None, "--provider", "-p"),
152
+ save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after commenting."),
153
+ ) -> None:
154
+ asyncio.run(_comment_item(input_file, workspace, item, text, provider, save))
155
+
156
+
157
+ @app.command("move-item")
158
+ def move_item(
159
+ input_file: Path,
160
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
161
+ item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
162
+ status: str = typer.Option(..., "--status", "-s", help="Destination status name or configured alias."),
163
+ provider: str | None = typer.Option(None, "--provider", "-p"),
164
+ save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after moving."),
165
+ ) -> None:
166
+ asyncio.run(_move_item(input_file, workspace, item, status, provider, save))
167
+
168
+
169
+ @app.command("claim-item")
170
+ def claim_item(
171
+ input_file: Path,
172
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
173
+ item: str = typer.Option(..., "--item", "-i", help="Item title, item ID, or unique title substring."),
174
+ assignee: str = typer.Option("@me", "--assignee", "-a", help="Provider username or @me."),
175
+ provider: str | None = typer.Option(None, "--provider", "-p"),
176
+ save: bool = typer.Option(True, "--save/--no-save", help="Refresh status.json and agent-context.md after assignment."),
177
+ ) -> None:
178
+ asyncio.run(_claim_item(input_file, workspace, item, assignee, provider, save))
179
+
180
+
181
+ @providers_app.command("test")
182
+ def test_provider(
183
+ workspace: Path = typer.Option(..., "--workspace", "-w"),
184
+ provider: str | None = typer.Option(None, "--provider", "-p"),
185
+ ) -> None:
186
+ asyncio.run(_test_provider(workspace, provider))
187
+
188
+
189
+ async def _create(
190
+ input_file: Path,
191
+ workspace: Path,
192
+ provider_name: str | None,
193
+ dry_run: bool,
194
+ execute: bool,
195
+ save: bool,
196
+ ) -> None:
197
+ runtime = load_workspace(workspace)
198
+ config = runtime.config
199
+ requirements = parse_markdown_requirements(input_file.read_text(), config)
200
+
201
+ if dry_run or (config.defaults.dry_run and not execute):
202
+ output = [item.model_dump() for item in requirements]
203
+ _echo_json(output)
204
+ if save:
205
+ _save_output(runtime.path, input_file, "preview.json", output)
206
+ return
207
+
208
+ selected_provider = provider_name or config.default_provider
209
+ provider_config = config.providers.get(selected_provider, {})
210
+ provider = build_provider(selected_provider, provider_config, runtime.env)
211
+
212
+ created = []
213
+ for requirement in requirements:
214
+ created.append((await provider.create_requirement(requirement)).model_dump())
215
+
216
+ _echo_json(created)
217
+ if save:
218
+ _save_output(runtime.path, input_file, "items.json", created)
219
+
220
+
221
+ async def _update(
222
+ input_file: Path,
223
+ workspace: Path,
224
+ provider_name: str | None,
225
+ execute: bool,
226
+ save: bool,
227
+ ) -> None:
228
+ runtime = load_workspace(workspace)
229
+ selected_provider = provider_name or runtime.config.default_provider
230
+ requirements = parse_markdown_requirements(input_file.read_text(), runtime.config)
231
+ items = _load_created_items(runtime.path, input_file, selected_provider)
232
+ matches = [(_find_created_item_by_title(items, requirement.title), requirement) for requirement in requirements]
233
+
234
+ if not execute:
235
+ _echo_json([_task_update_preview(item, requirement) for item, requirement in matches])
236
+ return
237
+
238
+ provider_config = runtime.config.providers.get(selected_provider, {})
239
+ provider = build_provider(selected_provider, provider_config, runtime.env)
240
+ statuses = [await provider.update_requirement_tasks(item, requirement) for item, requirement in matches]
241
+ _echo_json([status.model_dump() for status in statuses])
242
+ if save:
243
+ await _refresh_saved_context(runtime, input_file, provider_name)
244
+
245
+
246
+ def _task_update_preview(item: CreatedItem, requirement: Requirement) -> dict[str, Any]:
247
+ return {
248
+ "provider": item.provider,
249
+ "id": item.id,
250
+ "title": requirement.title,
251
+ "tasks": [task.model_dump() for task in requirement.tasks],
252
+ }
253
+
254
+
255
+ async def _test_provider(workspace: Path, provider_name: str | None) -> None:
256
+ runtime = load_workspace(workspace)
257
+ config = runtime.config
258
+ selected_provider = provider_name or config.default_provider
259
+ provider_config = config.providers.get(selected_provider, {})
260
+ provider = build_provider(selected_provider, provider_config, runtime.env)
261
+ result = await provider.check()
262
+ typer.echo(result.model_dump_json(indent=2))
263
+
264
+
265
+ async def _discover(
266
+ input_file: Path | None,
267
+ workspace: Path,
268
+ provider_name: str | None,
269
+ label: str | None,
270
+ assignee: str | None,
271
+ status: str | None,
272
+ output_name: str,
273
+ save: bool,
274
+ ) -> None:
275
+ runtime = load_workspace(workspace)
276
+ selected_provider = provider_name or runtime.config.default_provider
277
+ provider_config = runtime.config.providers.get(selected_provider, {})
278
+ provider = build_provider(selected_provider, provider_config, runtime.env)
279
+ discovered = [item.model_dump() for item in await provider.discover_items(label, assignee, status)]
280
+ _echo_json(discovered)
281
+
282
+ if save:
283
+ _save_output(runtime.path, _output_ref_for(input_file, output_name), "items.json", discovered)
284
+
285
+
286
+ async def _status(input_file: Path, workspace: Path, provider_name: str | None, save: bool) -> None:
287
+ runtime = load_workspace(workspace)
288
+ statuses = await _load_item_statuses(runtime, input_file, provider_name)
289
+ output = [status.model_dump() for status in statuses]
290
+ _echo_json(output)
291
+ if save:
292
+ _save_output(runtime.path, input_file, "status.json", output)
293
+
294
+
295
+ async def _agent_context(input_file: Path, workspace: Path, provider_name: str | None, save: bool) -> None:
296
+ runtime = load_workspace(workspace)
297
+ statuses = await _load_item_statuses(runtime, input_file, provider_name)
298
+ output = _build_agent_context(statuses)
299
+ typer.echo(output)
300
+ if save:
301
+ _save_text_output(runtime.path, input_file, "agent-context.md", output)
302
+
303
+
304
+ async def _item_context(
305
+ input_file: Path,
306
+ workspace: Path,
307
+ item_ref: str,
308
+ provider_name: str | None,
309
+ save: bool,
310
+ ) -> None:
311
+ runtime = load_workspace(workspace)
312
+ statuses = await _load_item_statuses(runtime, input_file, provider_name)
313
+ status = _find_item_status(statuses, item_ref)
314
+ output = _build_item_context(status)
315
+ typer.echo(output)
316
+ if save:
317
+ output_path = _item_context_path(runtime.path, input_file, status)
318
+ _save_text_path(output_path, output)
319
+
320
+
321
+ async def _complete_task(
322
+ input_file: Path,
323
+ workspace: Path,
324
+ item_ref: str,
325
+ task_ref: str,
326
+ provider_name: str | None,
327
+ save: bool,
328
+ ) -> None:
329
+ runtime = load_workspace(workspace)
330
+ selected_provider = provider_name or runtime.config.default_provider
331
+ provider_config = runtime.config.providers.get(selected_provider, {})
332
+ provider = build_provider(selected_provider, provider_config, runtime.env)
333
+ created_items = _load_created_items(runtime.path, input_file, selected_provider)
334
+ item = _find_created_item(created_items, item_ref)
335
+ updated_status = await provider.complete_task(item, task_ref)
336
+
337
+ _echo_json(updated_status.model_dump())
338
+
339
+ if save:
340
+ statuses = await _load_item_statuses(runtime, input_file, provider_name)
341
+ status_output = [status.model_dump() for status in statuses]
342
+ _save_output(runtime.path, input_file, "status.json", status_output)
343
+ _save_text_output(runtime.path, input_file, "agent-context.md", _build_agent_context(statuses))
344
+
345
+
346
+ async def _comment_item(
347
+ input_file: Path,
348
+ workspace: Path,
349
+ item_ref: str,
350
+ text: str,
351
+ provider_name: str | None,
352
+ save: bool,
353
+ ) -> None:
354
+ runtime = load_workspace(workspace)
355
+ selected_provider = provider_name or runtime.config.default_provider
356
+ provider_config = runtime.config.providers.get(selected_provider, {})
357
+ provider = build_provider(selected_provider, provider_config, runtime.env)
358
+ created_items = _load_created_items(runtime.path, input_file, selected_provider)
359
+ item = _find_created_item(created_items, item_ref)
360
+ updated_status = await provider.comment_item(item, text)
361
+
362
+ _echo_json(updated_status.model_dump())
363
+
364
+ if save:
365
+ await _refresh_saved_context(runtime, input_file, provider_name)
366
+
367
+
368
+ async def _move_item(
369
+ input_file: Path,
370
+ workspace: Path,
371
+ item_ref: str,
372
+ status_ref: str,
373
+ provider_name: str | None,
374
+ save: bool,
375
+ ) -> None:
376
+ runtime = load_workspace(workspace)
377
+ selected_provider = provider_name or runtime.config.default_provider
378
+ provider_config = runtime.config.providers.get(selected_provider, {})
379
+ provider = build_provider(selected_provider, provider_config, runtime.env)
380
+ created_items = _load_created_items(runtime.path, input_file, selected_provider)
381
+ item = _find_created_item(created_items, item_ref)
382
+ updated_status = await provider.move_item(item, status_ref)
383
+
384
+ _echo_json(updated_status.model_dump())
385
+
386
+ if save:
387
+ await _refresh_saved_context(runtime, input_file, provider_name)
388
+
389
+
390
+ async def _claim_item(
391
+ input_file: Path,
392
+ workspace: Path,
393
+ item_ref: str,
394
+ assignee_ref: str,
395
+ provider_name: str | None,
396
+ save: bool,
397
+ ) -> None:
398
+ runtime = load_workspace(workspace)
399
+ selected_provider = provider_name or runtime.config.default_provider
400
+ provider_config = runtime.config.providers.get(selected_provider, {})
401
+ provider = build_provider(selected_provider, provider_config, runtime.env)
402
+ item = _find_created_item(_load_created_items(runtime.path, input_file, selected_provider), item_ref)
403
+ updated_status = await provider.claim_item(item, assignee_ref)
404
+
405
+ _echo_json(updated_status.model_dump())
406
+ if save:
407
+ await _refresh_saved_context(runtime, input_file, provider_name)
408
+
409
+
410
+ async def _refresh_saved_context(
411
+ runtime: WorkspaceRuntime,
412
+ input_file: Path,
413
+ provider_name: str | None,
414
+ ) -> None:
415
+ statuses = await _load_item_statuses(runtime, input_file, provider_name)
416
+ status_output = [status.model_dump() for status in statuses]
417
+ _save_output(runtime.path, input_file, "status.json", status_output)
418
+ _save_text_output(runtime.path, input_file, "agent-context.md", _build_agent_context(statuses))
419
+
420
+
421
+ async def _load_item_statuses(
422
+ runtime: WorkspaceRuntime,
423
+ input_file: Path,
424
+ provider_name: str | None,
425
+ ) -> list[ItemStatus]:
426
+ selected_provider = provider_name or runtime.config.default_provider
427
+ provider_config = runtime.config.providers.get(selected_provider, {})
428
+ provider = build_provider(selected_provider, provider_config, runtime.env)
429
+ created_items = _load_created_items(runtime.path, input_file, selected_provider)
430
+ return [await provider.get_item_status(item) for item in created_items]
431
+
432
+
433
+ def _load_created_items(workspace_path: Path, input_file: Path, provider_name: str) -> list[CreatedItem]:
434
+ items_path = _output_dir_for(workspace_path, input_file) / "items.json"
435
+ if not items_path.exists():
436
+ raise FileNotFoundError(f"Created items output not found: {items_path}")
437
+
438
+ raw_items = json.loads(items_path.read_text())
439
+ return [
440
+ CreatedItem.model_validate(item)
441
+ for item in raw_items
442
+ if item.get("provider") == provider_name
443
+ ]
444
+
445
+
446
+ def _find_created_item(items: list[CreatedItem], item_ref: str) -> CreatedItem:
447
+ exact_matches = [
448
+ item
449
+ for item in items
450
+ if item.id == item_ref or item.title.casefold() == item_ref.casefold()
451
+ ]
452
+ if len(exact_matches) == 1:
453
+ return exact_matches[0]
454
+ if len(exact_matches) > 1:
455
+ raise ValueError(f"Multiple items matched exactly: {item_ref}")
456
+
457
+ partial_matches = [
458
+ item
459
+ for item in items
460
+ if item_ref.casefold() in item.title.casefold()
461
+ ]
462
+ if len(partial_matches) == 1:
463
+ return partial_matches[0]
464
+ if len(partial_matches) > 1:
465
+ titles = ", ".join(item.title for item in partial_matches)
466
+ raise ValueError(f"Multiple items matched '{item_ref}': {titles}")
467
+
468
+ raise ValueError(f"Item not found: {item_ref}")
469
+
470
+
471
+ def _find_created_item_by_title(items: list[CreatedItem], title: str) -> CreatedItem:
472
+ matches = [item for item in items if item.title.casefold() == title.casefold()]
473
+ if len(matches) == 1:
474
+ return matches[0]
475
+ if len(matches) > 1:
476
+ raise ValueError(f"Multiple items matched title: {title}")
477
+ raise ValueError(f"Item title not found: {title}")
478
+
479
+
480
+ def _find_item_status(statuses: list[ItemStatus], item_ref: str) -> ItemStatus:
481
+ exact_matches = [
482
+ status
483
+ for status in statuses
484
+ if status.id == item_ref or status.title.casefold() == item_ref.casefold()
485
+ ]
486
+ if len(exact_matches) == 1:
487
+ return exact_matches[0]
488
+ if len(exact_matches) > 1:
489
+ raise ValueError(f"Multiple items matched exactly: {item_ref}")
490
+
491
+ partial_matches = [
492
+ status
493
+ for status in statuses
494
+ if item_ref.casefold() in status.title.casefold()
495
+ ]
496
+ if len(partial_matches) == 1:
497
+ return partial_matches[0]
498
+ if len(partial_matches) > 1:
499
+ titles = ", ".join(status.title for status in partial_matches)
500
+ raise ValueError(f"Multiple items matched '{item_ref}': {titles}")
501
+
502
+ raise ValueError(f"Item not found: {item_ref}")
503
+
504
+
505
+ def _build_agent_context(statuses: list[ItemStatus]) -> str:
506
+ lines = ["# WorkForge Agent Context", ""]
507
+
508
+ for status in statuses:
509
+ total_tasks = len(status.tasks)
510
+ pending_tasks = [task for task in status.tasks if not task.done]
511
+ completed_tasks = [task for task in status.tasks if task.done]
512
+ lines.extend(
513
+ [
514
+ f"## {status.title}",
515
+ "",
516
+ f"Provider: {status.provider}",
517
+ f"Item ID: {status.id}",
518
+ f"URL: {status.url or ''}",
519
+ f"Item closed: {status.closed}",
520
+ f"Progress: {len(completed_tasks)}/{total_tasks} tasks complete",
521
+ "",
522
+ "### Pending Tasks",
523
+ "",
524
+ ]
525
+ )
526
+
527
+ if pending_tasks:
528
+ lines.extend(f"- {task.title}" for task in pending_tasks)
529
+ else:
530
+ lines.append("- None")
531
+
532
+ lines.extend(["", "### Completed Tasks", ""])
533
+ if completed_tasks:
534
+ lines.extend(f"- {task.title}" for task in completed_tasks)
535
+ else:
536
+ lines.append("- None")
537
+ lines.append("")
538
+
539
+ return "\n".join(lines).rstrip() + "\n"
540
+
541
+
542
+ def _build_item_context(status: ItemStatus) -> str:
543
+ total_tasks = len(status.tasks)
544
+ pending_tasks = [task for task in status.tasks if not task.done]
545
+ completed_tasks = [task for task in status.tasks if task.done]
546
+ lines = [
547
+ f"# {status.title}",
548
+ "",
549
+ f"Provider: {status.provider}",
550
+ f"Item ID: {status.id}",
551
+ f"URL: {status.url or ''}",
552
+ f"Item closed: {status.closed}",
553
+ f"Progress: {len(completed_tasks)}/{total_tasks} tasks complete",
554
+ "",
555
+ "## Implementation Focus",
556
+ "",
557
+ "Work only on this item unless the implementation requires a small supporting change.",
558
+ "Prefer completing one pending task at a time, then run the relevant checks before marking it done.",
559
+ "",
560
+ "## Pending Tasks",
561
+ "",
562
+ ]
563
+
564
+ if pending_tasks:
565
+ lines.extend(_format_task_line(task) for task in pending_tasks)
566
+ else:
567
+ lines.append("- None")
568
+
569
+ lines.extend(["", "## Completed Tasks", ""])
570
+ if completed_tasks:
571
+ lines.extend(_format_task_line(task) for task in completed_tasks)
572
+ else:
573
+ lines.append("- None")
574
+
575
+ lines.extend(
576
+ [
577
+ "",
578
+ "## Completion Command",
579
+ "",
580
+ "After implementing and verifying a task, mark it complete with:",
581
+ "",
582
+ "```bash",
583
+ "workforge complete-task <input-file> --workspace <workspace> "
584
+ f"--item \"{status.id}\" --task \"<task-id-or-title>\"",
585
+ "```",
586
+ ]
587
+ )
588
+
589
+ return "\n".join(lines).rstrip() + "\n"
590
+
591
+
592
+ def _format_task_line(task: Any) -> str:
593
+ suffix = f" (`{task.id}`)" if task.id else ""
594
+ return f"- {task.title}{suffix}"
595
+
596
+
597
+ def _output_dir_for(workspace_path: Path, input_file: Path) -> Path:
598
+ return workspace_path / "output" / input_file.stem
599
+
600
+
601
+ def _item_context_path(workspace_path: Path, input_file: Path, status: ItemStatus) -> Path:
602
+ return _output_dir_for(workspace_path, input_file) / "items" / f"{_slugify(status.title)}.md"
603
+
604
+
605
+ def _slugify(value: str) -> str:
606
+ normalized = unicodedata.normalize("NFKD", value.casefold())
607
+ normalized = "".join(character for character in normalized if not unicodedata.combining(character))
608
+ normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
609
+ normalized = normalized.strip("-")
610
+ return normalized or "item"
611
+
612
+
613
+ def _output_ref_for(input_file: Path | None, output_name: str) -> Path:
614
+ return input_file or Path(output_name)
615
+
616
+
617
+ def _init_workspace(
618
+ workspace_path: Path,
619
+ name: str | None,
620
+ provider: str,
621
+ namespace: str | None,
622
+ force: bool,
623
+ ) -> list[Path]:
624
+ workspace_name = name or workspace_path.name.removeprefix(".") or "workspace"
625
+ default_namespace = namespace or workspace_name
626
+ created: list[Path] = []
627
+
628
+ for directory in [workspace_path, workspace_path / "inbox", workspace_path / "output"]:
629
+ directory.mkdir(parents=True, exist_ok=True)
630
+
631
+ files = {
632
+ workspace_path / "workforge.yaml": _workspace_config_template(workspace_name, provider, default_namespace),
633
+ workspace_path / ".env.example": _env_example_template(provider),
634
+ workspace_path / "output" / ".gitkeep": "",
635
+ }
636
+
637
+ for path, content in files.items():
638
+ if path.exists() and not force:
639
+ continue
640
+
641
+ path.write_text(content)
642
+ created.append(path)
643
+
644
+ return created
645
+
646
+
647
+ def _workspace_config_template(name: str, provider: str, namespace: str) -> str:
648
+ provider_config: dict[str, Any]
649
+ if provider == "trello":
650
+ provider_config = {
651
+ "list_id": "replace-with-trello-list-id",
652
+ "lists": {
653
+ "todo": "replace-with-trello-todo-list-id",
654
+ "doing": "replace-with-trello-doing-list-id",
655
+ "done": "replace-with-trello-done-list-id",
656
+ },
657
+ "labels": {},
658
+ }
659
+ elif provider == "github":
660
+ provider_config = {
661
+ "owner": "replace-with-github-owner",
662
+ "repository": "replace-with-repository",
663
+ "project_number": 1,
664
+ "labels": {},
665
+ "milestones": {},
666
+ "status": {
667
+ "field": "Status",
668
+ "values": {
669
+ "todo": "Todo",
670
+ "doing": "In Progress",
671
+ "done": "Done",
672
+ },
673
+ },
674
+ }
675
+ else:
676
+ provider_config = {}
677
+
678
+ payload = {
679
+ "name": name,
680
+ "default_provider": provider,
681
+ "providers": {
682
+ provider: provider_config,
683
+ },
684
+ "defaults": {
685
+ "source": "manual",
686
+ "namespace": namespace,
687
+ "dry_run": True,
688
+ },
689
+ }
690
+ return yaml.safe_dump(payload, sort_keys=False)
691
+
692
+
693
+ def _env_example_template(provider: str) -> str:
694
+ if provider == "trello":
695
+ return "TRELLO_API_KEY=\nTRELLO_API_TOKEN=\n"
696
+ if provider == "github":
697
+ return "GITHUB_TOKEN=\n"
698
+
699
+ return ""
700
+
701
+
702
+ def _project_workspace_gitignore_block(workspace_path: Path) -> str:
703
+ workspace_ref = workspace_path.name if workspace_path.is_absolute() else workspace_path.as_posix().rstrip("/")
704
+ return f"{workspace_ref}/.env\n{workspace_ref}/output/\n"
705
+
706
+
707
+ def _save_output(workspace_path: Path, input_file: Path, filename: str, payload: Any) -> Path:
708
+ output_dir = _output_dir_for(workspace_path, input_file)
709
+ output_dir.mkdir(parents=True, exist_ok=True)
710
+ output_path = output_dir / filename
711
+ output_path.write_text(json.dumps(payload, indent=2) + "\n")
712
+ typer.echo(f"Saved {output_path}")
713
+ return output_path
714
+
715
+
716
+ def _save_text_output(workspace_path: Path, input_file: Path, filename: str, payload: str) -> Path:
717
+ output_dir = _output_dir_for(workspace_path, input_file)
718
+ output_dir.mkdir(parents=True, exist_ok=True)
719
+ output_path = output_dir / filename
720
+ output_path.write_text(payload)
721
+ typer.echo(f"Saved {output_path}")
722
+ return output_path
723
+
724
+
725
+ def _save_text_path(output_path: Path, payload: str) -> Path:
726
+ output_path.parent.mkdir(parents=True, exist_ok=True)
727
+ output_path.write_text(payload)
728
+ typer.echo(f"Saved {output_path}")
729
+ return output_path
730
+
731
+
732
+ def _echo_json(payload: Any) -> None:
733
+ typer.echo(json.dumps(payload, indent=2))