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.
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/PKG-INFO +17 -1
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/README.md +16 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/tests/test_cli.py +261 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/tests/test_mcp_server.py +130 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/__init__.py +1 -1
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/api_client.py +44 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/cli.py +226 -1
- ventilatepro_cli-0.5.2/ventilatepro/decisions.py +178 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/mcp_server.py +98 -0
- ventilatepro_cli-0.5.2/ventilatepro/tasks.py +58 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/PKG-INFO +17 -1
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/SOURCES.txt +2 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/pyproject.toml +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/setup.cfg +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/tests/test_config.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/__main__.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/auth.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/calc.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/categorization.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/config.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/design_day.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/entities.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/mcp_cli.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/meetings.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/notes.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/output.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/payloads.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/projects.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/queue.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/revit_imports.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro/systems.py +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/dependency_links.txt +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/entry_points.txt +0 -0
- {ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/requires.txt +0 -0
- {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.
|
|
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()
|
|
@@ -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.
|
|
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
|
|
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.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{ventilatepro_cli-0.5.0 → ventilatepro_cli-0.5.2}/ventilatepro_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|