ventilatepro-cli 0.5.0__tar.gz → 0.5.2__tar.gz

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 (35) hide show
  1. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/PKG-INFO +17 -1
  2. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/README.md +16 -0
  3. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/tests/test_cli.py +261 -0
  4. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/tests/test_mcp_server.py +130 -0
  5. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/__init__.py +1 -1
  6. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/api_client.py +44 -0
  7. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/cli.py +226 -1
  8. ventilatepro_cli-0.5.2/ventilatepro/decisions.py +178 -0
  9. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/mcp_server.py +98 -0
  10. ventilatepro_cli-0.5.2/ventilatepro/tasks.py +58 -0
  11. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/PKG-INFO +17 -1
  12. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/SOURCES.txt +2 -0
  13. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/pyproject.toml +0 -0
  14. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/setup.cfg +0 -0
  15. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/tests/test_config.py +0 -0
  16. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/__main__.py +0 -0
  17. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/auth.py +0 -0
  18. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/calc.py +0 -0
  19. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/categorization.py +0 -0
  20. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/config.py +0 -0
  21. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/design_day.py +0 -0
  22. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/entities.py +0 -0
  23. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/mcp_cli.py +0 -0
  24. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/meetings.py +0 -0
  25. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/notes.py +0 -0
  26. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/output.py +0 -0
  27. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/payloads.py +0 -0
  28. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/projects.py +0 -0
  29. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/queue.py +0 -0
  30. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/revit_imports.py +0 -0
  31. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/systems.py +0 -0
  32. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  33. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
  34. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/requires.txt +0 -0
  35. {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventilatepro-cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: VentilatePro CLI
5
5
  Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
6
  Project-URL: Documentation, https://ventilatepro.com/exam/cli/
@@ -61,6 +61,11 @@ ventilatepro notes sync
61
61
  ventilatepro meetings context --project 1 --json
62
62
  ventilatepro meetings record --project 1 --input meeting.json --json
63
63
  ventilatepro meetings list --project 1
64
+ ventilatepro decisions create --project 1 --text "Use heat recovery on AHU-2" --reason "Energy model payback" --tags ahu,energy
65
+ ventilatepro decisions list --project 1
66
+ ventilatepro decisions revise 51 --text "Use heat recovery with bypass control"
67
+ ventilatepro tasks context --project 1 --json
68
+ ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
64
69
  ventilatepro mcp doctor
65
70
  ventilatepro-mcp
66
71
  ```
@@ -74,6 +79,17 @@ stable project-member IDs, interprets the PM's prose into a structured JSON
74
79
  payload, and calls `meetings record`. The server atomically creates the meeting,
75
80
  decisions, and assigned project tasks and returns a direct web-app link.
76
81
 
82
+ For standalone work, Codex reads `tasks context` to resolve an assignee to a
83
+ stable project-member user ID, then calls `tasks create`. The same workflow is
84
+ available through the typed `vp_get_task_context` and
85
+ `vp_create_project_task` MCP tools.
86
+
87
+ Design decisions do not require meeting minutes. Codex can call `decisions
88
+ create` or `vp_create_decision` as soon as a PM communicates a decision, with
89
+ an optional meeting ID only when the relationship is useful. Listing, showing,
90
+ and revising decisions are available through first-class CLI and MCP surfaces;
91
+ revisions preserve the prior version as immutable history.
92
+
77
93
  For room classification, Codex can read the project-specific categorization
78
94
  review resource or use the dedicated MCP tools. Review manifests default every
79
95
  room to rejected so ambiguous spaces remain visible until a category is
@@ -46,6 +46,11 @@ ventilatepro notes sync
46
46
  ventilatepro meetings context --project 1 --json
47
47
  ventilatepro meetings record --project 1 --input meeting.json --json
48
48
  ventilatepro meetings list --project 1
49
+ ventilatepro decisions create --project 1 --text "Use heat recovery on AHU-2" --reason "Energy model payback" --tags ahu,energy
50
+ ventilatepro decisions list --project 1
51
+ ventilatepro decisions revise 51 --text "Use heat recovery with bypass control"
52
+ ventilatepro tasks context --project 1 --json
53
+ ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
49
54
  ventilatepro mcp doctor
50
55
  ventilatepro-mcp
51
56
  ```
@@ -59,6 +64,17 @@ stable project-member IDs, interprets the PM's prose into a structured JSON
59
64
  payload, and calls `meetings record`. The server atomically creates the meeting,
60
65
  decisions, and assigned project tasks and returns a direct web-app link.
61
66
 
67
+ For standalone work, Codex reads `tasks context` to resolve an assignee to a
68
+ stable project-member user ID, then calls `tasks create`. The same workflow is
69
+ available through the typed `vp_get_task_context` and
70
+ `vp_create_project_task` MCP tools.
71
+
72
+ Design decisions do not require meeting minutes. Codex can call `decisions
73
+ create` or `vp_create_decision` as soon as a PM communicates a decision, with
74
+ an optional meeting ID only when the relationship is useful. Listing, showing,
75
+ and revising decisions are available through first-class CLI and MCP surfaces;
76
+ revisions preserve the prior version as immutable history.
77
+
62
78
  For room classification, Codex can read the project-specific categorization
63
79
  review resource or use the dedicated MCP tools. Review manifests default every
64
80
  room to rejected so ambiguous spaces remain visible until a category is
@@ -362,6 +362,267 @@ class CliTests(unittest.TestCase):
362
362
  self.assertEqual(payload["members"][0]["user_id"], 12)
363
363
  fake_client.meeting_context.assert_called_once_with(1)
364
364
 
365
+ def test_tasks_context_outputs_assignable_directory(self):
366
+ fake_client = mock.Mock()
367
+ fake_client.task_context.return_value = {
368
+ "project": {"id": 1, "name": "Hospital"},
369
+ "members": [
370
+ {
371
+ "user_id": 12,
372
+ "name": "Alex Engineer",
373
+ "email": "alex@example.com",
374
+ "role": "engineer",
375
+ },
376
+ ],
377
+ }
378
+ with mock.patch.object(
379
+ auth,
380
+ "get_authenticated_client",
381
+ return_value=(fake_client, "http://localhost:8000"),
382
+ ):
383
+ result = self.runner.invoke(
384
+ app,
385
+ ["tasks", "context", "--project", "1", "--json"],
386
+ )
387
+
388
+ self.assertEqual(result.exit_code, 0)
389
+ payload = json.loads(result.stdout)
390
+ self.assertEqual(payload["members"][0]["user_id"], 12)
391
+ fake_client.task_context.assert_called_once_with(1)
392
+
393
+ def test_tasks_create_assigns_project_member(self):
394
+ fake_client = mock.Mock()
395
+ fake_client.base_url = "https://ventilatepro.com"
396
+ fake_client.create_project_task.return_value = {
397
+ "id": 46,
398
+ "project": 1,
399
+ "description": "Issue diffuser update",
400
+ "assigned_user": 12,
401
+ "priority": "high",
402
+ "include_in_minutes": False,
403
+ }
404
+ with mock.patch.object(
405
+ auth,
406
+ "get_authenticated_client",
407
+ return_value=(fake_client, "https://ventilatepro.com"),
408
+ ):
409
+ result = self.runner.invoke(
410
+ app,
411
+ [
412
+ "tasks",
413
+ "create",
414
+ "--project",
415
+ "1",
416
+ "--description",
417
+ "Issue diffuser update",
418
+ "--assignee-id",
419
+ "12",
420
+ "--due-at",
421
+ "2026-08-05T17:00:00-07:00",
422
+ "--priority",
423
+ "high",
424
+ "--notes",
425
+ "Coordinate with architecture.",
426
+ "--estimated-hours",
427
+ "2.5",
428
+ "--json",
429
+ ],
430
+ )
431
+
432
+ self.assertEqual(result.exit_code, 0)
433
+ payload = json.loads(result.stdout)
434
+ self.assertEqual(payload["task"]["id"], 46)
435
+ self.assertIn("view=project-tasks", payload["deep_link"])
436
+ sent_payload = fake_client.create_project_task.call_args.args[0]
437
+ self.assertEqual(sent_payload["project"], 1)
438
+ self.assertEqual(sent_payload["assigned_user"], 12)
439
+ self.assertEqual(sent_payload["priority"], "high")
440
+ self.assertFalse(sent_payload["include_in_minutes"])
441
+ self.assertEqual(sent_payload["source"], "cli")
442
+
443
+ def test_decisions_create_logs_standalone_decision_immediately(self):
444
+ fake_client = mock.Mock()
445
+ fake_client.base_url = "https://ventilatepro.com"
446
+ fake_client.create_decision.return_value = {
447
+ "project": 1,
448
+ "created": [{
449
+ "model": "notes.Decision",
450
+ "id": 51,
451
+ "data": {
452
+ "id": 51,
453
+ "project": 1,
454
+ "minute": None,
455
+ "text": "Use heat recovery on AHU-2.",
456
+ "reason": "Energy model payback.",
457
+ "impact": "Add a heat recovery wheel.",
458
+ "code_reference": "ASHRAE 90.1",
459
+ "tags": ["ahu", "energy"],
460
+ "version": 1,
461
+ "is_active": True,
462
+ },
463
+ }],
464
+ "audit_entry_id": 52,
465
+ "idempotent": False,
466
+ }
467
+
468
+ with tempfile.TemporaryDirectory() as tmpdir:
469
+ os.environ[config.DATA_ENV_VAR] = tmpdir
470
+ try:
471
+ with mock.patch.object(
472
+ auth,
473
+ "get_authenticated_client",
474
+ return_value=(fake_client, "https://ventilatepro.com"),
475
+ ):
476
+ result = self.runner.invoke(
477
+ app,
478
+ [
479
+ "decisions",
480
+ "create",
481
+ "--project",
482
+ "1",
483
+ "--text",
484
+ "Use heat recovery on AHU-2.",
485
+ "--reason",
486
+ "Energy model payback.",
487
+ "--impact",
488
+ "Add a heat recovery wheel.",
489
+ "--code-reference",
490
+ "ASHRAE 90.1",
491
+ "--tags",
492
+ "ahu, energy, ahu",
493
+ "--idempotency-key",
494
+ "decision-51",
495
+ "--json",
496
+ ],
497
+ )
498
+ finally:
499
+ os.environ.pop(config.DATA_ENV_VAR, None)
500
+
501
+ self.assertEqual(result.exit_code, 0)
502
+ payload = json.loads(result.stdout)
503
+ self.assertEqual(payload["decision_ref"], "decision:51")
504
+ self.assertIn("view=decision-log", payload["deep_link"])
505
+ sent = fake_client.create_decision.call_args.args[0]
506
+ self.assertEqual(sent["project_id"], 1)
507
+ self.assertEqual(sent["idempotency_key"], "decision-51")
508
+ decision_data = sent["writes"][0]["data"]
509
+ self.assertNotIn("minute", decision_data)
510
+ self.assertEqual(decision_data["references"], ["ahu", "energy"])
511
+ self.assertEqual(decision_data["source"], "cli")
512
+
513
+ def test_decisions_list_and_revise_use_first_class_commands(self):
514
+ fake_client = mock.Mock()
515
+ fake_client.list_decisions.return_value = [{
516
+ "id": 51,
517
+ "text": "Use heat recovery on AHU-2.",
518
+ "version": 1,
519
+ "is_active": True,
520
+ }]
521
+ fake_client.revise_decision.return_value = {
522
+ "id": 54,
523
+ "text": "Use heat recovery on AHU-2 with bypass.",
524
+ "version": 2,
525
+ "supersedes": 51,
526
+ }
527
+ with mock.patch.object(
528
+ auth,
529
+ "get_authenticated_client",
530
+ return_value=(fake_client, "https://ventilatepro.com"),
531
+ ):
532
+ list_result = self.runner.invoke(
533
+ app,
534
+ ["decisions", "list", "--project", "1", "--json"],
535
+ )
536
+ revise_result = self.runner.invoke(
537
+ app,
538
+ [
539
+ "decisions",
540
+ "revise",
541
+ "51",
542
+ "--text",
543
+ "Use heat recovery on AHU-2 with bypass.",
544
+ "--tags",
545
+ "ahu,energy",
546
+ "--json",
547
+ ],
548
+ )
549
+
550
+ self.assertEqual(list_result.exit_code, 0)
551
+ self.assertEqual(json.loads(list_result.stdout)["results"][0]["id"], 51)
552
+ fake_client.list_decisions.assert_called_once_with(
553
+ 1,
554
+ search=None,
555
+ include_history=False,
556
+ )
557
+ self.assertEqual(revise_result.exit_code, 0)
558
+ fake_client.revise_decision.assert_called_once_with(
559
+ 51,
560
+ {
561
+ "text": "Use heat recovery on AHU-2 with bypass.",
562
+ "tags": ["ahu", "energy"],
563
+ },
564
+ )
565
+
566
+ def test_decisions_create_queues_offline_and_syncs_with_same_key(self):
567
+ with tempfile.TemporaryDirectory() as tmpdir:
568
+ os.environ[config.DATA_ENV_VAR] = tmpdir
569
+ try:
570
+ offline_client = mock.Mock()
571
+ offline_client.create_decision.side_effect = NetworkError("offline")
572
+ with mock.patch.object(
573
+ auth,
574
+ "get_authenticated_client",
575
+ return_value=(offline_client, "https://ventilatepro.com"),
576
+ ):
577
+ queued_result = self.runner.invoke(
578
+ app,
579
+ [
580
+ "decisions",
581
+ "create",
582
+ "--project",
583
+ "1",
584
+ "--text",
585
+ "Use heat recovery.",
586
+ "--idempotency-key",
587
+ "offline-decision-1",
588
+ "--json",
589
+ ],
590
+ )
591
+ self.assertEqual(queued_result.exit_code, 0)
592
+ queued_payload = json.loads(queued_result.stdout)
593
+ self.assertEqual(queued_payload["status"], "queued")
594
+
595
+ online_client = mock.Mock()
596
+ online_client.base_url = "https://ventilatepro.com"
597
+ online_client.create_decision.return_value = {
598
+ "created": [{
599
+ "model": "notes.Decision",
600
+ "id": 61,
601
+ "data": {"id": 61, "text": "Use heat recovery."},
602
+ }],
603
+ "audit_entry_id": 62,
604
+ "idempotent": False,
605
+ }
606
+ with mock.patch.object(
607
+ auth,
608
+ "get_authenticated_client",
609
+ return_value=(online_client, "https://ventilatepro.com"),
610
+ ):
611
+ sync_result = self.runner.invoke(
612
+ app,
613
+ ["decisions", "sync", "--project", "1", "--json"],
614
+ )
615
+
616
+ self.assertEqual(sync_result.exit_code, 0)
617
+ self.assertEqual(json.loads(sync_result.stdout)["synced"], 1)
618
+ sent = online_client.create_decision.call_args.args[0]
619
+ self.assertEqual(sent["idempotency_key"], "offline-decision-1")
620
+ operation = QueueStore().get_operation(queued_payload["local_id"])
621
+ self.assertEqual(operation.sync_state, "synced")
622
+ self.assertEqual(operation.server_ref, "decision:61")
623
+ finally:
624
+ os.environ.pop(config.DATA_ENV_VAR, None)
625
+
365
626
  def test_meetings_record_reads_structured_stdin_and_saves_immediately(self):
366
627
  fake_client = mock.Mock()
367
628
  fake_client.record_meeting.return_value = {
@@ -41,6 +41,45 @@ class FakeClient:
41
41
  "deep_link": "https://ventilatepro.com/?view=meeting-minutes&project=7&minute=31",
42
42
  }
43
43
 
44
+ def task_context(self, project_id):
45
+ self.calls.append(("task_context", project_id))
46
+ return {
47
+ "project": {"id": project_id, "name": "Hospital"},
48
+ "members": [{"user_id": 12, "name": "Alex Engineer"}],
49
+ }
50
+
51
+ def create_project_task(self, payload):
52
+ self.calls.append(("create_project_task", payload))
53
+ return {"id": 46, **payload}
54
+
55
+ def create_decision(self, payload):
56
+ self.calls.append(("create_decision", payload))
57
+ return {
58
+ "project": payload["project_id"],
59
+ "created": [{
60
+ "model": "notes.Decision",
61
+ "id": 51,
62
+ "data": {
63
+ "id": 51,
64
+ "project": payload["project_id"],
65
+ "minute": payload["writes"][0]["data"].get("minute"),
66
+ "text": payload["writes"][0]["data"]["text"],
67
+ "version": 1,
68
+ "is_active": True,
69
+ },
70
+ }],
71
+ "audit_entry_id": 52,
72
+ "idempotent": False,
73
+ }
74
+
75
+ def list_decisions(self, project_id, *, search=None, include_history=False):
76
+ self.calls.append(("list_decisions", project_id, search, include_history))
77
+ return [{"id": 51, "project": project_id, "text": "Use heat recovery."}]
78
+
79
+ def show_decision(self, decision_id, *, project):
80
+ self.calls.append(("show_decision", decision_id, project))
81
+ return {"id": decision_id, "project": project, "text": "Use heat recovery."}
82
+
44
83
  def revit_import_pending(self, project_id):
45
84
  self.calls.append(("revit_import_pending", project_id))
46
85
  return self.revit_session
@@ -74,9 +113,14 @@ class McpServerTests(unittest.TestCase):
74
113
  self.assertIn("vp_get_categorization_review", names)
75
114
  self.assertIn("vp_apply_categorization", names)
76
115
  self.assertIn("vp_get_meeting_context", names)
116
+ self.assertIn("vp_get_task_context", names)
117
+ self.assertIn("vp_create_project_task", names)
77
118
  self.assertIn("vp_record_meeting", names)
78
119
  self.assertIn("vp_list_meetings", names)
79
120
  self.assertIn("vp_get_meeting", names)
121
+ self.assertIn("vp_create_decision", names)
122
+ self.assertIn("vp_list_decisions", names)
123
+ self.assertIn("vp_get_decision", names)
80
124
  self.assertIn("vp_revise_decision", names)
81
125
  self.assertIn("vp_review_revit_import", names)
82
126
  self.assertIn("vp_confirm_revit_import", names)
@@ -168,6 +212,92 @@ class McpServerTests(unittest.TestCase):
168
212
 
169
213
  asyncio.run(run())
170
214
 
215
+ def test_create_project_task_tool_assigns_member_without_confirmation(self):
216
+ async def run():
217
+ server = build_server()
218
+ fake_client = FakeClient()
219
+ fake_client.base_url = "https://ventilatepro.com"
220
+ with mock.patch(
221
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
222
+ return_value=(fake_client, "https://ventilatepro.com"),
223
+ ):
224
+ _content, structured = await server.call_tool(
225
+ "vp_create_project_task",
226
+ {
227
+ "project_id": 7,
228
+ "description": "Issue diffuser update",
229
+ "assigned_user_id": 12,
230
+ "priority": "high",
231
+ "due_at": "2026-08-05T17:00:00-07:00",
232
+ },
233
+ )
234
+
235
+ result = structured["result"]
236
+ self.assertEqual(result["status"], "created")
237
+ self.assertEqual(result["task"]["id"], 46)
238
+ self.assertIn("view=project-tasks", result["deep_link"])
239
+ call_name, sent_payload = fake_client.calls[0]
240
+ self.assertEqual(call_name, "create_project_task")
241
+ self.assertEqual(sent_payload["assigned_user"], 12)
242
+ self.assertFalse(sent_payload["include_in_minutes"])
243
+
244
+ asyncio.run(run())
245
+
246
+ def test_create_decision_tool_logs_without_meeting_or_confirmation(self):
247
+ async def run():
248
+ server = build_server()
249
+ fake_client = FakeClient()
250
+ fake_client.base_url = "https://ventilatepro.com"
251
+ with mock.patch(
252
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
253
+ return_value=(fake_client, "https://ventilatepro.com"),
254
+ ), mock.patch("ventilatepro.mcp_server.QueueStore"):
255
+ _content, structured = await server.call_tool(
256
+ "vp_create_decision",
257
+ {
258
+ "project_id": 7,
259
+ "text": "Use heat recovery.",
260
+ "reason": "Energy savings.",
261
+ "tags": ["ahu", "energy"],
262
+ "idempotency_key": "decision-51",
263
+ },
264
+ )
265
+
266
+ result = structured["result"]
267
+ self.assertEqual(result["status"], "created")
268
+ self.assertEqual(result["decision_ref"], "decision:51")
269
+ self.assertIn("view=decision-log", result["deep_link"])
270
+ call_name, sent = fake_client.calls[0]
271
+ self.assertEqual(call_name, "create_decision")
272
+ self.assertEqual(sent["project_id"], 7)
273
+ self.assertNotIn("minute", sent["writes"][0]["data"])
274
+
275
+ asyncio.run(run())
276
+
277
+ def test_decision_read_tools_are_project_scoped(self):
278
+ async def run():
279
+ server = build_server()
280
+ fake_client = FakeClient()
281
+ with mock.patch(
282
+ "ventilatepro.mcp_server.auth.get_authenticated_client",
283
+ return_value=(fake_client, "https://ventilatepro.com"),
284
+ ):
285
+ _content, listed = await server.call_tool(
286
+ "vp_list_decisions",
287
+ {"project_id": 7, "query": "recovery"},
288
+ )
289
+ _content, shown = await server.call_tool(
290
+ "vp_get_decision",
291
+ {"project_id": 7, "decision_id": 51},
292
+ )
293
+
294
+ self.assertEqual(listed["result"]["results"][0]["id"], 51)
295
+ self.assertEqual(shown["result"]["project"], 7)
296
+ self.assertIn(("list_decisions", 7, "recovery", False), fake_client.calls)
297
+ self.assertIn(("show_decision", 51, 7), fake_client.calls)
298
+
299
+ asyncio.run(run())
300
+
171
301
  def test_generic_api_tool_blocks_prefixed_revit_commit_path(self):
172
302
  async def run():
173
303
  server = build_server()
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.5.0"
5
+ __version__ = "0.5.2"
@@ -523,6 +523,16 @@ class ApiClient:
523
523
  params={"project": project},
524
524
  )
525
525
 
526
+ def task_context(self, project: int):
527
+ return self.meeting_context(project)
528
+
529
+ def create_project_task(self, payload: Dict[str, Any]):
530
+ return self.cli_proxy(
531
+ "POST",
532
+ "api/notes/action-items/",
533
+ json_payload=payload,
534
+ )
535
+
526
536
  def record_meeting(self, payload: Dict[str, Any]):
527
537
  return self.cli_proxy(
528
538
  "POST",
@@ -549,6 +559,40 @@ class ApiClient:
549
559
  params={"project": project},
550
560
  )
551
561
 
562
+ def create_decision(self, payload: Dict[str, Any]):
563
+ return self.cli_proxy(
564
+ "POST",
565
+ "api/notes/ingest/",
566
+ json_payload=payload,
567
+ )
568
+
569
+ def list_decisions(
570
+ self,
571
+ project: int,
572
+ *,
573
+ search: Optional[str] = None,
574
+ include_history: bool = False,
575
+ ):
576
+ params: Dict[str, Any] = {"project": project}
577
+ if search:
578
+ params["search"] = search
579
+ response = self.cli_proxy(
580
+ "GET",
581
+ "api/notes/decisions/",
582
+ params=params,
583
+ )
584
+ decisions = response.get("results", []) if isinstance(response, dict) else response
585
+ if include_history:
586
+ return decisions
587
+ return [decision for decision in decisions if decision.get("is_active", True)]
588
+
589
+ def show_decision(self, decision_id: int, *, project: int):
590
+ return self.cli_proxy(
591
+ "GET",
592
+ f"api/notes/decisions/{decision_id}/",
593
+ params={"project": project},
594
+ )
595
+
552
596
  def revise_decision(self, decision_id: int, payload: Dict[str, Any]):
553
597
  return self.cli_proxy(
554
598
  "POST",
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  import typer
6
6
 
7
- from . import auth, meetings, notes
7
+ from . import auth, decisions, meetings, notes, tasks
8
8
  from .api_client import ApiError, NetworkError
9
9
  from .calc import app as calc_app
10
10
  from .categorization import app as categorization_app
@@ -29,6 +29,8 @@ app = typer.Typer(help="VentilatePro CLI")
29
29
  auth_app = typer.Typer(help="Authentication commands")
30
30
  notes_app = typer.Typer(help="Notes commands")
31
31
  meetings_app = typer.Typer(help="Meeting minutes commands")
32
+ decisions_app = typer.Typer(help="Design decision commands")
33
+ tasks_app = typer.Typer(help="Project task commands")
32
34
 
33
35
  app.add_typer(auth_app, name="auth")
34
36
  app.add_typer(projects_app, name="projects")
@@ -45,6 +47,8 @@ app.add_typer(revit_imports_app, name="revit-imports")
45
47
  app.add_typer(design_day_app, name="design-day")
46
48
  app.add_typer(notes_app, name="notes")
47
49
  app.add_typer(meetings_app, name="meetings")
50
+ app.add_typer(decisions_app, name="decisions")
51
+ app.add_typer(tasks_app, name="tasks")
48
52
  app.add_typer(mcp_app, name="mcp")
49
53
 
50
54
 
@@ -430,5 +434,226 @@ def meetings_sync(
430
434
  raise typer.Exit(1)
431
435
 
432
436
 
437
+ @decisions_app.command("create")
438
+ def decisions_create(
439
+ project: int = typer.Option(..., "--project", help="Target project ID"),
440
+ text: str = typer.Option(..., "--text", help="Decision text"),
441
+ reason: str = typer.Option(None, "--reason", help="Decision rationale"),
442
+ impact: str = typer.Option(None, "--impact", help="Design or delivery impact"),
443
+ code_reference: str = typer.Option(None, "--code-reference", help="Code or standard reference"),
444
+ tags: str = typer.Option(None, "--tags", help="Comma-separated tags"),
445
+ meeting_id: int = typer.Option(None, "--meeting-id", help="Optional related meeting-minute ID"),
446
+ captured_at: str = typer.Option(None, "--captured-at", help="Capture timestamp in ISO-8601 format"),
447
+ source_message: str = typer.Option(None, "--source-message", help="Original PM input retained for audit"),
448
+ idempotency_key: str = typer.Option(None, "--idempotency-key", help="Stable retry key"),
449
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
450
+ ):
451
+ """Log a project decision directly; linking meeting minutes is optional."""
452
+ try:
453
+ client, _ = auth.get_authenticated_client()
454
+ result = decisions.create_decision(
455
+ client,
456
+ QueueStore(),
457
+ project=project,
458
+ text=text,
459
+ reason=reason,
460
+ impact=impact,
461
+ code_reference=code_reference,
462
+ tags=notes.parse_tags(tags),
463
+ meeting_id=meeting_id,
464
+ captured_at=captured_at,
465
+ source_message=source_message,
466
+ idempotency_key=idempotency_key,
467
+ )
468
+ except (ValueError, ApiError, NetworkError) as exc:
469
+ emit_error(str(exc), json_output=json_output)
470
+ raise typer.Exit(1)
471
+
472
+ if result["status"] == "queued":
473
+ lines = [f"Queued decision locally as operation #{result['local_id']}."]
474
+ else:
475
+ lines = [
476
+ f"Created {result['decision_ref']} in project {project}.",
477
+ result.get("deep_link") or "",
478
+ ]
479
+ emit(result, json_output=json_output, lines=[line for line in lines if line])
480
+
481
+
482
+ @decisions_app.command("list")
483
+ def decisions_list(
484
+ project: int = typer.Option(..., "--project", help="Target project ID"),
485
+ query: str = typer.Option(None, "--query", help="Search decision content"),
486
+ include_history: bool = typer.Option(False, "--include-history", help="Include superseded versions"),
487
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
488
+ ):
489
+ try:
490
+ client, _ = auth.get_authenticated_client()
491
+ result = client.list_decisions(
492
+ project,
493
+ search=query,
494
+ include_history=include_history,
495
+ )
496
+ except (ValueError, ApiError, NetworkError) as exc:
497
+ emit_error(str(exc), json_output=json_output)
498
+ raise typer.Exit(1)
499
+
500
+ lines = [
501
+ f"decision:{item.get('id')}\tv{item.get('version', 1)}\t{item.get('text', '')}"
502
+ for item in result
503
+ ]
504
+ emit({"results": result}, json_output=json_output, lines=lines or ["No decisions found."])
505
+
506
+
507
+ @decisions_app.command("show")
508
+ def decisions_show(
509
+ decision_id: int = typer.Argument(..., help="Decision ID"),
510
+ project: int = typer.Option(..., "--project", help="Target project ID"),
511
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
512
+ ):
513
+ try:
514
+ client, _ = auth.get_authenticated_client()
515
+ result = client.show_decision(decision_id, project=project)
516
+ except (ValueError, ApiError, NetworkError) as exc:
517
+ emit_error(str(exc), json_output=json_output)
518
+ raise typer.Exit(1)
519
+
520
+ emit(
521
+ result,
522
+ json_output=json_output,
523
+ lines=[
524
+ f"decision:{result.get('id')} v{result.get('version', 1)}"
525
+ f"{' (superseded)' if not result.get('is_active', True) else ''}",
526
+ result.get("text") or "",
527
+ f"Reason: {result.get('reason') or 'None'}",
528
+ f"Impact: {result.get('impact') or result.get('impact_text') or 'None'}",
529
+ f"Code reference: {result.get('code_reference') or result.get('code_reference_text') or 'None'}",
530
+ f"Tags: {', '.join(result.get('tags') or result.get('references') or []) or 'None'}",
531
+ f"Meeting: {result.get('minute') or 'None'}",
532
+ ],
533
+ )
534
+
535
+
536
+ @decisions_app.command("revise")
537
+ def decisions_revise(
538
+ decision_id: int = typer.Argument(..., help="Active decision ID"),
539
+ text: str = typer.Option(None, "--text", help="Revised decision text"),
540
+ reason: str = typer.Option(None, "--reason", help="Revised rationale"),
541
+ impact: str = typer.Option(None, "--impact", help="Revised impact"),
542
+ code_reference: str = typer.Option(None, "--code-reference", help="Revised code or standard reference"),
543
+ tags: str = typer.Option(None, "--tags", help="Replacement comma-separated tags"),
544
+ clear_tags: bool = typer.Option(False, "--clear-tags", help="Clear all tags"),
545
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
546
+ ):
547
+ """Create an immutable successor version of an existing decision."""
548
+ try:
549
+ payload = {}
550
+ if text is not None:
551
+ payload["text"] = text
552
+ if reason is not None:
553
+ payload["reason"] = reason
554
+ if impact is not None:
555
+ payload["impact"] = impact
556
+ if code_reference is not None:
557
+ payload["code_reference"] = code_reference
558
+ if clear_tags:
559
+ payload["tags"] = []
560
+ elif tags is not None:
561
+ payload["tags"] = notes.parse_tags(tags)
562
+ if not payload:
563
+ raise ValueError("Provide at least one field to revise.")
564
+ client, _ = auth.get_authenticated_client()
565
+ result = client.revise_decision(decision_id, payload)
566
+ except (ValueError, ApiError, NetworkError) as exc:
567
+ emit_error(str(exc), json_output=json_output)
568
+ raise typer.Exit(1)
569
+
570
+ emit(
571
+ result,
572
+ json_output=json_output,
573
+ lines=[
574
+ f"Created decision:{result.get('id')} v{result.get('version')} as the successor to decision:{decision_id}."
575
+ ],
576
+ )
577
+
578
+
579
+ @decisions_app.command("sync")
580
+ def decisions_sync(
581
+ project: int = typer.Option(None, "--project", help="Only sync this project"),
582
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
583
+ ):
584
+ try:
585
+ client, _ = auth.get_authenticated_client()
586
+ result = decisions.sync_decisions(client, QueueStore(), project=project)
587
+ except (ValueError, ApiError, NetworkError) as exc:
588
+ emit_error(str(exc), json_output=json_output)
589
+ raise typer.Exit(1)
590
+ emit(
591
+ result,
592
+ json_output=json_output,
593
+ lines=[f"Processed {result['total']} queued decisions: {result['synced']} synced, {result['failed']} failed."],
594
+ )
595
+ if result["failed"]:
596
+ raise typer.Exit(1)
597
+
598
+
599
+ @tasks_app.command("context")
600
+ def tasks_context(
601
+ project: int = typer.Option(..., "--project", help="Target project ID"),
602
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
603
+ ):
604
+ """Show the stable user IDs that may be assigned project tasks."""
605
+ try:
606
+ client, _ = auth.get_authenticated_client()
607
+ result = client.task_context(project)
608
+ except (ValueError, ApiError, NetworkError) as exc:
609
+ emit_error(str(exc), json_output=json_output)
610
+ raise typer.Exit(1)
611
+
612
+ lines = [
613
+ f"Project: {(result.get('project') or {}).get('name')} ({project})",
614
+ *[
615
+ f"{member.get('user_id')}\t{member.get('name')}\t{member.get('email') or ''}\t{member.get('role') or ''}"
616
+ for member in result.get("members", [])
617
+ ],
618
+ ]
619
+ emit(result, json_output=json_output, lines=lines)
620
+
621
+
622
+ @tasks_app.command("create")
623
+ def tasks_create(
624
+ project: int = typer.Option(..., "--project", help="Target project ID"),
625
+ description: str = typer.Option(..., "--description", help="Task description"),
626
+ assignee_id: int = typer.Option(None, "--assignee-id", help="Assignable project-member user ID"),
627
+ due_at: str = typer.Option(None, "--due-at", help="Due timestamp in ISO-8601 format"),
628
+ priority: str = typer.Option("medium", "--priority", help="low, medium, high, or critical"),
629
+ notes_text: str = typer.Option(None, "--notes", help="Internal task notes"),
630
+ estimated_hours: float = typer.Option(None, "--estimated-hours", help="Estimated effort in hours"),
631
+ json_output: bool = typer.Option(False, "--json", help="Return JSON output"),
632
+ ):
633
+ """Create a standalone project task and optionally assign it to a member."""
634
+ try:
635
+ client, _ = auth.get_authenticated_client()
636
+ result = tasks.create_project_task(
637
+ client,
638
+ project=project,
639
+ description=description,
640
+ assigned_user_id=assignee_id,
641
+ due_at=due_at,
642
+ priority=priority,
643
+ notes=notes_text,
644
+ estimated_hours=estimated_hours,
645
+ )
646
+ except (ValueError, ApiError, NetworkError) as exc:
647
+ emit_error(str(exc), json_output=json_output)
648
+ raise typer.Exit(1)
649
+
650
+ task = result.get("task") or {}
651
+ lines = [
652
+ f"Created project-task:{task.get('id')} in project {project}.",
653
+ result.get("deep_link") or "",
654
+ ]
655
+ emit(result, json_output=json_output, lines=[line for line in lines if line])
656
+
657
+
433
658
  if __name__ == "__main__":
434
659
  main()
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any, Dict, Optional
5
+
6
+ from .api_client import ApiError, ApiClient, NetworkError
7
+ from .queue import QueueStore
8
+
9
+
10
+ DECISION_OPERATION = "decision.create"
11
+
12
+
13
+ def prepare_payload(
14
+ *,
15
+ project: int,
16
+ text: str,
17
+ reason: Optional[str] = None,
18
+ impact: Optional[str] = None,
19
+ code_reference: Optional[str] = None,
20
+ tags: Optional[list[str]] = None,
21
+ meeting_id: Optional[int] = None,
22
+ captured_at: Optional[str] = None,
23
+ source_message: Optional[str] = None,
24
+ idempotency_key: Optional[str] = None,
25
+ ) -> Dict[str, Any]:
26
+ if project <= 0:
27
+ raise ValueError("Project ID must be a positive integer.")
28
+ normalized_text = (text or "").strip()
29
+ if not normalized_text:
30
+ raise ValueError("Decision text is required.")
31
+ if meeting_id is not None and meeting_id <= 0:
32
+ raise ValueError("Meeting ID must be a positive integer.")
33
+
34
+ normalized_tags = []
35
+ for tag in tags or []:
36
+ normalized = str(tag).strip()
37
+ if normalized and normalized not in normalized_tags:
38
+ normalized_tags.append(normalized)
39
+
40
+ decision: Dict[str, Any] = {
41
+ "text": normalized_text,
42
+ "reason": (reason or "").strip(),
43
+ "impact_text": (impact or "").strip(),
44
+ "code_reference_text": (code_reference or "").strip(),
45
+ "references": normalized_tags,
46
+ "source": "cli",
47
+ }
48
+ if meeting_id is not None:
49
+ decision["minute"] = meeting_id
50
+ if captured_at:
51
+ decision["captured_at"] = captured_at
52
+
53
+ return {
54
+ "project_id": project,
55
+ "idempotency_key": idempotency_key or str(uuid.uuid4()),
56
+ "source_message": source_message if source_message is not None else normalized_text,
57
+ "writes": [{"model": "notes.Decision", "data": decision}],
58
+ }
59
+
60
+
61
+ def _created_result(client: ApiClient, project: int, response: Dict[str, Any]) -> Dict[str, Any]:
62
+ created = [
63
+ item
64
+ for item in response.get("created", [])
65
+ if item.get("model") == "notes.Decision"
66
+ ]
67
+ if len(created) != 1:
68
+ raise ValueError(
69
+ "The idempotency key did not resolve to exactly one decision. Use a new key."
70
+ )
71
+ decision = created[0].get("data") or {}
72
+ decision_id = created[0].get("id") or decision.get("id")
73
+ return {
74
+ "status": "created",
75
+ "decision": decision,
76
+ "decision_ref": f"decision:{decision_id}",
77
+ "audit_entry_id": response.get("audit_entry_id"),
78
+ "idempotent": bool(response.get("idempotent")),
79
+ "deep_link": f"{client.base_url}/?view=decision-log&project={project}&decision={decision_id}",
80
+ }
81
+
82
+
83
+ def create_decision(
84
+ client: ApiClient,
85
+ queue_store: QueueStore,
86
+ *,
87
+ project: int,
88
+ text: str,
89
+ reason: Optional[str] = None,
90
+ impact: Optional[str] = None,
91
+ code_reference: Optional[str] = None,
92
+ tags: Optional[list[str]] = None,
93
+ meeting_id: Optional[int] = None,
94
+ captured_at: Optional[str] = None,
95
+ source_message: Optional[str] = None,
96
+ idempotency_key: Optional[str] = None,
97
+ ) -> Dict[str, Any]:
98
+ payload = prepare_payload(
99
+ project=project,
100
+ text=text,
101
+ reason=reason,
102
+ impact=impact,
103
+ code_reference=code_reference,
104
+ tags=tags,
105
+ meeting_id=meeting_id,
106
+ captured_at=captured_at,
107
+ source_message=source_message,
108
+ idempotency_key=idempotency_key,
109
+ )
110
+ try:
111
+ return _created_result(client, project, client.create_decision(payload))
112
+ except NetworkError as exc:
113
+ return _queue_decision(queue_store, project, payload, exc)
114
+ except ApiError as exc:
115
+ if exc.status_code < 500:
116
+ raise
117
+ return _queue_decision(queue_store, project, payload, exc)
118
+
119
+
120
+ def _queue_decision(
121
+ queue_store: QueueStore,
122
+ project: int,
123
+ payload: Dict[str, Any],
124
+ error: Exception,
125
+ ) -> Dict[str, Any]:
126
+ queued = queue_store.enqueue_operation(
127
+ operation_type=DECISION_OPERATION,
128
+ project_id=project,
129
+ payload=payload,
130
+ idempotency_key=payload["idempotency_key"],
131
+ )
132
+ return {
133
+ "status": "queued",
134
+ "project": project,
135
+ "local_id": queued.local_id,
136
+ "idempotency_key": queued.idempotency_key,
137
+ "error": str(error),
138
+ }
139
+
140
+
141
+ def sync_decisions(
142
+ client: ApiClient,
143
+ queue_store: QueueStore,
144
+ *,
145
+ project: Optional[int] = None,
146
+ ) -> Dict[str, Any]:
147
+ queued = queue_store.list_unsynced_operations(
148
+ operation_type=DECISION_OPERATION,
149
+ project_id=project,
150
+ )
151
+ results = []
152
+ synced = 0
153
+ failed = 0
154
+ for operation in queued:
155
+ queue_store.mark_operation_syncing(operation.local_id)
156
+ try:
157
+ result = _created_result(
158
+ client,
159
+ operation.project_id,
160
+ client.create_decision(operation.payload),
161
+ )
162
+ queue_store.mark_operation_synced(operation.local_id, result["decision_ref"])
163
+ synced += 1
164
+ results.append({"local_id": operation.local_id, "status": "synced", **result})
165
+ except (ValueError, NetworkError, ApiError) as exc:
166
+ queue_store.mark_operation_failed(operation.local_id, str(exc))
167
+ failed += 1
168
+ results.append({
169
+ "local_id": operation.local_id,
170
+ "status": "failed",
171
+ "error": str(exc),
172
+ })
173
+ return {
174
+ "total": len(queued),
175
+ "synced": synced,
176
+ "failed": failed,
177
+ "results": results,
178
+ }
@@ -7,7 +7,9 @@ from pathlib import Path
7
7
  from typing import Any, Dict, Optional
8
8
 
9
9
  from . import auth
10
+ from . import decisions
10
11
  from . import meetings
12
+ from . import tasks
11
13
  from .api_client import ApiError, NetworkError
12
14
  from .categorization import _validate_apply_manifest
13
15
  from .queue import QueueStore
@@ -432,6 +434,41 @@ def build_server():
432
434
  detail = getattr(exc, "payload", None)
433
435
  return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
434
436
 
437
+ @server.tool()
438
+ def vp_get_task_context(project_id: int) -> Dict[str, Any]:
439
+ """Get the project-member directory and stable user IDs available for task assignment."""
440
+ try:
441
+ return _client().task_context(project_id)
442
+ except (ValueError, ApiError, NetworkError) as exc:
443
+ detail = getattr(exc, "payload", None)
444
+ return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
445
+
446
+ @server.tool()
447
+ def vp_create_project_task(
448
+ project_id: int,
449
+ description: str,
450
+ assigned_user_id: Optional[int] = None,
451
+ due_at: Optional[str] = None,
452
+ priority: str = "medium",
453
+ notes: Optional[str] = None,
454
+ estimated_hours: Optional[float] = None,
455
+ ) -> Dict[str, Any]:
456
+ """Create a standalone project task and optionally assign it to a project member."""
457
+ try:
458
+ return tasks.create_project_task(
459
+ _client(),
460
+ project=project_id,
461
+ description=description,
462
+ assigned_user_id=assigned_user_id,
463
+ due_at=due_at,
464
+ priority=priority,
465
+ notes=notes,
466
+ estimated_hours=estimated_hours,
467
+ )
468
+ except (ValueError, ApiError, NetworkError) as exc:
469
+ detail = getattr(exc, "payload", None)
470
+ return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
471
+
435
472
  @server.tool()
436
473
  def vp_record_meeting(
437
474
  project_id: int,
@@ -472,6 +509,67 @@ def build_server():
472
509
  detail = getattr(exc, "payload", None)
473
510
  return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
474
511
 
512
+ @server.tool()
513
+ def vp_create_decision(
514
+ project_id: int,
515
+ text: str,
516
+ reason: Optional[str] = None,
517
+ impact: Optional[str] = None,
518
+ code_reference: Optional[str] = None,
519
+ tags: Optional[list[str]] = None,
520
+ meeting_id: Optional[int] = None,
521
+ captured_at: Optional[str] = None,
522
+ source_message: Optional[str] = None,
523
+ idempotency_key: Optional[str] = None,
524
+ ) -> Dict[str, Any]:
525
+ """Log a project decision immediately; a meeting-minute link is optional."""
526
+ try:
527
+ return decisions.create_decision(
528
+ _client(),
529
+ QueueStore(),
530
+ project=project_id,
531
+ text=text,
532
+ reason=reason,
533
+ impact=impact,
534
+ code_reference=code_reference,
535
+ tags=tags,
536
+ meeting_id=meeting_id,
537
+ captured_at=captured_at,
538
+ source_message=source_message,
539
+ idempotency_key=idempotency_key,
540
+ )
541
+ except (ValueError, ApiError, NetworkError) as exc:
542
+ detail = getattr(exc, "payload", None)
543
+ return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
544
+
545
+ @server.tool()
546
+ def vp_list_decisions(
547
+ project_id: int,
548
+ query: Optional[str] = None,
549
+ include_history: bool = False,
550
+ ) -> Dict[str, Any]:
551
+ """List active project decisions, optionally including superseded versions."""
552
+ try:
553
+ return {
554
+ "results": _client().list_decisions(
555
+ project_id,
556
+ search=query,
557
+ include_history=include_history,
558
+ )
559
+ }
560
+ except (ValueError, ApiError, NetworkError) as exc:
561
+ detail = getattr(exc, "payload", None)
562
+ return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
563
+
564
+ @server.tool()
565
+ def vp_get_decision(project_id: int, decision_id: int) -> Dict[str, Any]:
566
+ """Get one decision, including its immutable version and optional meeting link."""
567
+ try:
568
+ return _client().show_decision(decision_id, project=project_id)
569
+ except (ValueError, ApiError, NetworkError) as exc:
570
+ detail = getattr(exc, "payload", None)
571
+ return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
572
+
475
573
  @server.tool()
476
574
  def vp_revise_decision(
477
575
  decision_id: int,
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ from .api_client import ApiClient
6
+
7
+
8
+ VALID_PRIORITIES = {"low", "medium", "high", "critical"}
9
+
10
+
11
+ def create_project_task(
12
+ client: ApiClient,
13
+ *,
14
+ project: int,
15
+ description: str,
16
+ assigned_user_id: Optional[int] = None,
17
+ due_at: Optional[str] = None,
18
+ priority: str = "medium",
19
+ notes: Optional[str] = None,
20
+ estimated_hours: Optional[float] = None,
21
+ ) -> Dict[str, Any]:
22
+ description = (description or "").strip()
23
+ if not description:
24
+ raise ValueError("Task description is required.")
25
+ if project <= 0:
26
+ raise ValueError("Project ID must be a positive integer.")
27
+
28
+ normalized_priority = (priority or "medium").strip().lower()
29
+ if normalized_priority not in VALID_PRIORITIES:
30
+ choices = ", ".join(sorted(VALID_PRIORITIES))
31
+ raise ValueError(f"Priority must be one of: {choices}.")
32
+ if assigned_user_id is not None and assigned_user_id <= 0:
33
+ raise ValueError("Assignee user ID must be a positive integer.")
34
+ if estimated_hours is not None and estimated_hours < 0:
35
+ raise ValueError("Estimated hours cannot be negative.")
36
+
37
+ payload: Dict[str, Any] = {
38
+ "project": project,
39
+ "description": description,
40
+ "priority": normalized_priority,
41
+ "source": "cli",
42
+ "include_in_minutes": False,
43
+ }
44
+ if assigned_user_id is not None:
45
+ payload["assigned_user"] = assigned_user_id
46
+ if due_at:
47
+ payload["due_at"] = due_at
48
+ if notes:
49
+ payload["notes"] = notes
50
+ if estimated_hours is not None:
51
+ payload["estimated_hours"] = estimated_hours
52
+
53
+ task = client.create_project_task(payload)
54
+ return {
55
+ "status": "created",
56
+ "task": task,
57
+ "deep_link": f"{client.base_url}/?view=project-tasks&project={project}",
58
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventilatepro-cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: VentilatePro CLI
5
5
  Project-URL: Homepage, https://ventilatepro.com/exam/cli/
6
6
  Project-URL: Documentation, https://ventilatepro.com/exam/cli/
@@ -61,6 +61,11 @@ ventilatepro notes sync
61
61
  ventilatepro meetings context --project 1 --json
62
62
  ventilatepro meetings record --project 1 --input meeting.json --json
63
63
  ventilatepro meetings list --project 1
64
+ ventilatepro decisions create --project 1 --text "Use heat recovery on AHU-2" --reason "Energy model payback" --tags ahu,energy
65
+ ventilatepro decisions list --project 1
66
+ ventilatepro decisions revise 51 --text "Use heat recovery with bypass control"
67
+ ventilatepro tasks context --project 1 --json
68
+ ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
64
69
  ventilatepro mcp doctor
65
70
  ventilatepro-mcp
66
71
  ```
@@ -74,6 +79,17 @@ stable project-member IDs, interprets the PM's prose into a structured JSON
74
79
  payload, and calls `meetings record`. The server atomically creates the meeting,
75
80
  decisions, and assigned project tasks and returns a direct web-app link.
76
81
 
82
+ For standalone work, Codex reads `tasks context` to resolve an assignee to a
83
+ stable project-member user ID, then calls `tasks create`. The same workflow is
84
+ available through the typed `vp_get_task_context` and
85
+ `vp_create_project_task` MCP tools.
86
+
87
+ Design decisions do not require meeting minutes. Codex can call `decisions
88
+ create` or `vp_create_decision` as soon as a PM communicates a decision, with
89
+ an optional meeting ID only when the relationship is useful. Listing, showing,
90
+ and revising decisions are available through first-class CLI and MCP surfaces;
91
+ revisions preserve the prior version as immutable history.
92
+
77
93
  For room classification, Codex can read the project-specific categorization
78
94
  review resource or use the dedicated MCP tools. Review manifests default every
79
95
  room to rejected so ambiguous spaces remain visible until a category is
@@ -11,6 +11,7 @@ ventilatepro/calc.py
11
11
  ventilatepro/categorization.py
12
12
  ventilatepro/cli.py
13
13
  ventilatepro/config.py
14
+ ventilatepro/decisions.py
14
15
  ventilatepro/design_day.py
15
16
  ventilatepro/entities.py
16
17
  ventilatepro/mcp_cli.py
@@ -23,6 +24,7 @@ ventilatepro/projects.py
23
24
  ventilatepro/queue.py
24
25
  ventilatepro/revit_imports.py
25
26
  ventilatepro/systems.py
27
+ ventilatepro/tasks.py
26
28
  ventilatepro_cli.egg-info/PKG-INFO
27
29
  ventilatepro_cli.egg-info/SOURCES.txt
28
30
  ventilatepro_cli.egg-info/dependency_links.txt