ventilatepro-cli 0.5.1__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.1 → ventilatepro_cli-0.5.2}/PKG-INFO +10 -1
  2. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/README.md +9 -0
  3. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/tests/test_cli.py +183 -0
  4. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/tests/test_mcp_server.py +86 -0
  5. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/__init__.py +1 -1
  6. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/api_client.py +34 -0
  7. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/cli.py +165 -1
  8. ventilatepro_cli-0.5.2/ventilatepro/decisions.py +178 -0
  9. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/mcp_server.py +62 -0
  10. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/PKG-INFO +10 -1
  11. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/SOURCES.txt +1 -0
  12. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/pyproject.toml +0 -0
  13. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/setup.cfg +0 -0
  14. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/tests/test_config.py +0 -0
  15. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/__main__.py +0 -0
  16. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/auth.py +0 -0
  17. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/calc.py +0 -0
  18. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/categorization.py +0 -0
  19. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/config.py +0 -0
  20. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/design_day.py +0 -0
  21. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/entities.py +0 -0
  22. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/mcp_cli.py +0 -0
  23. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/meetings.py +0 -0
  24. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/notes.py +0 -0
  25. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/output.py +0 -0
  26. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/payloads.py +0 -0
  27. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/projects.py +0 -0
  28. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/queue.py +0 -0
  29. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/revit_imports.py +0 -0
  30. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/systems.py +0 -0
  31. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/tasks.py +0 -0
  32. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
  33. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
  34. {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/requires.txt +0 -0
  35. {ventilatepro_cli-0.5.1 → 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.1
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,9 @@ 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"
64
67
  ventilatepro tasks context --project 1 --json
65
68
  ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
66
69
  ventilatepro mcp doctor
@@ -81,6 +84,12 @@ stable project-member user ID, then calls `tasks create`. The same workflow is
81
84
  available through the typed `vp_get_task_context` and
82
85
  `vp_create_project_task` MCP tools.
83
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
+
84
93
  For room classification, Codex can read the project-specific categorization
85
94
  review resource or use the dedicated MCP tools. Review manifests default every
86
95
  room to rejected so ambiguous spaces remain visible until a category is
@@ -46,6 +46,9 @@ 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"
49
52
  ventilatepro tasks context --project 1 --json
50
53
  ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
51
54
  ventilatepro mcp doctor
@@ -66,6 +69,12 @@ stable project-member user ID, then calls `tasks create`. The same workflow is
66
69
  available through the typed `vp_get_task_context` and
67
70
  `vp_create_project_task` MCP tools.
68
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
+
69
78
  For room classification, Codex can read the project-specific categorization
70
79
  review resource or use the dedicated MCP tools. Review manifests default every
71
80
  room to rejected so ambiguous spaces remain visible until a category is
@@ -440,6 +440,189 @@ class CliTests(unittest.TestCase):
440
440
  self.assertFalse(sent_payload["include_in_minutes"])
441
441
  self.assertEqual(sent_payload["source"], "cli")
442
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
+
443
626
  def test_meetings_record_reads_structured_stdin_and_saves_immediately(self):
444
627
  fake_client = mock.Mock()
445
628
  fake_client.record_meeting.return_value = {
@@ -52,6 +52,34 @@ class FakeClient:
52
52
  self.calls.append(("create_project_task", payload))
53
53
  return {"id": 46, **payload}
54
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
+
55
83
  def revit_import_pending(self, project_id):
56
84
  self.calls.append(("revit_import_pending", project_id))
57
85
  return self.revit_session
@@ -90,6 +118,9 @@ class McpServerTests(unittest.TestCase):
90
118
  self.assertIn("vp_record_meeting", names)
91
119
  self.assertIn("vp_list_meetings", names)
92
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)
93
124
  self.assertIn("vp_revise_decision", names)
94
125
  self.assertIn("vp_review_revit_import", names)
95
126
  self.assertIn("vp_confirm_revit_import", names)
@@ -212,6 +243,61 @@ class McpServerTests(unittest.TestCase):
212
243
 
213
244
  asyncio.run(run())
214
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
+
215
301
  def test_generic_api_tool_blocks_prefixed_revit_commit_path(self):
216
302
  async def run():
217
303
  server = build_server()
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.5.1"
5
+ __version__ = "0.5.2"
@@ -559,6 +559,40 @@ class ApiClient:
559
559
  params={"project": project},
560
560
  )
561
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
+
562
596
  def revise_decision(self, decision_id: int, payload: Dict[str, Any]):
563
597
  return self.cli_proxy(
564
598
  "POST",
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  import typer
6
6
 
7
- from . import auth, meetings, notes, tasks
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,7 @@ 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")
32
33
  tasks_app = typer.Typer(help="Project task commands")
33
34
 
34
35
  app.add_typer(auth_app, name="auth")
@@ -46,6 +47,7 @@ app.add_typer(revit_imports_app, name="revit-imports")
46
47
  app.add_typer(design_day_app, name="design-day")
47
48
  app.add_typer(notes_app, name="notes")
48
49
  app.add_typer(meetings_app, name="meetings")
50
+ app.add_typer(decisions_app, name="decisions")
49
51
  app.add_typer(tasks_app, name="tasks")
50
52
  app.add_typer(mcp_app, name="mcp")
51
53
 
@@ -432,6 +434,168 @@ def meetings_sync(
432
434
  raise typer.Exit(1)
433
435
 
434
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
+
435
599
  @tasks_app.command("context")
436
600
  def tasks_context(
437
601
  project: int = typer.Option(..., "--project", help="Target project ID"),
@@ -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,6 +7,7 @@ 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
11
12
  from . import tasks
12
13
  from .api_client import ApiError, NetworkError
@@ -508,6 +509,67 @@ def build_server():
508
509
  detail = getattr(exc, "payload", None)
509
510
  return _error_payload(exc.__class__.__name__, str(exc), detail=detail)
510
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
+
511
573
  @server.tool()
512
574
  def vp_revise_decision(
513
575
  decision_id: int,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventilatepro-cli
3
- Version: 0.5.1
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,9 @@ 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"
64
67
  ventilatepro tasks context --project 1 --json
65
68
  ventilatepro tasks create --project 1 --description "Issue updated duct plan" --assignee-id 12 --priority high
66
69
  ventilatepro mcp doctor
@@ -81,6 +84,12 @@ stable project-member user ID, then calls `tasks create`. The same workflow is
81
84
  available through the typed `vp_get_task_context` and
82
85
  `vp_create_project_task` MCP tools.
83
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
+
84
93
  For room classification, Codex can read the project-specific categorization
85
94
  review resource or use the dedicated MCP tools. Review manifests default every
86
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