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.
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/PKG-INFO +10 -1
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/README.md +9 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/tests/test_cli.py +183 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/tests/test_mcp_server.py +86 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/__init__.py +1 -1
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/api_client.py +34 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/cli.py +165 -1
- ventilatepro_cli-0.5.2/ventilatepro/decisions.py +178 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/mcp_server.py +62 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/PKG-INFO +10 -1
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/SOURCES.txt +1 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/pyproject.toml +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/setup.cfg +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/calc.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/categorization.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/design_day.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/entities.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/mcp_cli.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/meetings.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/output.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/payloads.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/projects.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/queue.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/revit_imports.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/systems.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro/tasks.py +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
- {ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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()
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ventilatepro_cli-0.5.1 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|