diff --git a/.engineering/baseline.json b/.engineering/baseline.json index 4a3c4174..ba53c286 100644 --- a/.engineering/baseline.json +++ b/.engineering/baseline.json @@ -1,29 +1,21 @@ { "schema_version": 1, - "standard": { - "source": "daniele21/repo-template-sw", - "version": "0.8.0" - }, + "standard": {"source": "daniele21/repo-template-sw", "version": "0.10.0", "revision": "f392e4ded5cd3ec5efbf806934789c6cb9f10ded"}, "target_level": "L2", - "profiles": [ - "python", - "typescript", - "macos", - "local-ai", - "product-ui" - ], + "profiles": ["python", "typescript", "macos", "local-ai", "product-ui"], "skills": { - "plan-workstream": {"source_version": "0.1.0", "customized": false}, - "structured-change": {"source_version": "0.6.0", "customized": false}, + "plan-workstream": {"source_version": "0.10.0", "customized": true}, + "structured-change": {"source_version": "0.10.0", "customized": true}, "design-product-experience": {"source_version": "0.5.0", "customized": false}, - "validate-change": {"source_version": "0.8.0", "customized": false}, - "preflight-change": {"source_version": "0.8.0", "customized": false}, - "remote-preflight": {"source_version": "0.2.0", "customized": false}, - "finalize-workstream": {"source_version": "0.1.0", "customized": false}, + "validate-change": {"source_version": "0.10.0", "customized": true}, + "preflight-change": {"source_version": "0.10.0", "customized": true}, + "remote-preflight": {"source_version": "0.10.0", "customized": true}, + "finalize-workstream": {"source_version": "0.10.0", "customized": true}, "review-reference-quality": {"source_version": "0.4.0", "customized": false} }, - "documentation": { - "completed_workstreams": "delete-by-default", - "historical_archive": "exception-only" + "documentation": {"completed_workstreams": "delete-by-default", "historical_archive": "exception-only"}, + "adoption": { + "development_velocity_0_9_2": {"staged_delivery": "adopted", "risk_to_gate_selection": "adopted", "exact_head_evidence_reuse": "adopted", "post_merge_tree_equivalent_reuse": "adopted-on-dev", "risk_based_ui_evidence": "adopted", "affected_automated_e2e_before_dev": "adopted", "integration_real_environment_deferred_to_release": "adopted", "release_real_environment_blocking": "adopted", "target_mac_real_environment_boundary": "preserved"}, + "development_velocity_0_10_0": {"bounded_agent_reporting": "adopted", "schema2_context_routes": "adopted", "two-failed-repair_diagnostic_pivot": "adopted", "resume_checkpoint": "adopted", "template_revision": "f392e4ded5cd3ec5efbf806934789c6cb9f10ded"} } } diff --git a/.engineering/commands.json b/.engineering/commands.json index bcc94d5a..7fd3dd91 100644 --- a/.engineering/commands.json +++ b/.engineering/commands.json @@ -1,119 +1,39 @@ { "schema_version": 1, - "contract_version": "0.5.0", + "contract_version": "0.7.0", "commands": { "setup": {"status": "required", "run": "./setup.sh && (cd frontend && pnpm install --frozen-lockfile)"}, "doctor": {"status": "recommended", "run": "UV_CACHE_DIR=.cache/uv uv run local-asr doctor"}, "dev": {"status": "recommended", "run": "./run.sh"}, - "check": {"status": "required", "run": "python3 scripts/verify_repository.py && python3 scripts/verify_operations.py && python3 scripts/verify_e2e.py && python3 scripts/verify_product_experience.py && python3 scripts/verify_docs.py && python3 scripts/verify_agent_context.py && python3 -m unittest test.test_validation_profile_selector test.test_build_artifact_metadata -v && (cd frontend && pnpm run lint && pnpm exec tsc --noEmit)"}, + "check": {"status": "required", "run": "python3 scripts/verify_repository.py && python3 scripts/verify_operations.py && python3 scripts/verify_e2e.py && python3 scripts/verify_stage_environment_policy.py && python3 scripts/verify_product_experience.py && python3 scripts/verify_docs.py && python3 scripts/verify_agent_context.py && python3 -m unittest test.test_validation_profile_selector test.test_build_artifact_metadata -v && (cd frontend && pnpm run lint && pnpm exec tsc --noEmit)"}, "test": {"status": "required", "run": "UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -v"}, "e2e": {"status": "recommended", "run": "UV_CACHE_DIR=.cache/uv uv run python -m unittest test.test_frontend_transcription_workflow test.test_recording_api test.test_native_capture -v"}, "build": {"status": "required", "run": "bash scripts/build_artifact.sh --no-dmg"}, "smoke": {"status": "recommended", "run": "python3 scripts/smoke_packaged_app.py"}, "package": {"status": "optional", "run": "bash scripts/build_artifact.sh --dmg"}, - "stop": {"status": "n/a", "run": ""}, + "local_real_environment": {"status": "recommended", "run": "python3 scripts/run_local_real_environment_suite.py"}, + "release_build": {"status": "required", "run": "python3 scripts/build_production_artifact.py"}, + "release_evidence": {"status": "required", "run": "python3 scripts/measured_release_target_mac.py --app && python3 scripts/record_while_ai_busy_target_mac.py --app "}, + "stop": {"status": "n/a", "run": "n/a"}, "clean": {"status": "required", "run": "python3 scripts/clean_build_state.py"} }, - "publication_gate": { - "agent_preflight_required": true, - "target_base_freshness_required": true, - "full_diff_review_required": true, - "material_ambiguity_must_be_resolved": true, - "failure_root_cause_required": true, - "execution_capability_classification_required": true, - "blast_radius_profile_selection_required": true, - "automatable_gates_must_not_be_delegated_to_user": true, - "remote_automated_fallback_required_when_agent_local_unavailable": true, - "deterministic_ci_command_parity_required": true, - "non_automated_evidence_must_be_declared": true, - "exact_head_evidence_required": true - }, - "validation_execution": { - "classes": ["agent_local", "remote_automated", "real_environment"], - "no_human_runner_for_automatable_gates": true, - "remote_automation_required_when_agent_local_unavailable": true - }, - "validation_profiles": { - "default": "auto", - "profiles": ["lean", "scoped", "strong", "full"], - "selector": "python3 scripts/select_validation_profile.py --base --head ", - "unknown_executable_paths_fail_safe": true, - "selector_changes_force_full": true, - "promotion_validation_full": true, - "automatic_escalation_allowed": true, - "silent_downgrade_below_auto_forbidden": true, - "report_selected_profile_and_reason": true - }, - "remote_preflight": { - "status": "required", - "trigger": ".github/workflows/preflight.yml on pull_request; checks out and validates the exact PR head SHA with read-only contents permission", - "default_profile": "auto", - "stronger_profile_override_allowed": true, - "weaker_profile_override_requires_explicit_justification": true, - "exact_head_required": true, - "trusted_requesters_only": true, - "same_repository_prs_only_by_default": true, - "execution_job_write_credentials": false, - "report_result_to_pr": true - }, - "end_to_end": { - "recommended_when_full_workflow_boundary_exists": true, - "critical_journeys_prioritized": true, - "lower_level_tests_remain_primary": true, - "use_stack_native_tooling": true, - "run_against_built_artifact_when_material": true, - "failure_evidence_bounded": true, - "zero_residue_required": true - }, - "build_identity": { - "unique_per_build": true, - "source_revision_required": true, - "dirty_state_required": true, - "lineage_fields": ["project", "platform", "architecture", "channel", "variant"], - "artifact_name_fields": ["product", "product_version", "build_id", "source_revision"] - }, - "artifact_lifecycle": { - "immutable_successful_artifacts": true, - "promote_only_after_success": true, - "manifest_required": true, - "manifest_name": "build-manifest.json", - "checksum_algorithm": "sha256", - "local_keep_successful_per_lineage": 2, - "ci_store": "github-actions-or-equivalent", - "ci_retention_days": 7, - "release_store": "github-releases-or-equivalent", - "release_artifacts_immutable": true - }, - "build_delta": { - "required": true, - "compare_to": "previous-successful-comparable-build", - "output": "BUILD_CHANGELOG.md", - "bundle_with_artifact": true, - "dimensions": [ - "source", - "dependencies", - "toolchain", - "configuration", - "compatibility_migrations", - "artifact_metrics", - "validation" - ] - }, - "local_runtime": { - "applicable": true, - "bind_default": "loopback", - "port_strategy": "configurable-with-collision-check", - "foreground_default": true, - "readiness_required": true, - "graceful_shutdown_required": true, - "verify_no_project_listener_after_stop": true - }, - "ephemeral_resources": { - "run_identity": true, - "isolated_workspace": true, - "cleanup_paths": ["success", "failure", "timeout", "cancellation", "interrupt", "partial-initialization"], - "stale_resource_recovery": true, - "ownership_required_before_cleanup": true, - "post_cleanup_verification": true - } + "development_velocity": { + "default_stage": "iteration", "stages": ["iteration", "integration", "release"], + "iteration": {"target_feedback_minutes": 3, "exact_head_required": false, "full_diff_review_required": false, "durable_documentation_current_required": false, "remote_preflight_required": false, "e2e_default": "risk_only"}, + "integration": {"target_feedback_minutes": 8, "exact_head_required": true, "full_diff_review_required": true, "durable_documentation_current_required": true, "remote_preflight_when_required_gates_unavailable_local": true, "e2e_default": "affected_critical_journeys", "automated_e2e_required_when_affected": true, "real_environment_blocking": false, "real_environment_deferred_to_release": true}, + "release": {"exact_head_required": true, "full_diff_review_required": true, "durable_documentation_current_required": true, "full_validation_required": true, "e2e_default": "release_critical_journeys", "required_real_environment_blocking": true}, + "parallel_development_prefers_early_convergence": true, "stacked_publication_exception_only": true + }, + "publication_gate": {"applies_from_stage": "integration", "agent_preflight_required": true, "target_base_freshness_required": true, "full_diff_review_required": true, "material_ambiguity_must_be_resolved": true, "failure_root_cause_required": true, "execution_capability_classification_required": true, "blast_radius_profile_selection_required": true, "automatable_gates_must_not_be_delegated_to_user": true, "remote_automated_fallback_required_when_agent_local_unavailable": true, "deterministic_ci_command_parity_required": true, "non_automated_evidence_must_be_declared": true, "exact_head_evidence_required": true}, + "validation_execution": {"classes": ["agent_local", "remote_automated", "real_environment"], "no_human_runner_for_automatable_gates": true, "remote_automation_required_when_agent_local_unavailable": true}, + "validation_profiles": {"default": "auto", "profiles": ["lean", "scoped", "strong", "full"], "selector": "python3 scripts/select_validation_profile.py --base --head --stage ", "selector_output": "risk_dimensions_and_required_gates", "profiles_are_shorthand": true, "gate_selection_preferred_over_suite_selection": true, "unknown_executable_paths_fail_safe": true, "selector_changes_force_full": true, "promotion_validation_full": true, "automatic_escalation_allowed": true, "silent_downgrade_below_auto_forbidden": true, "report_selected_profile_and_reason": true}, + "remote_preflight": {"status": "required", "trigger": ".github/workflows/preflight.yml validates pull requests and integrated pushes using exact-head or safe tree-equivalent evidence", "default_profile": "auto", "stronger_profile_override_allowed": true, "weaker_profile_override_requires_explicit_justification": true, "exact_head_required": true, "reuse_successful_equivalent_evidence": true, "rerun_only_when_missing_stale_or_insufficient": true, "evidence_identity_fields": ["head", "source_tree", "target_base", "required_gates", "profile", "e2e_environment"], "post_merge_tree_equivalent_reuse_allowed": true, "post_merge_tree_reuse_requires_same_target_base": true, "direct_push_without_equivalent_evidence_must_validate": true, "trusted_requesters_only": true, "same_repository_prs_only_by_default": true, "execution_job_write_credentials": false, "report_result_to_pr": true}, + "end_to_end": {"recommended_when_full_workflow_boundary_exists": true, "critical_journeys_prioritized": true, "lower_level_tests_remain_primary": true, "use_stack_native_tooling": true, "run_against_built_artifact_when_material": true, "failure_evidence_bounded": true, "zero_residue_required": true, "ui_evidence_modes": ["assertions", "screenshots", "full_media"], "ui_evidence_selection": "risk_based", "incidental_ui_does_not_force_full_media": true, "full_media_for_motion_timing_sequence_or_release_claims": true}, + "validation_economics": {"status": "recommended", "optimize_for": "sufficient-confidence-per-feedback-time", "dimensions": ["duration", "flake_rate", "unique_regression_signal", "overlap"], "periodic_review": true}, + "build_identity": {"unique_per_build": true, "source_revision_required": true, "dirty_state_required": true, "lineage_fields": ["project", "platform", "architecture", "channel", "variant"], "artifact_name_fields": ["product", "product_version", "build_id", "source_revision"]}, + "artifact_lifecycle": {"immutable_successful_artifacts": true, "promote_only_after_success": true, "manifest_required": true, "manifest_name": "build-manifest.json", "checksum_algorithm": "sha256", "local_keep_successful_per_lineage": 2, "ci_store": "github-actions-or-equivalent", "ci_retention_days": 7, "release_store": "github-releases-or-equivalent", "release_artifacts_immutable": true}, + "build_delta": {"required": true, "compare_to": "previous-successful-comparable-build", "output": "BUILD_CHANGELOG.md", "bundle_with_artifact": true, "dimensions": ["source", "dependencies", "toolchain", "configuration", "compatibility_migrations", "artifact_metrics", "validation"]}, + "local_runtime": {"applicable": true, "bind_default": "loopback", "port_strategy": "configurable-with-collision-check", "foreground_default": true, "readiness_required": true, "graceful_shutdown_required": true, "verify_no_project_listener_after_stop": true}, + "ephemeral_resources": {"run_identity": true, "isolated_workspace": true, "cleanup_paths": ["success", "failure", "timeout", "cancellation", "interrupt", "partial-initialization"], "stale_resource_recovery": true, "ownership_required_before_cleanup": true, "post_cleanup_verification": true}, + "agent_reporting": {"schema_version": 1, "format": "summary_with_evidence_references", "required_summary_fields": ["stage", "source_identity", "risks", "profile", "required_gates", "evidence", "remaining_gaps", "next_action"], "gate_fields": ["id", "reason", "executor", "status"], "source_identity_fields": ["head", "source_tree", "target_base", "dirty"], "bounded_output": true, "full_report_on_demand": true, "preserve_failed_pending_gates": true, "summary_is_not_evidence_verification": true} } diff --git a/.engineering/documentation-policy.json b/.engineering/documentation-policy.json index 85249edd..4f181e75 100644 --- a/.engineering/documentation-policy.json +++ b/.engineering/documentation-policy.json @@ -1,22 +1,18 @@ { - "schema_version": 1, + "schema_version": 2, "estimated_token_characters": 4, - "budgets": { - "root_agents": {"max_lines": 180, "max_estimated_tokens": 2500}, - "scoped_agents": {"max_lines": 150, "max_estimated_tokens": 2000}, - "current_state": {"max_lines": 120, "max_estimated_tokens": 1500}, - "active_workstream": {"max_lines": 220, "max_estimated_tokens": 3000}, - "architecture": {"max_lines": 1000, "max_estimated_tokens": 12000}, - "feature_doc": {"max_lines": 400, "max_estimated_tokens": 5500} - }, - "context_targets": { - "bootstrap_max_estimated_tokens": 2500, - "root_scoped_workstream_max_estimated_tokens": 6000 - }, + "budgets": {"root_agents": {"max_lines": 120, "max_estimated_tokens": 1200}, "scoped_agents": {"max_lines": 150, "max_estimated_tokens": 2000}, "current_state": {"max_lines": 120, "max_estimated_tokens": 1500}, "active_workstream": {"max_lines": 220, "max_estimated_tokens": 3000}, "architecture": {"max_lines": 1000, "max_estimated_tokens": 12000}, "feature_doc": {"max_lines": 400, "max_estimated_tokens": 5500}}, + "context_targets": {"bootstrap_max_estimated_tokens": 1200, "root_scoped_workstream_max_estimated_tokens": 6000}, "duplicate_min_characters": 240, - "completed_workstream_markers": [ - "Status: complete", - "Status: completed", - "Status: done" - ] + "completed_workstream_markers": ["Status: complete", "Status: completed", "Status: done"], + "context_exclude_directories": [".git", "node_modules", ".venv", "venv", ".gradle", ".build", "build", "dist", "frontend/node_modules", "__pycache__"], + "context_routes": { + "docs": {"read_when": "Pure documentation/copy without executable or policy changes.", "files": ["AGENTS.md", "docs/README.md"], "include_scoped_guides": true, "include_workstream": false, "max_estimated_tokens": 5000}, + "bug": {"read_when": "Meaningful owner-local bug during iteration.", "files": ["AGENTS.md", ".engineering/commands.json", "skills/structured-change/SKILL.md", "skills/validate-change/SKILL.md"], "include_scoped_guides": true, "include_workstream": false, "max_estimated_tokens": 7000}, + "contract": {"read_when": "Shared API/persistence/runtime/native-audio boundary change; add docs/architecture.md only when system ownership/topology itself is material.", "files": ["AGENTS.md", ".engineering/commands.json", "skills/structured-change/SKILL.md", "skills/validate-change/SKILL.md"], "include_scoped_guides": true, "include_workstream": false, "max_estimated_tokens": 10000}, + "ui": {"read_when": "Material ClosedRoom frontend/WKWebView UI iteration; add brand-kit for visual identity and use the integration route when preparing E2E evidence.", "files": ["AGENTS.md", ".engineering/commands.json", "skills/structured-change/SKILL.md", "skills/validate-change/SKILL.md", "skills/design-product-experience/SKILL.md", "design/ux-contract.json"], "include_scoped_guides": true, "include_workstream": false, "max_estimated_tokens": 12000, "requires_profile": "product-ui"}, + "integration": {"read_when": "Candidate readiness including remote deterministic preflight.", "files": ["AGENTS.md", ".engineering/commands.json", "skills/preflight-change/SKILL.md", ".engineering/e2e.json", "skills/remote-preflight/SKILL.md", "docs/README.md"], "include_scoped_guides": true, "include_workstream": false, "max_estimated_tokens": 10000}, + "release": {"read_when": "Release readiness including target-Mac evidence.", "files": ["AGENTS.md", ".engineering/commands.json", "skills/preflight-change/SKILL.md", ".engineering/e2e.json", "skills/remote-preflight/SKILL.md", "docs/README.md"], "include_scoped_guides": true, "include_workstream": false, "max_estimated_tokens": 10000}, + "resume": {"read_when": "Resume a multi-session bug/implementation workstream.", "files": ["AGENTS.md", ".engineering/commands.json", "skills/structured-change/SKILL.md", "skills/validate-change/SKILL.md"], "include_scoped_guides": true, "include_workstream": true, "max_estimated_tokens": 9500} + } } diff --git a/.engineering/e2e.json b/.engineering/e2e.json index 57cceebd..8484df21 100644 --- a/.engineering/e2e.json +++ b/.engineering/e2e.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "contract_version": "0.1.0", + "contract_version": "0.2.1", "applicability": { "status": "recommended", "reason": "ClosedRoom crosses FastAPI, persisted meeting state, a React/WKWebView UI, macOS native capture helpers and local AI runtimes; lower-level tests alone cannot prove every critical assembled workflow." @@ -11,28 +11,36 @@ "lowest_sufficient_test_level": true, "critical_journeys_only": true, "built_artifact_when_material": true, - "residual_fidelity_gaps_explicit": true + "residual_fidelity_gaps_explicit": true, + "ui_evidence_risk_based": true }, - "fidelity_order": [ - "host_or_fake", - "simulated_or_emulated", - "representative_virtual", - "representative_physical", - "target_environment" - ], + "stage_policy": { + "integration": { + "automated_e2e_before_shared_integration": true, + "real_environment_blocking": false, + "real_environment_deferred_to_release": true, + "material_ui_journey_minimum_evidence_mode": "full_media", + "incidental_ui_may_use_assertions": true + }, + "release": { + "full_validation_required": true, + "release_critical_e2e_required": true, + "required_real_environment_blocking": true + } + }, + "ui_evidence": { + "modes": ["assertions", "screenshots", "full_media"], + "default_mode": "screenshots", + "assertions_allowed_when_ui_incidental": true, + "full_media_triggers": ["material_ui_integration_outcome", "motion_or_animation", "timing_or_progression", "navigation_or_transition_sequence", "lifecycle_visibility", "release_acceptance"] + }, + "fidelity_order": ["host_or_fake", "simulated_or_emulated", "representative_virtual", "representative_physical", "target_environment"], "target_environments": [ { "id": "closedroom-macos-apple-silicon", "platform": "macOS Apple Silicon", "description": "Supported ClosedRoom desktop target using the packaged app, loopback FastAPI runtime, WKWebView, native audio capture helpers and local MLX/FluidAudio runtimes.", - "material_dimensions": [ - "macOS version and TCC permissions", - "arm64 Apple Silicon architecture", - "packaged .app and native helper layout", - "microphone and system-audio capture", - "MLX/Metal and local model compatibility", - "loopback server lifecycle and persistence" - ] + "material_dimensions": ["macOS version and TCC permissions", "arm64 Apple Silicon architecture", "packaged .app and native helper layout", "microphone and system-audio capture", "MLX/Metal and local model compatibility", "loopback server lifecycle and persistence"] } ], "execution_environments": [ @@ -43,50 +51,57 @@ "platform": "Python test host", "artifact_surface": "source checkout with FastAPI/TestClient, persisted test stores, deterministic fixtures and mocked native/model boundaries", "target_environment_refs": ["closedroom-macos-apple-silicon"], - "known_gaps": [ - "does not exercise the packaged .app or WKWebView process boundary", - "does not prove real macOS TCC permissions or physical audio-device behavior", - "does not prove production MLX/Metal model memory, latency or throughput" - ] + "known_gaps": ["does not exercise the packaged .app or WKWebView process boundary", "does not prove real macOS TCC permissions or physical audio-device behavior", "does not prove production MLX/Metal model memory, latency or throughput"] + }, + { + "id": "browser-macos-arm64-ci", + "fidelity_class": "simulated_or_emulated", + "automation": "automated", + "platform": "GitHub-hosted macOS arm64 with headless Chrome", + "command": "node scripts/browser_meeting_ui_e2e.mjs", + "artifact_surface": "real React source served by Vite, deterministic loopback API fixtures for saved-Meeting, preparation-recovery, verified-note-edit revision, complete archive-search and busy-AI recording-priority journeys, plus a synthetic demo-mode coherent-workspace journey across Today, Meeting and Projects with wide/compact/narrow resize assertions; ChromeDriver interaction, bounded viewport screenshots and frame-assembled MP4 evidence", + "target_environment_refs": ["closedroom-macos-apple-silicon"], + "known_gaps": ["does not exercise the packaged WKWebView process boundary", "deterministic API/demo fixtures do not prove the assembled FastAPI persistence/runtime path", "does not prove TCC, native capture, physical audio devices or production MLX/Metal behavior"] }, { "id": "packaged-macos-arm64-ci", "fidelity_class": "representative_virtual", "automation": "automated", "platform": "GitHub-hosted macOS arm64", - "artifact_surface": "the finalized ad-hoc-signed ClosedRoom .app; its frozen executable serves the bundled FastAPI runtime and static frontend through the real bundle layout", + "artifact_surface": "finalized ad-hoc-signed ClosedRoom .app serving bundled FastAPI runtime and static frontend through the real bundle layout; lifecycle smoke also probes the authenticated meeting-search path to verify bundled SQLite FTS5 availability", "target_environment_refs": ["closedroom-macos-apple-silicon"], - "known_gaps": [ - "non-Cocoa smoke does not assert interactive WKWebView rendering or window behavior", - "GitHub-hosted macOS does not prove user TCC prompts or physical microphone/system-audio devices", - "ad-hoc signing does not prove production signing/notarization identity", - "production MLX/Metal model quality and representative memory/performance remain separate evidence" - ] + "known_gaps": ["non-Cocoa smoke does not assert interactive WKWebView rendering or window behavior", "hosted macOS does not prove user TCC prompts or physical audio devices", "ad-hoc signing does not prove production signing/notarization", "production MLX/Metal quality and representative performance remain separate evidence"] }, { - "id": "target-macos-manual", + "id": "target-macos-real", "fidelity_class": "target_environment", "automation": "real_environment", "platform": "macOS Apple Silicon", - "artifact_surface": "built ClosedRoom .app with interactive WKWebView, native helpers, real permissions/audio devices and representative local models", + "command": "python3 scripts/real_environment_ui_evidence.py --build", + "artifact_surface": "successful finalized ClosedRoom .app for the exact clean checkout; built only when absent and then reused unchanged across TCC permission reruns, with isolated HOME, real WKWebView accessibility automation, TCC-backed capture, persistence, screenshots and journey recording", "target_environment_refs": ["closedroom-macos-apple-silicon"], - "known_gaps": [] + "known_gaps": ["VoiceOver spoken-output quality and subjective usability still require human judgement", "ad-hoc signing is stable only while the same finalized app is reused; production signing/notarization remains separate evidence", "production MLX/Metal inference quality and performance remain separate evidence"] } ], "critical_journeys": [ { "id": "recording-persistence", "claim": "A recording can be created, receive ordered chunks, finalize, update catalog state and remain discoverable after the owning store is reopened.", + "ui_surface": false, + "minimum_ui_evidence_mode": "assertions", "target_environment_refs": ["closedroom-macos-apple-silicon"], "automated_environment_refs": ["source-contract-suite"], "minimum_automated_fidelity": "host_or_fake", "real_environment_confirmation": "conditional", - "residual_gaps": ["real native capture and permission behavior are outside this source-level claim"], + "real_environment_runner": "target-macos-real", + "residual_gaps": ["real native capture and permission behavior are confirmed by target-macos-real when material"], "automation_gap_reason": "" }, { "id": "transcription-workflow", "claim": "The public transcription workflow coordinates request/job state, transcription service behavior and persistence without losing or falsely completing work.", + "ui_surface": false, + "minimum_ui_evidence_mode": "assertions", "target_environment_refs": ["closedroom-macos-apple-silicon"], "automated_environment_refs": ["source-contract-suite"], "minimum_automated_fidelity": "host_or_fake", @@ -97,26 +112,119 @@ { "id": "native-capture-recovery", "claim": "Native capture capability, permission and lifecycle contracts fail safely and restore run-owned state across stop and error paths.", + "ui_surface": false, + "minimum_ui_evidence_mode": "assertions", "target_environment_refs": ["closedroom-macos-apple-silicon"], "automated_environment_refs": ["source-contract-suite"], "minimum_automated_fidelity": "host_or_fake", "real_environment_confirmation": "required", - "residual_gaps": ["TCC prompts, ScreenCaptureKit/AVFoundation behavior and actual microphone/system-audio devices"], + "real_environment_runner": "target-macos-real", + "residual_gaps": ["TCC prompts, ScreenCaptureKit/AVFoundation behavior and physical audio devices require target-macos-real evidence"], "automation_gap_reason": "" }, { "id": "packaged-app-lifecycle", - "claim": "A finalized ClosedRoom .app contains a runnable frozen executable and bundled frontend, reaches loopback health/readiness through that executable, then stops gracefully without leaving its listener or observed child processes.", + "claim": "A finalized ClosedRoom .app is runnable, reaches loopback readiness, exposes the archive-search endpoint with bundled SQLite FTS5, and stops without leaking its listener or observed child processes.", + "ui_surface": false, + "minimum_ui_evidence_mode": "assertions", "target_environment_refs": ["closedroom-macos-apple-silicon"], "automated_environment_refs": ["packaged-macos-arm64-ci"], "minimum_automated_fidelity": "representative_virtual", "real_environment_confirmation": "conditional", - "residual_gaps": [ - "interactive WKWebView/window rendering and focus behavior", - "TCC identity/prompts and physical microphone/system-audio capture", - "production signing/notarization and production MLX/Metal performance" - ], + "real_environment_runner": "target-macos-real", + "residual_gaps": ["interactive WKWebView/focus and TCC/native-audio behavior require target-macos-real when material", "production signing and MLX/Metal performance remain separate evidence"], + "automation_gap_reason": "" + }, + { + "id": "saved-meeting-fast-open", + "claim": "A saved Meeting renders core transcript content without waiting for diagnostics or visual routes, keeps core content visible through accessory loading/failure, and recovers each accessory independently on retry.", + "ui_surface": true, + "minimum_ui_evidence_mode": "full_media", + "target_environment_refs": ["closedroom-macos-apple-silicon"], + "automated_environment_refs": ["browser-macos-arm64-ci"], + "minimum_automated_fidelity": "simulated_or_emulated", + "real_environment_confirmation": "conditional", + "real_environment_runner": "target-macos-real", + "residual_gaps": ["packaged WKWebView rendering remains a release confirmation when the saved-Meeting interaction is release-critical", "TCC/native capture and model fidelity are outside this journey"], + "automation_gap_reason": "" + }, + { + "id": "meeting-preparation-recovery", + "claim": "A user can prepare notes with one durable action, read the transcript before notes finish, reconnect to the same parent work, preserve the transcript through notes failure, resume without rerunning ASR, and land on ready notes when the retry completes.", + "ui_surface": true, + "minimum_ui_evidence_mode": "full_media", + "target_environment_refs": ["closedroom-macos-apple-silicon"], + "automated_environment_refs": ["browser-macos-arm64-ci"], + "minimum_automated_fidelity": "simulated_or_emulated", + "real_environment_confirmation": "conditional", + "real_environment_runner": "target-macos-real", + "residual_gaps": ["deterministic browser fixtures do not prove production ASR/LLM quality or latency", "packaged WKWebView rendering remains a release confirmation when this journey is release-critical"], + "automation_gap_reason": "" + }, + { + "id": "meeting-note-edit-revision", + "claim": "A user can open source evidence for generated notes, edit an action or decision, keep that correction across reload/restart, regenerate notes as a new revision, see ambiguous carry as an explicit conflict and resolve it deliberately without silent remapping.", + "ui_surface": true, + "minimum_ui_evidence_mode": "full_media", + "target_environment_refs": ["closedroom-macos-apple-silicon"], + "automated_environment_refs": ["browser-macos-arm64-ci"], + "minimum_automated_fidelity": "simulated_or_emulated", + "real_environment_confirmation": "conditional", + "real_environment_runner": "target-macos-real", + "residual_gaps": ["deterministic browser fixtures do not prove the assembled FastAPI/CatalogStore restart path, which remains source-contract evidence at integration", "packaged WKWebView focus and accessibility remain release confirmation when material", "production LLM quality and revision frequency are outside this journey"], + "automation_gap_reason": "" + }, + { + "id": "meeting-archive-search", + "claim": "A user can search the complete local meeting archive beyond the Today preview, open a matching source meeting, navigate back and recover the query without loading the whole archive into React.", + "ui_surface": true, + "minimum_ui_evidence_mode": "full_media", + "target_environment_refs": ["closedroom-macos-apple-silicon"], + "automated_environment_refs": ["browser-macos-arm64-ci", "source-contract-suite", "packaged-macos-arm64-ci"], + "minimum_automated_fidelity": "simulated_or_emulated", + "real_environment_confirmation": "conditional", + "real_environment_runner": "target-macos-real", + "residual_gaps": ["browser fixtures do not prove the assembled SQLite projection, which is covered by source-contract and packaged FTS5 evidence", "packaged WKWebView focus behavior remains release confirmation when material"], + "automation_gap_reason": "" + }, + { + "id": "record-while-ai-busy", + "claim": "When managed AI work is already active, starting a meeting reserves capture priority without force-killing the active phase, shows a truthful cancellable preparation state, starts capture only after the safe boundary, holds queued heavy work during recording, and releases priority on Stop so queued work can resume.", + "ui_surface": true, + "minimum_ui_evidence_mode": "full_media", + "target_environment_refs": ["closedroom-macos-apple-silicon"], + "automated_environment_refs": ["browser-macos-arm64-ci", "source-contract-suite"], + "minimum_automated_fidelity": "simulated_or_emulated", + "real_environment_confirmation": "required", + "real_environment_runner": "target-macos-real", + "residual_gaps": ["browser fixtures prove user-visible ordering but not physical microphone/system-audio capture", "source-contract tests prove scheduler ordering but not representative MLX/Metal pressure or thermal behavior", "packaged WKWebView and TCC-backed capture remain target-Mac release evidence"], + "automation_gap_reason": "" + }, + { + "id": "coherent-macos-workspace", + "claim": "Today, a saved Meeting and Projects share one stable application hierarchy across wide, compact and narrow windows: Meeting remains a child of Today, Projects is the second peer destination, New Meeting remains the persistent primary action, utility controls remain secondary, theme continuity survives navigation and the layout does not introduce page-wide horizontal overflow.", + "ui_surface": true, + "minimum_ui_evidence_mode": "full_media", + "target_environment_refs": ["closedroom-macos-apple-silicon"], + "automated_environment_refs": ["browser-macos-arm64-ci"], + "minimum_automated_fidelity": "simulated_or_emulated", + "real_environment_confirmation": "required", + "real_environment_runner": "target-macos-real", + "residual_gaps": ["headless Chrome proves responsive hierarchy and interaction semantics but not the packaged WKWebView process boundary", "window chrome, keyboard/focus behavior, reduced-motion behavior and VoiceOver quality require target-macos-real release evidence", "this journey uses synthetic demo data and does not claim persistence/runtime fidelity"], "automation_gap_reason": "" + }, + { + "id": "meeting-recording-ui", + "claim": "A user can create a meeting, observe ready and active recording states, stop and save, and reach the persisted workspace through the real WKWebView and native capture path.", + "ui_surface": true, + "minimum_ui_evidence_mode": "full_media", + "target_environment_refs": ["closedroom-macos-apple-silicon"], + "automated_environment_refs": [], + "minimum_automated_fidelity": "host_or_fake", + "real_environment_confirmation": "required", + "real_environment_runner": "target-macos-real", + "residual_gaps": ["VoiceOver spoken-output quality and subjective usability remain human evidence", "production signing/notarization and MLX/Metal inference quality remain outside this journey"], + "automation_gap_reason": "Interactive WKWebView plus TCC-backed microphone/system-audio capture requires the target Mac; integration uses affected automated frontend/source/package evidence and carries this residual target-environment delta to release." } ] } diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 79121c45..a2807ddc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,7 +12,23 @@ Describe the user/system outcome and the owning boundary changed. - Owning source(s): - Public/persistence/security/runtime/UI contract impact: -- Documentation/design contracts updated or N/A: + +## Documentation impact + +Classify each owner as `UPDATED` or `N/A`; give a short reason when impact was plausible but is `N/A`. + +- README_IDENTITY: +- README_USAGE: +- FEATURE_DOCS: +- ARCHITECTURE: +- ADR: +- SECURITY_DATA: +- OPERATIONS: +- PRODUCT_EXPERIENCE: +- CURRENT_STATE: +- DOCS_CURRENT_WITH_IMPLEMENTATION: PASS / FAIL + +README identity means purpose/audience/outcome/positioning. README usage means prerequisites/setup/run/configuration/public API/UI/examples. A usage-only change must not trigger an opportunistic mission rewrite. ## Validation @@ -29,6 +45,7 @@ If E2E applies, record the journey, `.engineering/e2e.json` environment ID, fide - [ ] Material ambiguity resolved - [ ] Intended target base/head identity checked - [ ] Complete diff reviewed for unrelated/generated/private residue +- [ ] Documentation impact assessed and every affected canonical owner is current - [ ] Required deterministic gates for the selected profile passed or are explicitly routed - [ ] Failure root causes were diagnosed rather than suppressed - [ ] Cleanup/residue expectations are satisfied for executed runtime/E2E/build work diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index f4c1671a..e46b37ee 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -8,6 +8,7 @@ on: branches: [dev, main] permissions: + actions: read contents: read concurrency: @@ -16,82 +17,75 @@ concurrency: jobs: select: - name: Select validation profile + name: Validation scope and repository guards runs-on: ubuntu-latest outputs: + stage: ${{ steps.stage.outputs.stage }} profile: ${{ steps.profile.outputs.profile }} reason: ${{ steps.profile.outputs.reason }} + risk_dimensions: ${{ steps.profile.outputs.risk_dimensions }} + required_gates: ${{ steps.profile.outputs.required_gates }} head_sha: ${{ steps.identity.outputs.head_sha }} base_sha: ${{ steps.identity.outputs.base_sha }} + source_tree: ${{ steps.identity.outputs.source_tree }} steps: - - name: Check out exact head + - name: Resolve delivery stage + id: stage + shell: bash + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + if [[ "${{ github.base_ref }}" == "main" ]]; then stage=release + elif [[ "${{ github.event.pull_request.draft }}" == "true" ]]; then stage=iteration + else stage=integration + fi + elif [[ "${{ github.ref_name }}" == "main" ]]; then stage=release + else stage=integration + fi + echo "stage=$stage" >> "$GITHUB_OUTPUT" + - name: Check out exact source head uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 - name: Enforce canonical branch flow - env: - EVENT_NAME: ${{ github.event_name }} - BASE_REF: ${{ github.base_ref }} - HEAD_REF: ${{ github.head_ref }} + if: github.event_name == 'pull_request' + shell: bash run: | set -euo pipefail - if [[ "$EVENT_NAME" != "pull_request" ]]; then - exit 0 + if [[ "${{ github.base_ref }}" == "dev" && "${{ github.head_ref }}" == "main" ]]; then + echo "main must not flow back into dev" >&2; exit 1 fi - case "$BASE_REF" in - dev) - if [[ "$HEAD_REF" == "main" ]]; then - echo "main is the stable branch and must not flow back into dev via PR." >&2 - exit 1 - fi - ;; - main) - if [[ "$HEAD_REF" != "dev" ]]; then - echo "Stable promotion must be dev -> main; got $HEAD_REF -> main." >&2 - exit 1 - fi - ;; - *) - echo "Canonical PR targets are dev and main; got $BASE_REF." >&2 - exit 1 - ;; - esac - - name: Resolve exact base/head + if [[ "${{ github.base_ref }}" == "main" && "${{ github.head_ref }}" != "dev" ]]; then + echo "Stable promotion must be dev -> main" >&2; exit 1 + fi + - name: Resolve validation identity id: identity - env: - EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} - EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + shell: bash run: | set -euo pipefail - HEAD_SHA="${EVENT_HEAD_SHA:-$(git rev-parse HEAD)}" - BASE_SHA="${EVENT_BASE_SHA:-$(git rev-parse HEAD^)}" + HEAD_SHA="${{ github.event.pull_request.head.sha || github.sha }}" + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.before }}" + fi test "$(git rev-parse HEAD)" = "$HEAD_SHA" echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" echo "base_sha=$BASE_SHA" >> "$GITHUB_OUTPUT" - - name: Select blast-radius profile + echo "source_tree=$(git rev-parse HEAD^{tree})" >> "$GITHUB_OUTPUT" + - name: Select risks and required gates id: profile + shell: bash run: | python3 scripts/select_validation_profile.py \ --base "${{ steps.identity.outputs.base_sha }}" \ --head "${{ steps.identity.outputs.head_sha }}" \ + --stage "${{ steps.stage.outputs.stage }}" \ --format github >> "$GITHUB_OUTPUT" python3 scripts/select_validation_profile.py \ --base "${{ steps.identity.outputs.base_sha }}" \ - --head "${{ steps.identity.outputs.head_sha }}" - - governance: - name: Governance / ${{ needs.select.outputs.profile }} - needs: select - runs-on: ubuntu-latest - steps: - - name: Check out exact head - uses: actions/checkout@v4 - with: - ref: ${{ needs.select.outputs.head_sha }} - fetch-depth: 0 - - name: Verify exact head - run: test "$(git rev-parse HEAD)" = "${{ needs.select.outputs.head_sha }}" + --head "${{ steps.identity.outputs.head_sha }}" \ + --stage "${{ steps.stage.outputs.stage }}" - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -100,6 +94,7 @@ jobs: python3 scripts/verify_repository.py python3 scripts/verify_operations.py python3 scripts/verify_e2e.py + python3 scripts/verify_stage_environment_policy.py python3 scripts/verify_product_experience.py python3 scripts/verify_docs.py python3 scripts/verify_agent_context.py @@ -108,21 +103,61 @@ jobs: python3 -m unittest discover -s test -p 'test_validation_profile_selector.py' -v python3 -m unittest discover -s test -p 'test_build_artifact_metadata.py' -v + reuse: + name: Reuse equivalent validation evidence + needs: select + if: needs.select.outputs.stage != 'iteration' + runs-on: ubuntu-latest + outputs: + reused: ${{ steps.find.outputs.reused }} + reuse_kind: ${{ steps.find.outputs.reuse_kind }} + source_run_id: ${{ steps.find.outputs.source_run_id }} + steps: + - name: Find reusable evidence + id: find + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + HEAD_SHA: ${{ needs.select.outputs.head_sha }} + SOURCE_TREE: ${{ needs.select.outputs.source_tree }} + BASE_SHA: ${{ needs.select.outputs.base_sha }} + PROFILE: ${{ needs.select.outputs.profile }} + REQUIRED_GATES: ${{ needs.select.outputs.required_gates }} + shell: bash + run: | + set -euo pipefail + gate_key=$(printf '%s' "$REQUIRED_GATES" | sha256sum | cut -c1-16) + if [[ "$EVENT_NAME" == "push" && "$REF_NAME" == "dev" ]]; then + name="validation-tree-evidence-${SOURCE_TREE}-${BASE_SHA}-${PROFILE}-${gate_key}" + reuse_kind=tree-equivalent + elif [[ "$EVENT_NAME" == "pull_request" ]]; then + name="validation-evidence-${HEAD_SHA}-${BASE_SHA}-${PROFILE}-${gate_key}" + reuse_kind=exact-head + else + echo "reused=false" >> "$GITHUB_OUTPUT"; echo "reuse_kind=none" >> "$GITHUB_OUTPUT"; exit 0 + fi + artifact=$(gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts?name=${name}&per_page=20" --jq '.artifacts | map(select(.expired == false)) | sort_by(.created_at) | reverse | .[0] // empty') + if [[ -z "$artifact" ]]; then + echo "reused=false" >> "$GITHUB_OUTPUT"; echo "reuse_kind=none" >> "$GITHUB_OUTPUT"; exit 0 + fi + echo "reused=true" >> "$GITHUB_OUTPUT" + echo "reuse_kind=$reuse_kind" >> "$GITHUB_OUTPUT" + echo "source_run_id=$(jq -r '.workflow_run.id // empty' <<<"$artifact")" >> "$GITHUB_OUTPUT" + echo "Reusing $reuse_kind validation evidence: $name" + tests: - name: Source tests / ${{ needs.select.outputs.profile }} - needs: [select, governance] - if: needs.select.outputs.profile != 'lean' + name: Source tests / ${{ needs.select.outputs.stage }} / ${{ needs.select.outputs.profile }} + needs: [select, reuse] + if: >- + ${{ always() && contains(needs.select.outputs.required_gates, 'source-tests') && needs.reuse.outputs.reused != 'true' }} runs-on: macos-14 steps: - - name: Check out exact head - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: ref: ${{ needs.select.outputs.head_sha }} fetch-depth: 0 - - name: Verify arm64 exact head - run: | - test "$(git rev-parse HEAD)" = "${{ needs.select.outputs.head_sha }}" - test "$(uname -m)" = "arm64" + - run: test "$(git rev-parse HEAD)" = "${{ needs.select.outputs.head_sha }}" && test "$(uname -m)" = "arm64" - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -131,47 +166,46 @@ jobs: brew list uv >/dev/null 2>&1 || brew install uv npm install --global pnpm@9 uv sync --all-extras --frozen --python 3.12 - cd frontend - pnpm install --frozen-lockfile + cd frontend && pnpm install --frozen-lockfile - name: Frontend deterministic checks - run: | - cd frontend - pnpm run lint - pnpm exec tsc --noEmit + run: cd frontend && pnpm run lint && pnpm exec tsc --noEmit - name: Python unit and integration suite env: UV_CACHE_DIR: .cache/uv run: uv run --frozen --python 3.12 python -m unittest discover -s test -v + - name: Saved Meeting browser FULL_MEDIA journey + if: contains(needs.select.outputs.required_gates, 'browser-e2e') + env: + E2E_SOURCE_REVISION: ${{ needs.select.outputs.head_sha }} + run: | + brew list ffmpeg >/dev/null 2>&1 || brew install ffmpeg + node scripts/browser_meeting_ui_e2e.mjs + - name: Upload saved Meeting browser evidence + if: ${{ always() && contains(needs.select.outputs.required_gates, 'browser-e2e') }} + uses: actions/upload-artifact@v4 + with: + name: closedroom-browser-meeting-${{ github.run_id }}-${{ github.run_attempt }} + path: dist/evidence/browser-meeting-ui/** + if-no-files-found: error + retention-days: 7 packaged-app: - name: Packaged app / ${{ needs.select.outputs.profile }} - needs: [select, governance, tests] + name: Packaged app / ${{ needs.select.outputs.stage }} / ${{ needs.select.outputs.profile }} + needs: [select, reuse, tests] if: >- - ${{ - (needs.select.outputs.profile == 'strong' || needs.select.outputs.profile == 'full') && - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - }} + ${{ always() && contains(needs.select.outputs.required_gates, 'packaged-app') && needs.reuse.outputs.reused != 'true' && (needs.tests.result == 'success' || needs.tests.result == 'skipped') && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} runs-on: macos-14 timeout-minutes: 60 env: DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer steps: - - name: Check out exact head - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: ref: ${{ needs.select.outputs.head_sha }} fetch-depth: 0 - - name: Verify arm64 exact head - run: | - test "$(git rev-parse HEAD)" = "${{ needs.select.outputs.head_sha }}" - test "$(uname -m)" = "arm64" + - run: test "$(git rev-parse HEAD)" = "${{ needs.select.outputs.head_sha }}" && test "$(uname -m)" = "arm64" - name: Verify Swift 6 packaging toolchain - run: | - set -euo pipefail - test -d "$DEVELOPER_DIR" - xcodebuild -version - swift --version - swift --version | grep -Eq 'Swift version 6\.' + run: test -d "$DEVELOPER_DIR" && xcodebuild -version && swift --version | grep -Eq 'Swift version 6\.' - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -188,25 +222,71 @@ jobs: run: bash scripts/build_artifact.sh --no-dmg - name: Smoke finalized .app lifecycle run: python3 scripts/smoke_packaged_app.py - - name: Prepare immutable CI archive - id: artifact - run: | - set -euo pipefail - ARTIFACT_DIR="$(python3 -c 'import json; print(json.load(open("dist/last-build.json"))["artifact_dir"])')" - APP="$(python3 -c 'import json; print(json.load(open("dist/last-build.json"))["app"])')" - ZIP="$ARTIFACT_DIR/$(basename "$APP").zip" - ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" - echo "artifact_dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" - echo "build_id=$(python3 -c 'import json; print(json.load(open("dist/last-build.json"))["build_id"])')" >> "$GITHUB_OUTPUT" - name: Upload build + smoke evidence uses: actions/upload-artifact@v4 with: - name: closedroom-${{ steps.artifact.outputs.build_id }} + name: closedroom-packaged-${{ github.run_id }}-${{ github.run_attempt }} path: | - ${{ steps.artifact.outputs.artifact_dir }}/build-manifest.json - ${{ steps.artifact.outputs.artifact_dir }}/BUILD_CHANGELOG.md - ${{ steps.artifact.outputs.artifact_dir }}/SHA256SUMS - ${{ steps.artifact.outputs.artifact_dir }}/packaged-app-smoke.json - ${{ steps.artifact.outputs.artifact_dir }}/*.zip + dist/**/build-manifest.json + dist/**/BUILD_CHANGELOG.md + dist/**/SHA256SUMS + dist/**/packaged-app-smoke.json if-no-files-found: error retention-days: 7 + + validation: + name: Repository validation / ${{ needs.select.outputs.stage }} / ${{ needs.select.outputs.profile }} + needs: [select, reuse, tests, packaged-app] + if: always() + runs-on: ubuntu-latest + steps: + - name: Evaluate validation jobs + env: + REUSED: ${{ needs.reuse.outputs.reused }} + GATES: ${{ needs.select.outputs.required_gates }} + TESTS: ${{ needs.tests.result }} + PACKAGED: ${{ needs.packaged-app.result }} + shell: bash + run: | + set -euo pipefail + if [[ "$REUSED" == "true" ]]; then exit 0; fi + if [[ "$GATES" == *source-tests* && "$TESTS" != "success" ]]; then echo "source-tests gate failed: $TESTS" >&2; exit 1; fi + if [[ "$GATES" == *browser-e2e* && "$TESTS" != "success" ]]; then echo "browser-e2e gate failed: $TESTS" >&2; exit 1; fi + if [[ "$GATES" == *packaged-app* && "$PACKAGED" != "success" ]]; then echo "packaged-app gate failed: $PACKAGED" >&2; exit 1; fi + + evidence: + name: Publish reusable validation evidence + needs: [select, reuse, validation] + if: needs.select.outputs.stage != 'iteration' && needs.validation.result == 'success' && needs.reuse.outputs.reused != 'true' + runs-on: ubuntu-latest + steps: + - name: Create evidence identity + id: identity + env: + HEAD_SHA: ${{ needs.select.outputs.head_sha }} + SOURCE_TREE: ${{ needs.select.outputs.source_tree }} + BASE_SHA: ${{ needs.select.outputs.base_sha }} + STAGE: ${{ needs.select.outputs.stage }} + PROFILE: ${{ needs.select.outputs.profile }} + RISKS: ${{ needs.select.outputs.risk_dimensions }} + GATES: ${{ needs.select.outputs.required_gates }} + shell: bash + run: | + set -euo pipefail + gate_key=$(printf '%s' "$GATES" | sha256sum | cut -c1-16) + e2e_environment=none + if [[ "$GATES" == *browser-e2e* ]]; then e2e_environment=browser-macos-arm64-ci; fi + mkdir -p validation-evidence + printf '{"head":"%s","source_tree":"%s","target_base":"%s","stage":"%s","profile":"%s","risk_dimensions":"%s","required_gates":"%s","e2e_environment":"%s","run_id":"%s"}\n' "$HEAD_SHA" "$SOURCE_TREE" "$BASE_SHA" "$STAGE" "$PROFILE" "$RISKS" "$GATES" "$e2e_environment" "$GITHUB_RUN_ID" > validation-evidence/evidence.json + echo "exact=validation-evidence-${HEAD_SHA}-${BASE_SHA}-${PROFILE}-${gate_key}" >> "$GITHUB_OUTPUT" + echo "tree=validation-tree-evidence-${SOURCE_TREE}-${BASE_SHA}-${PROFILE}-${gate_key}" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 + with: + name: ${{ steps.identity.outputs.exact }} + path: validation-evidence/evidence.json + retention-days: 7 + - uses: actions/upload-artifact@v4 + with: + name: ${{ steps.identity.outputs.tree }} + path: validation-evidence/evidence.json + retention-days: 7 diff --git a/.github/workflows/repository-health.yml b/.github/workflows/repository-health.yml index 72a6ed50..0423ac8f 100644 --- a/.github/workflows/repository-health.yml +++ b/.github/workflows/repository-health.yml @@ -23,6 +23,8 @@ jobs: run: python3 scripts/verify_operations.py - name: Verify E2E environment contract run: python3 scripts/verify_e2e.py + - name: Verify integration/release environment policy + run: python3 scripts/verify_stage_environment_policy.py - name: Verify product experience contract run: python3 scripts/verify_product_experience.py - name: Verify documentation lifecycle and budgets diff --git a/AGENTS.md b/AGENTS.md index c014bcc4..a52bf21b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,111 +1,53 @@ # ClosedRoom — Coding Agent Guide -Repository-wide routing layer. Detailed architecture belongs in `docs/architecture.md`, feature behavior in `docs/features.md`, and operational commands in `.engineering/commands.json`. +ClosedRoom is a privacy-first macOS meeting workspace built around a loopback FastAPI service, native audio helpers, local AI runtimes and a React UI in WKWebView. -## Read only what the task requires +## Durable invariants -Always read this guide. Then read only relevant sources: +- Local-first by default: no implicit cloud fallback and no sensitive meeting content in ordinary telemetry. +- Loopback/auth/origin restrictions and canonical persistence owners remain explicit. +- Recording/job/model/native-capture lifecycles are bounded, cancellable and restore run-owned state on every applicable exit path. +- Paths resolve through settings/path owners; Cocoa/WebKit mutations remain on the main thread. +- Model/resource telemetry stays truthful; deterministic fixtures never become representative MLX/Metal, TCC, physical-audio or interactive target-Mac evidence. +- `frontend/src/` is the UI source of truth; finalized artifacts are immutable. -1. closest scoped `AGENTS.md`, if present; -2. `docs/architecture.md` plus owning code for architecture/lifecycle work; -3. `docs/features.md` for feature contracts; -4. `.engineering/commands.json` for operations; -5. `.engineering/e2e.json` for complete workflow, macOS/audio/model or package-fidelity claims; -6. `design/*` and `skills/design-product-experience/SKILL.md` for meaningful UI/UX work; -7. owning implementation, consumers/fakes and nearby tests. +## Ownership -Do not ingest generated assets, model caches, dependencies or historical plans for a local change. - -## Repository purpose - -ClosedRoom is a privacy-first macOS meeting workspace. It records microphone/system audio locally, transcribes through local ASR, persists meeting/transcription/job state, and can enrich or analyze meetings through explicitly selected local or remote providers. The primary runtime is a macOS Apple Silicon app built around a loopback FastAPI service, native helpers and a React UI in WKWebView. - -## Non-negotiable invariants - -- Local-first is the default trust boundary. No implicit cloud fallback; remote ASR/LLM providers must be explicit choices. -- Sensitive audio, transcripts, prompts and meeting content must not enter ordinary telemetry/logs by default. -- Bind the application service to loopback by default; preserve session/auth/origin restrictions. -- `server.py` is a composition root; reusable policy belongs in domain/service/runtime owners. -- `CatalogStore` owns cross-feature queryable metadata; do not create parallel indexes. -- Resolve user-data and bundle/dev paths through `paths.py`/settings; never hardcode machine-local dependencies. -- Recording/job/model work needs explicit lifecycle, bounded concurrency/backpressure where applicable, cancellation and cleanup. -- Native capture/audio routing must restore run-owned system/device state on stop, error, cancellation and shutdown. -- Cocoa/WebKit UI mutations stay on the macOS main thread. -- Validate model/backend identity before expensive local-AI load; missing resource telemetry is unknown, not zero. -- Prefer deterministic fixtures/mocks over production model downloads for cheap regressions. -- Edit `frontend/src/`, not generated `src/local_asr_server/static/assets/` bundles. -- Finalized `dist/artifacts/` build directories are immutable; create a new build identity instead of modifying one. - -## Ownership and routing - -| Change | Start here | Inspect next | +| Change | Owner | Inspect / prove | | --- | --- | --- | -| FastAPI/public API | `server.py`, `routers/`, `schemas.py` | services, frontend API, tests | -| Recording/persistence | `recordings.py`, `catalog.py`, `transcriptions.py`, `jobs/` | routers/services/tests | -| ASR/model runtime | `runtime/asr_worker.py`, `asr_provider.py`, `transcriber.py` | service/jobs/settings/tests | -| Local LLM runtime | `runtime/llm_sidecar.py`, `runtime/service_manager.py`, `llm.py` | settings/services/diagnostics/tests | -| Native audio/capture | `native_capture.py`, helpers, `audio_router.py`, `macos_permissions.py` | recordings/window/build/tests | -| Speaker/visual intelligence | `speaker_diarization.py`, `speaker_labels.py`, `visual_intelligence/` | transcription/UI/benchmarks/tests | -| Ports/process leases | `runtime/port_manager.py`, `runtime/leases.py`, service manager | CLI/menubar/tests | -| Frontend | `frontend/src/` | `design/*`, API contract, i18n, E2E | -| Packaging/artifacts | `scripts/build_artifact.sh`, `build.sh`, `ClosedRoom.spec`, `build_assets/` | finalizer/smoke/E2E | -| CI/preflight | selector + `.github/workflows/preflight.yml` | commands/E2E/tests | - -Public API changes require router/schema/service, frontend API consumers and tests. Persisted-data changes require migration/recovery compatibility review. - -## Core engineering workflow - -Use the repo-template-sw 0.8 core skills in `skills/`: `structured-change`, `design-product-experience`, `validate-change`, `preflight-change`, `remote-preflight`, `plan-workstream`, `finalize-workstream`, `review-reference-quality`. - -ClosedRoom-specific skills remain local specializations; universal 0.8 contracts and this file govern conflicts. - -## Project operating commands - -`.engineering/commands.json` is canonical: - -`setup -> doctor -> dev -> check -> test -> e2e -> build -> smoke -> package -> stop -> clean` - -`build`/`package` use `scripts/build_artifact.sh`, wrapping the existing builder with unique identity, immutable successful artifacts, manifest/SHA-256 evidence, build delta and bounded retention. `build.sh` is not the canonical release/evidence path. - -`smoke` exercises the finalized `.app` frozen executable, loopback health/static frontend, graceful stop and listener/child cleanup. It does not prove interactive WKWebView, TCC, physical audio or production MLX behavior. `stop` is N/A as a standalone command because runtime/smoke owners stop their own processes. - -`select_validation_profile.py` chooses LEAN for docs/governance, SCOPED for contained implementation, STRONG for runtime/native/persistence/E2E boundaries, and FULL for build/dependency/CI/selector machinery or unknown paths. `.github/workflows/preflight.yml` validates the exact PR head with read-only repository contents permission. - -Execution capability (`AGENT_LOCAL`, `REMOTE_AUTOMATED`, `REAL_ENVIRONMENT`) is separate from E2E fidelity. Read `.engineering/e2e.json` before claims about real macOS permissions/audio, packaged behavior or production models. - -## Product experience routing - -For structural UX use: `user outcome -> task model -> IA/journey -> hierarchy -> disclosure/defaults -> states/feedback/recovery -> platform/adaptive -> accessibility -> components -> motion -> polish -> evidence`. - -Reuse semantic components/tokens from `frontend/src/components/ui` and `frontend/src/index.css`; keep diagnostics progressively disclosed and motion purposeful/reduced-motion aware. +| API | `server.py`, routers/schemas/services | clients + API tests | +| Persistence | recordings/catalog/transcriptions/jobs | migration/recovery tests | +| ASR/LLM | runtime/service owners | lifecycle/resource tests | +| Native audio | capture/helpers/router/permissions | TCC/audio lifecycle evidence | +| Frontend | `frontend/src/` + `design/*` | browser/UI journeys | +| Packaging | `scripts/build_artifact.sh`, `ClosedRoom.spec` | finalizer/smoke/artifact evidence | +| CI | selector + `.github/workflows/preflight.yml` | exact-head/base evidence | -## Documentation lifecycle +Follow the closest scoped `AGENTS.md`. Extend one canonical owner before introducing state/policy; inspect material consumers when a shared boundary changes. -- `docs/architecture.md`: detailed current architecture; intentionally larger local budget. -- `docs/features.md`: aggregate current feature registry; split into `docs/features/` only when useful. -- `docs/current-state.md`: short operational ledger. -- `docs/adr/`: accepted durable decisions only. -- `docs/workstreams/`: active bounded plans only; delete completed plans after transferring durable truth. -- Historical plans are not current truth unless confirmed against code/current docs. +## Read by task -## Validation and evidence +| Task | Read now | +| --- | --- | +| Pure docs/copy | affected source/links; `docs/README.md` only if ownership unclear | +| Behavior/bug/contract | `skills/structured-change/SKILL.md`, `skills/validate-change/SKILL.md`, relevant commands | +| Material UI | above + `skills/design-product-experience/SKILL.md`, relevant `design/*` | +| Integration/release | `skills/preflight-change/SKILL.md`, commands, affected `.engineering/e2e.json` | +| Missing deterministic remote gate | `skills/remote-preflight/SKILL.md` | +| Persistent multi-session work | `skills/plan-workstream/SKILL.md` + active plan; finalize with `skills/finalize-workstream/SKILL.md` | -Full Python suite: +## Delivery and evidence -```bash -UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -v -``` +- **ITERATION**: focused owner-local falsification; no exact-head/full-diff/docs/publication ceremony per edit. +- **INTEGRATION** (`PR -> dev`): coherent observable outcome, exact head/base, complete diff, affected durable docs, required automated gates and affected automated E2E. Material UI/UX integration journeys require `FULL_MEDIA`. Genuine TCC, physical-audio, representative MLX/Metal or interactive target-Mac gaps are `DEFERRED_TO_RELEASE`. +- **RELEASE** (`dev -> main`): `FULL` plus release-critical artifact/E2E and every applicable required target-Mac confirmation. -Frontend deterministic checks: +The selector resolves risks -> concrete gates -> profile. Profiles are shorthand. `.github/workflows/preflight.yml` owns remote deterministic validation; missing local tooling never makes the user the fallback runner. Reuse evidence only when head/tree/base/gates/profile/material E2E identity remain equivalent. -```bash -cd frontend -pnpm run lint -pnpm exec tsc --noEmit -``` +## Context, diagnosis and completion -For package/native/runtime evidence use `bash scripts/build_artifact.sh --no-dmg` then `python3 scripts/smoke_packaged_app.py`, or exact-head remote preflight. Real audio/TCC/interactive-WKWebView/production-MLX evidence remains separate. Never claim a gate passed unless it ran on the relevant head/environment. +`.engineering/documentation-policy.json` owns bounded context routes. Use `python3 scripts/verify_agent_context.py --route bug --format json`, optionally with `--path`/`--workstream`; routes estimate context cost, not validation scope. -## Stop conditions +For meaningful work state observable outcome, owner, invariants and proof. Classify failures before patching. Each failed repair needs a falsifiable hypothesis; after two failed repairs with the same signature, change diagnostic strategy and obtain new discriminating evidence before a third. On resume refresh head/tree/base; checkpoint evidence is a pointer, not current-source proof. -Surface conflicts instead of improvising when a request would create a second owner, silently move data to cloud, weaken auth/privacy, bypass migration review, leave unbounded resources, bypass cleanup/permission/command/E2E/design contracts, weaken tests for green CI, mutate a finalized artifact, or claim evidence that was not executed. +Before integration update affected canonical docs. Transfer durable truth and deferred release obligations before deleting completed plans. Never weaken privacy/auth/migration/resource cleanup, mutate finalized artifacts, create a second owner or overclaim hosted macOS evidence as target-Mac proof. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..c98a40d7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Daniele Moltisanti + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4689553d..7cef2d4e 100644 --- a/README.md +++ b/README.md @@ -5,141 +5,86 @@

ClosedRoom

- Local-first Meeting Intelligence for macOS
- Record, transcribe, identify speakers, analyze, and remember meetings without making cloud APIs the default home for sensitive meeting data.
- local-asr-server is the local backend and runtime behind the ClosedRoom workspace. -

- -

- Mission · - Vision · - Opportunities · - Today · - Architecture · - Run it · - Features + Private meeting intelligence for macOS.
+ Record, transcribe, identify speakers, extract decisions and actions, and build project memory — with Local AI as the default boundary.

+ Repository health macOS - Python 3.10+ Local first + Active development MIT License

-## Why this exists - -My mission is to [scale AI, GenAI, and Data Science with impact](https://daniele21.github.io/): move beyond isolated demos and turn AI into understandable, measurable, reusable products. - -Meetings are a good example of where that gap still exists. They contain decisions, commitments, risks, blockers, project changes, and context that often disappears into raw transcripts, fragmented notes, chat messages, or memory. - -ClosedRoom is designed to turn that information into a **local operational memory**. - -The goal is intentionally bigger than speech-to-text: - -- capture meetings locally and preserve recoverable audio artifacts; -- transcribe speech on-device with MLX/Nemotron ASR; -- separate speakers and, when evidence is strong enough, attach human-readable names conservatively; -- transform transcripts into summaries, actions, decisions, risks, open questions, minutes, and project updates; -- reuse extracted knowledge across meetings instead of treating every transcript as an isolated document; -- keep the default workflow local, while making any cloud processing explicit and optional. - -ClosedRoom is therefore not just an ASR server. It is an experiment in making **meeting intelligence a private, local-first product capability**. +

+ Why ClosedRoom · + Features · + How to use it · + Run it · + Privacy model · + Architecture · + Feature registry +

-## Values and opportunities +

+ + ClosedRoom product workflow from a new meeting through recording, transcription, structured notes, review, and project memory + +

-ClosedRoom is designed around values that make meeting intelligence useful beyond a transcription demo. +## Why ClosedRoom is different -| Value | What it means | Opportunity it creates | +| **Private by default** | **Intelligence, not just transcripts** | **Memory across meetings** | | --- | --- | --- | -| **Local-first control** | Audio, transcripts, prompts, analysis results, diarization artifacts, and visual observations stay on the Mac by default | Sensitive meetings can be processed without making a remote AI API the default data boundary | -| **Intelligence over transcription** | The product extracts actions, decisions, risks, minutes, open questions, and project updates | Users can act on meetings without rereading raw transcripts | -| **Project memory over isolated meetings** | Meeting outputs can be reused across a project workspace | ClosedRoom can show what changed across multiple conversations instead of only what happened once | -| **Human-reviewable speaker attribution** | Diarization clusters remain the stable identity; visual evidence may name them only when support is strong enough | More useful transcripts without pretending uncertain identity inference is fact | -| **Fail-soft enrichment** | Diarization, visual intelligence, and audio intelligence may degrade without invalidating a usable transcript | Optional intelligence can improve the experience without making the core recording/transcription path brittle | -| **Replaceable provider boundaries** | Local and cloud ASR/LLM choices sit behind explicit configuration and runtime boundaries | Models and providers can evolve without rewriting the product workflow | - -This creates several useful product directions: - -- **For individual knowledge workers:** preserve decisions, actions, and context without manually maintaining meeting notes. -- **For project work:** connect multiple meetings into a living view of status, commitments, risks, and changes. -- **For sensitive contexts:** keep the default recording, transcription, diarization, and analysis path on the local machine. -- **For local-model experimentation:** compare ASR, diarization, visual, and LLM workflows inside a real product rather than a benchmark-only harness. -- **For hybrid deployments:** opt into Speechmatics or Gemini only when the user explicitly chooses a cloud capability. - -## Local meeting intelligence vision +| Sensitive meeting data stays on your Mac by default. Cloud providers are explicit choices, never silent fallbacks. | ClosedRoom turns conversations into speaker-aware transcripts, actions, decisions, risks, questions and editable notes. | Meeting outputs become reusable project context instead of disappearing inside isolated transcripts. | -The product vision is a **private meeting workspace that remembers work, not just words**. +> **A meeting should become useful memory, while the default trust boundary stays on your Mac.** -ClosedRoom should let a user move from a live meeting to an operationally useful memory without assembling separate recording, ASR, diarization, note-taking, LLM, and project-tracking tools. +## What ClosedRoom does -In the default architecture: - -- the React workspace talks to one local `local-asr-server` boundary; -- recording artifacts, transcript state, jobs, analysis runs, and project memory are persisted locally; -- MLX/Nemotron handles local ASR; -- FluidAudio can provide local post-meeting speaker diarization on supported Macs; -- `local-llm-server` provides the local reasoning and visual-model boundary; -- Qwen3-VL contributes conservative visual evidence for naming existing speaker clusters; -- Speechmatics and Gemini remain explicit opt-in cloud alternatives. - -![ClosedRoom high-level local-first architecture](docs/assets/closedroom-high-level-architecture.png) - -_The default trust boundary stays on the user's Mac; cloud providers sit outside it and are used only when explicitly selected._ +ClosedRoom is built around the full path from capture to reusable operational memory rather than transcription alone. -## Strategy: from transcript to operational memory +### Core product capabilities -The product sequence is deliberately simple from the user's point of view: +| Capability | What you get | +| --- | --- | +| **Local meeting capture** | Record microphone and system audio on macOS, preserve recoverable artifacts, and save the meeting before expensive inference starts. | +| **Local transcription** | Transcribe on-device with MLX Whisper or Nemotron ASR, with persisted jobs and reusable results. | +| **Speaker-aware transcripts** | Separate speakers locally with FluidAudio and keep speaker clusters stable across review and recalculation. | +| **Structured meeting intelligence** | Turn a transcript into summaries, actions, decisions, risks, open questions and meeting notes through a structured analysis pipeline. | +| **Verifiable, editable notes** | Review generated actions and decisions against source context, correct them, and preserve user edits across reloads and regenerated revisions. | +| **Project memory** | Reuse meeting outputs across a project so decisions, risks, commitments and updates do not disappear inside isolated transcripts. | -![ClosedRoom end-to-end meeting intelligence flow](docs/assets/closedroom-end-to-end-meeting-flow.png) +ClosedRoom can also add optional enrichment without making the core workflow depend on it: conservative visual speaker attribution, richer diagnostics, provider alternatives and deeper analysis paths. -**Configure → Record → Transcribe → Enrich → Analyze → Remember** +## From meeting to operational memory -Each step has a distinct responsibility: +The user mental model is deliberately simple: -1. **Configure the evidence path:** choose diarization, visual intelligence, provider options, and—when visual capture is enabled—the exact macOS window to observe. -2. **Preserve the meeting first:** record microphone/system audio progressively and finalize recoverable local artifacts before expensive inference begins. -3. **Create the transcript:** run local ASR or an explicitly selected provider and persist timestamped transcript output. -4. **Enrich without breaking the core:** add speaker diarization, visual evidence, and audio intelligence as fail-soft stages. -5. **Convert text into work:** run structured analysis for summaries, actions, decisions, risks, minutes, questions, and project updates. -6. **Reuse the result:** surface knowledge in Today, Meeting, and Project workspaces so context survives beyond one call. +**Record → Transcribe → Understand → Remember** -## Where we are today +Under that simple flow, ClosedRoom saves the meeting before expensive AI work, enriches the transcript without making optional stages mandatory, keeps generated notes reviewable, and carries useful outputs into Today and Project memory. -**ClosedRoom is already a working local-first macOS meeting intelligence application and local server.** The current `speaker_detection` branch extends the product with post-meeting speaker diarization, conservative visual speaker attribution, richer diagnostics, and a more explicit enrichment pipeline. +> **Image placeholder:** A compact four-stage product journey showing **Record → Transcribe → Understand → Remember**. Use real ClosedRoom UI fragments for each stage rather than an abstract architecture diagram. The final stage should visibly connect multiple meetings into Project memory. -The current product can: - -- record microphone and system audio, with native macOS capture where supported and a browser/BlackHole fallback; -- save meetings first and run transcription asynchronously afterward; -- transcribe locally with MLX Whisper or Nemotron ASR, with result caching for identical audio/options; -- diarize speakers locally with FluidAudio or opt into Speechmatics diarization; -- stage timestamped frames from an explicitly selected macOS window and analyze them with Qwen3-VL when visual intelligence is enabled; -- keep diarization clusters stable and use visual evidence only for conservative name attribution; -- run structured local meeting analysis through `local-llm-server` and Nemotron Nano 4B; -- browse Today, Meeting, and Project workspaces with persistent jobs, diagnostics, and analysis history; -- recalculate speakers without rerunning ASR; -- package the backend and product surface into a native macOS application. - -> **Current boundary:** ClosedRoom is local-first by default, not local-only. Speechmatics and Gemini are available as explicit opt-in providers. Visual intelligence is disabled by default, and automatic speaker naming is intentionally conservative rather than guaranteed. - -The current product surfaces make that milestone visible: +## See the product - + - - - + + + - - - + + +
Today RecordingMeetingMeeting intelligence
ClosedRoom Today workspaceClosedRoom recording configurationClosedRoom meeting analysis workspaceClosedRoom Today workspaceClosedRoom recording setupClosedRoom meeting intelligence workspace
Meetings, actions, decisions, risks, and period contextCapture setup, diarization, and visual-intelligence controlsTranscript, audio, speaker context, and structured analysisMeetings, actions, decisions, risks and contextA focused capture flow with optional advanced controlsTranscript, speakers, sources and structured analysis
@@ -149,80 +94,140 @@ The current product surfaces make that milestone visible: Deep-dive actions - ClosedRoom project analysis workspace - ClosedRoom deep-dive action items + ClosedRoom project memory workspace + ClosedRoom deep-dive action items - Cross-meeting status, decisions, risks, and updates + Cross-meeting status, decisions, risks and updates Operational detail extracted from meeting intelligence -## How it works - -![ClosedRoom detailed technical architecture](docs/assets/closedroom-detailed-technical-architecture.png) +## How you use it -The current implementation separates product experience, orchestration, persistence, and execution backends: +A normal ClosedRoom workflow does not require choosing models, audio devices or inference parameters before every meeting. -- **Product surface:** React/TypeScript is served locally and runs either in a browser or the native WKWebView shell. -- **Local API boundary:** FastAPI binds to loopback by default, bootstraps a local authenticated session, and exposes recording, transcription, analysis, workspace, settings, runtime, and diagnostic APIs. -- **Composition root:** `create_app()` assembles long-lived services through `AppServices` rather than scattering process-global ownership across routes. -- **Recording:** ClosedRoom persists microphone, system, and mixed tracks plus metadata and optional visual frames; recordings are finalized before transcription begins. -- **Transcription:** MLX Whisper and Nemotron ASR provide the local path; transcription jobs persist progress, events, outputs, and cache identity. -- **Speaker diarization:** FluidAudio Community-1 can create local timestamped speaker clusters after a meeting. Speechmatics can be selected independently as a cloud diarization provider. -- **Visual intelligence:** Qwen3-VL consumes selected meeting-window frames after the meeting and contributes evidence for existing diarization clusters. It does not replace diarization and is not used as face recognition. -- **Meeting analysis:** `local-llm-server` isolates local model lifecycle and inference; Nemotron Nano 4B is used for structured meeting intelligence. Gemini remains an optional cloud analysis provider. -- **Persistence:** filesystem artifacts and the SQLite catalog store recordings, transcripts, jobs, events, analysis runs, diagnostics, and project-level state locally. -- **Failure handling:** enrichment stages record effective backend, warnings, and failure causes instead of turning every partial degradation into a failed transcript. +1. **Start and record a meeting.** Add a title or project if useful, grant the required macOS capture permissions, and let ClosedRoom write audio progressively so the meeting remains recoverable. +2. **Stop and transcribe.** ClosedRoom finalizes the recording first, then runs the default local ASR path as a persisted, observable job. +3. **Turn the transcript into meeting intelligence.** Structured analysis produces the summary, actions, decisions, risks and other reusable outputs shown in the main workspace. +4. **Review and edit.** Generated items stay human-reviewable; corrections are persisted instead of being silently replaced on refresh or regeneration. +5. **Reuse the result.** Today and Project workspaces carry decisions, commitments, risks and context across meetings instead of treating each transcript as an isolated file. -Speaker identity stays explicit end to end: **audio diarization determines who spoke when; visual intelligence may add a name only when the evidence satisfies the configured support and margin rules.** Users can rename clusters later without rerunning ASR. +Optional visual intelligence can be enabled for a specifically selected macOS window. It may contribute evidence for naming existing speaker clusters, but **audio diarization remains the source of who spoke when and uncertain identity mappings abstain rather than pretending certainty**. -For the durable system design and extension rules, read [`docs/architecture.md`](docs/architecture.md). The combined visual-intelligence and diarization path is tracked in [`docs/visual-diarization-e2e-readiness.md`](docs/visual-diarization-e2e-readiness.md). +![ClosedRoom speaker intelligence: audio diarization produces stable speaker clusters, while optional visual evidence can support conservative names or abstain](docs/assets/closedroom-speaker-intelligence.png) -### Why `local-llm-server` is a separate boundary +_Visual evidence may support naming an existing speaker cluster; it never replaces audio diarization, and low-confidence mappings remain unknown._ -ClosedRoom delegates local LLM serving to [`local-llm-server`](https://github.com/daniele21/local-llm-server) instead of owning model-runtime details inside the meeting product. +## Local-first by default -That boundary handles model loading, backend selection, OpenAI-compatible inference, runtime lifecycle, model configuration, reasoning/JSON modes, vision inference, logs, and diagnostics. ClosedRoom stays focused on meeting workflows, evidence, persistence, and product state. +ClosedRoom is **local-first, not local-only**. -## Repository map +The default product path keeps the sensitive meeting workflow on the Mac. Cloud providers exist as explicit choices, not silent fallbacks. -| Area | Main paths | Responsibility | +| Area | Default | Optional alternative | | --- | --- | --- | -| Product UI | `frontend/src/`, generated `src/local_asr_server/static/` | Today, recording, meeting, project, settings, guided workflows, and API client | -| API and composition | `src/local_asr_server/server.py`, `app_services.py` | FastAPI composition root, route wiring, local session boundary, long-lived services | -| Recording and macOS capture | `recordings.py`, `native_capture_helper/`, `macos_audio_helper/`, `audio_router.py` | Progressive recording, native microphone/system capture, fallback audio routing, recovery | -| Transcription and diarization | `transcriptions.py`, `asr_provider.py`, `speaker_diarization_helper/` | ASR provider boundary, transcript persistence, local FluidAudio speaker clustering | -| Meeting intelligence | `analysis_jobs.py`, `analysis_templates.py`, `audio_intelligence/` | Persistent analysis jobs, structured pipelines, optional enrichment | -| Local model runtime | `local-llm-server` dependency + runtime service manager | Local text/vision inference, model lifecycle, diagnostics | -| Persistence and configuration | `catalog.py`, `settings.py`, `paths.py` | SQLite catalog, settings, runtime paths, cross-feature metadata | -| macOS application | `menubar.py`, `window.py`, `ClosedRoom.spec`, `build.sh`, `build_assets/` | Native menu bar/WKWebView shell, PyInstaller bundle, signing and packaging | -| Validation and documentation | `test/`, `scripts/`, `docs/`, `AGENTS.md` | Unit/API tests, smoke harnesses, architecture, feature registry, engineering guidance | +| Recording | Local macOS capture | — | +| Speech-to-text | Local MLX Whisper / Nemotron | Speechmatics when explicitly selected | +| Speaker diarization | Local FluidAudio on supported Macs | Speechmatics when explicitly selected | +| Meeting analysis | Local [Korgis](https://github.com/daniele21/korgis) runtime | Gemini when explicitly selected | +| Visual speaker evidence | Local selected-window frames + local VLM path | Disabled unless explicitly enabled | +| Persistence | Local filesystem + SQLite | No implicit remote persistence | + +This is a product boundary, not a marketing label: provider selection, runtime ownership and failure states are explicit so a local failure does not silently move meeting content to a cloud API. + +![ClosedRoom high-level local-first architecture](docs/assets/closedroom-high-level-architecture.png) + +_The default trust boundary stays on the user's Mac; cloud providers sit outside it and are used only when explicitly selected._ + +## Architecture + +ClosedRoom separates the **meeting product** from reusable Local AI runtime concerns. + +```text +ClosedRoom macOS app / React workspace + │ + ▼ + Loopback FastAPI boundary + │ + ┌───────┼────────┐ + │ │ │ + Recording Jobs Local persistence + │ │ │ + └───────┼────────┘ + │ + ┌────────┴─────────┐ + │ │ + Local ASR / diarization Korgis + │ │ + transcript + speakers LLM / VLM runtime + └──────────┬────────┘ + ▼ + Meeting / Project memory +``` + +The key ownership split is: + +- **ClosedRoom owns the user problem:** capture, meetings, transcripts, speaker state, structured notes, user edits, project memory, persistence and product UX. +- **[Korgis](https://github.com/daniele21/korgis) owns reusable Local AI runtime infrastructure:** model loading, backend selection, runtime lifecycle, text/vision inference, logs and diagnostics. The current integration still uses the internal `local-llm-server` package/runtime identity where required by the implementation. +- **Native helpers own platform-specific capture and diarization work** behind explicit process boundaries. + +![ClosedRoom detailed technical architecture](docs/assets/closedroom-detailed-technical-architecture.png) -[`AGENTS.md`](AGENTS.md) is the repository navigation and change guide; [`docs/features.md`](docs/features.md) is the business/technical feature registry. +For durable ownership and extension rules, read [`docs/architecture.md`](docs/architecture.md). -## Run it +## Designed to degrade usefully -### Prerequisites +Meeting intelligence is a pipeline, so one optional enrichment should not invalidate everything that came before it. -- macOS; Apple Silicon is recommended and required for some MLX/FluidAudio paths -- Python `>= 3.10` +ClosedRoom therefore distinguishes the core meeting artifact from optional stages: + +- a recording is finalized before transcription; +- a usable transcript can survive diarization or visual-intelligence degradation; +- visual speaker attribution can abstain; +- diagnostics record effective backends, warnings and failure causes; +- jobs are persistent, cancellable and recoverable where the workflow requires it; +- invalid or stale structured-note edits are surfaced as explicit conflicts instead of being silently remapped. + +This is one of the main engineering goals of the project: **Local AI should behave like a product system, not like a successful-demo-only pipeline**. + +## Run ClosedRoom + +### Current distribution + +ClosedRoom is currently an **active-development, source-built macOS project**. A public downloadable GitHub Release is not published yet, so the supported path today is to run or build the project from source. + +### Requirements + +- macOS +- Apple Silicon recommended and required for some MLX / FluidAudio paths +- Python `>=3.10,<3.14` +- Homebrew for the repository setup script - `ffmpeg` -- a local ASR model such as `mlx-community/whisper-large-v3-turbo` or `mlx-community/nemotron-3.5-asr-streaming-0.6b` -- [`local-llm-server`](https://github.com/daniele21/local-llm-server) and a compatible local analysis model for local meeting intelligence -- optional `blackhole-2ch` for the browser/system-audio fallback -- optional Speechmatics or Gemini credentials only when those cloud providers are explicitly selected +- `uv` recommended +- local model storage appropriate for the ASR / LLM models you choose + +Cloud credentials are **not required** for the default local path. ### Install and launch ```bash -git clone -b speaker_detection https://github.com/daniele21/local-asr-server.git -cd local-asr-server +git clone https://github.com/daniele21/closedroom.git +cd closedroom + ./setup.sh ./run.sh ``` -Or start the local API directly: +Then open: + +```text +http://127.0.0.1:1236 +``` + +The repository still uses the internal Python package / CLI name `local-asr-server` / `local-asr` for the local backend. That is an implementation boundary inside the **ClosedRoom** product, not a separate repository to clone. + +### Start the local API directly ```bash local-asr serve \ @@ -231,92 +236,97 @@ local-asr serve \ --port 1236 ``` -Open: - -```text -http://127.0.0.1:1236 -``` - -Development mode: +For development with reload: ```bash UV_CACHE_DIR=.cache/uv uv run local-asr serve --reload ``` -### Build the macOS application +### Build the native macOS application ```bash ./build.sh --no-dmg ``` -The packaged application includes the native capture helper and FluidAudio diarization helper. Build/package changes require macOS Apple Silicon and the relevant native toolchain. +The packaged application includes the native capture helper and FluidAudio diarization helper. Packaging and target-Mac behavior require the applicable macOS toolchain and real-environment validation. -## Use the local API +## Current status and limits -ClosedRoom exposes a local HTTP API because the browser UI, WKWebView shell, CLI workflows, and diagnostics all use the same product boundary. +ClosedRoom already implements the core meeting-intelligence workflow, but it remains under active development. -Bootstrap a local session and check health: +Current boundaries worth knowing: -```bash -curl -c /tmp/closedroom.cookies http://127.0.0.1:1236/v1/session -curl http://127.0.0.1:1236/health -``` +- the product is focused on macOS, with the strongest local inference path on Apple Silicon; +- local FluidAudio diarization requires supported macOS / hardware and remains an enrichment rather than a prerequisite for transcription; +- visual intelligence is disabled by default and only observes a window explicitly selected by the user; +- automatic speaker naming is conservative and may intentionally leave a speaker unnamed; +- Speechmatics and Gemini are opt-in providers and move selected meeting data outside the local trust boundary; +- no public binary GitHub Release is available yet; +- model quality, latency, memory and thermal behavior should be treated as environment/model dependent unless backed by representative recorded evidence. -For direct endpoint coverage, runtime status, transcription jobs, speaker re-diarization, visual intelligence, analysis pipelines, and diagnostics, use the API examples in [`docs/features.md`](docs/features.md) and the architecture references in [`docs/architecture.md`](docs/architecture.md). +ClosedRoom is not a guarantee of perfect transcription, speaker identity or meeting understanding. Human review remains part of the product model. -## Evidence and maturity +## For developers -ClosedRoom is an active local-first product and engineering project, not a guarantee of perfect transcription, speaker identity, or meeting understanding. +### Repository map -Current maturity boundaries include: - -- MLX-based local inference is primarily designed for Apple Silicon; -- local FluidAudio diarization requires macOS 14+ and may be slower than speed-oriented defaults because the current profile favors short-turn recall; -- visual intelligence is disabled by default and requires an explicitly selected macOS window; -- speaker naming is evidence-based and conservative: uncertain mappings abstain rather than being promoted as known identities; -- Qwen visual processing can degrade independently while leaving the transcript usable; -- Speechmatics and Gemini move selected meeting data outside the machine and may incur provider cost; -- the macOS bundle pins runtime versions that have been verified together rather than always choosing the newest MLX stack automatically. +| Area | Main paths | Responsibility | +| --- | --- | --- | +| Product UI | `frontend/src/` | Today, recording, meeting, project, settings and guided workflows | +| API / composition | `src/local_asr_server/server.py`, `app_services.py` | FastAPI boundary, route wiring and long-lived services | +| Recording / capture | `recordings.py`, native helpers, `audio_router.py` | Progressive capture, recovery, macOS audio and optional visual frames | +| Transcription / diarization | `transcriptions.py`, `asr_provider.py`, `speaker_diarization_helper/` | ASR providers, transcript persistence and speaker clustering | +| Meeting intelligence | analysis jobs, templates, structured notes | Structured meeting outputs, source references, edits and revisions | +| Local model runtime | [Korgis](https://github.com/daniele21/korgis) + internal `local-llm-server` integration | Local LLM/VLM lifecycle and inference | +| Persistence | `catalog.py`, settings and paths | SQLite catalog, artifacts, configuration and durable state | +| macOS app | `menubar.py`, `window.py`, `ClosedRoom.spec`, build scripts | WKWebView shell, menu bar, bundling and packaging | -Use these sources for the current technical truth: +Start with [`AGENTS.md`](AGENTS.md) for engineering ownership and [`docs/features.md`](docs/features.md) for the business/technical feature registry. -- [Architecture](docs/architecture.md) -- [Feature registry](docs/features.md) -- [Visual + diarization E2E readiness](docs/visual-diarization-e2e-readiness.md) -- [Task-aware visual intelligence plan](docs/task-aware-visual-intelligence-plan.md) -- [Audio intelligence / VAD plan](docs/audio-intelligence-vad-plan.md) -- [Repository engineering guide](AGENTS.md) +### Canonical development commands -## Build and validate +```bash +# setup +./setup.sh +(cd frontend && pnpm install --frozen-lockfile) -Run the narrowest tests for the area you change. The repository uses Python `unittest`, focused API tests, frontend builds, and dedicated E2E/smoke harnesses for native and multimodal paths. +# repository checks +python3 scripts/verify_repository.py -```bash +# tests UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -v + +# build +bash scripts/build_artifact.sh --no-dmg ``` -Useful focused checks include: +Canonical commands and validation stages live in [`.engineering/commands.json`](.engineering/commands.json). -```bash -UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_recordings.py' -v -UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_recording_api.py' -v -UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_audio_router.py' -v -``` +### Local API -For the combined local visual + diarization path: +The browser workspace, WKWebView shell and diagnostics use the same loopback API boundary. ```bash -.venv/bin/python scripts/smoke_visual_diarization_e2e.py \ - --audio /path/to/two-speakers.wav \ - --frame /path/to/active-speaker.jpg \ - --base-url http://127.0.0.1:1245 \ - --output-dir /private/tmp/closedroom-combo-e2e +curl -c /tmp/closedroom.cookies http://127.0.0.1:1236/v1/session +curl http://127.0.0.1:1236/health ``` -Coding agents should start from [`AGENTS.md`](AGENTS.md), which maps changes to the owning layer and documents repository-specific validation rules. +For endpoint-level behavior, provider options, speaker recalculation, analysis pipelines and diagnostics, see [`docs/features.md`](docs/features.md) and [`docs/architecture.md`](docs/architecture.md). + +## Contributing + +ClosedRoom uses `dev → main` as its canonical branch flow. Ordinary feature and fix work branches from `dev` and returns to `dev` through a pull request; `main` is the stable promotion line. + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development and validation model, and [`SECURITY.md`](SECURITY.md) for security reporting. + +## Project context + +ClosedRoom is part of [Daniele Moltisanti's Local AI work](https://daniele21.github.io/): build real products on top of reusable Local AI infrastructure, measure what works, and keep **Local, Hybrid and Cloud** as explicit architectural choices rather than accidental dependencies. + +Within that ecosystem, **ClosedRoom applies Local AI to a real sensitive workflow**, while **[Korgis](https://github.com/daniele21/korgis) provides reusable execution and control infrastructure**. -## License and author +Its role in that mission is concrete: **prove that sensitive meeting workflows can become useful, persistent AI-assisted memory while keeping the default data boundary under the user's control.** -ClosedRoom is available under the [MIT License](LICENSE). +## License -Built by [Daniele Moltisanti](https://daniele21.github.io/) as part of a broader mission to make AI products useful, understandable, and deliberate about their technical and privacy trade-offs. \ No newline at end of file +ClosedRoom is available under the [MIT License](LICENSE). \ No newline at end of file diff --git a/design/README.md b/design/README.md index 284aff02..3f9acdb5 100644 --- a/design/README.md +++ b/design/README.md @@ -4,4 +4,6 @@ ClosedRoom uses a **code-first** design source of truth. `design/ux-contract.jso Canonical implementation sources are `frontend/src/components/ui`, page/workspace components, `frontend/src/index.css`, i18n resources and the current logo assets. Existing screenshots under `docs/assets/` are bounded reference views, not a second design system or an automatically current visual-regression baseline. +Approved raster brand artwork lives under `design/assets/brand/`. These assets are intended for presentations, documentation and promotional compositions; they do not replace the canonical SVG logos used by the application. + Meaningful UX/UI changes follow `skills/design-product-experience/SKILL.md` before implementation. diff --git a/design/assets/brand/closedroom-microphone-mark.png b/design/assets/brand/closedroom-microphone-mark.png new file mode 100644 index 00000000..7913a706 Binary files /dev/null and b/design/assets/brand/closedroom-microphone-mark.png differ diff --git a/design/assets/brand/closedroom-primary-horizontal-lockup.png b/design/assets/brand/closedroom-primary-horizontal-lockup.png new file mode 100644 index 00000000..8f1fb039 Binary files /dev/null and b/design/assets/brand/closedroom-primary-horizontal-lockup.png differ diff --git a/design/assets/brand/closedroom-primary-stacked-lockup.png b/design/assets/brand/closedroom-primary-stacked-lockup.png new file mode 100644 index 00000000..dacef256 Binary files /dev/null and b/design/assets/brand/closedroom-primary-stacked-lockup.png differ diff --git a/design/brand-kit.json b/design/brand-kit.json index f12b257e..3ac712a0 100644 --- a/design/brand-kit.json +++ b/design/brand-kit.json @@ -7,7 +7,10 @@ "logo_compact": "public/logo-light.svg", "logo_monochrome": "use the canonical SVG logo with semantic foreground treatment; do not create a parallel logo source", "app_icon": "canonical ClosedRoom logo source packaged by the macOS build when an app-icon artifact is generated", - "favicon": "frontend/public/logo-light.svg" + "favicon": "frontend/public/logo-light.svg", + "brand_lockup_stacked": "design/assets/brand/closedroom-primary-stacked-lockup.png", + "brand_lockup_horizontal": "design/assets/brand/closedroom-primary-horizontal-lockup.png", + "brand_illustration_mark": "design/assets/brand/closedroom-microphone-mark.png" }, "tokens": { "colors": { diff --git a/design/ux-contract.json b/design/ux-contract.json index 6afb4368..dca7d56a 100644 --- a/design/ux-contract.json +++ b/design/ux-contract.json @@ -1,10 +1,10 @@ { "schema_version": 1, - "contract_version": "0.5.0", + "contract_version": "0.8.1", "applicable": true, "design_source_of_truth": { "type": "code-first", - "location": "frontend/src, frontend/src/index.css and frontend/public logo assets" + "location": "frontend/src, frontend/src/index.css, frontend/src/workspace.css and frontend/public logo assets" }, "experience_context": { "primary_users": [ @@ -13,16 +13,16 @@ ], "primary_jobs": [ "start and understand a reliable meeting recording", - "transcribe a recording and understand processing state", + "prepare a recorded meeting into readable transcript and useful notes without technical setup", "review speaker-attributed meeting content", - "extract useful meeting/project insights", + "extract useful meeting/project notes and insights", "recover from permission, audio, model or processing failures without losing work" ], "primary_surfaces": [ - "dashboard and meeting/project workspace", - "recording and compact recording overlay", - "transcription and meeting detail", - "analysis workflows", + "Today and meeting/project workspace", + "new meeting and compact recording overlay", + "meeting detail with transcript, notes, speakers and on-demand tools", + "secondary import and power-tool workflows", "settings and diagnostics" ] }, @@ -43,6 +43,128 @@ "bounded_information_density": true, "actionable_error_recovery": true }, + "product_object_model": { + "primary_user_object": "meeting", + "normal_user_concepts": [ + "meeting", + "project", + "transcript", + "notes", + "actions", + "speakers", + "search" + ], + "implementation_concepts_hidden_by_default": [ + "recording job", + "transcription job", + "analysis job", + "provider", + "model", + "runtime", + "backend", + "port", + "model path", + "audio routing" + ], + "rule": "Technical capability does not imply a user-facing decision or setting. Surface implementation concepts only when an explicit advanced choice or recovery action materially requires them." + }, + "normal_decision_budget": { + "start_meeting_required_decisions_max": 1, + "golden_path_technical_concepts": 0, + "provider_model_decisions_before_recording": 0, + "provider_model_decisions_before_transcription": 0, + "provider_model_decisions_before_notes": 0, + "prepare_notes_primary_actions": 1, + "transcript_only_secondary_actions": 1 + }, + "capability_placement": { + "core": [ + "microphone and computer audio recording", + "meeting transcript", + "summary, decisions and actions", + "speaker attribution and naming", + "meeting search" + ], + "contextual": [ + "project organization and filtering" + ], + "secondary": [ + "transcript-only preparation", + "file import" + ], + "on_demand": [ + "visual intelligence", + "custom analysis and ask" + ], + "advanced": [ + "cloud/local trust boundary override", + "provider, model and quality overrides" + ], + "developer": [ + "custom model paths", + "custom runtime endpoints", + "backend and context overrides" + ], + "diagnostics": [ + "runtime lifecycle controls", + "ports", + "logs", + "audio routing diagnostics" + ], + "power_tools": [ + "merge, split and raw transcript tools" + ] + }, + "workspace_navigation": { + "primary_destinations": ["today", "projects"], + "meeting_parent": "today", + "persistent_primary_action": "new_meeting", + "utility_destinations": ["settings", "theme", "language", "tour", "demo", "runtime_status"], + "desktop_pattern": "one stable left workspace rail", + "compact_pattern": "the same destinations and action hierarchy reflow into one sticky top toolbar; compact width must not introduce a second navigation model", + "rule": "Today, Meeting and Projects share one workspace chrome. Meeting is opened from Today/search/project context but does not become a third peer destination. Advanced transcription, analysis and diagnostics remain contextual or utility routes." + }, + "golden_path": { + "steps": [ + "today", + "new_meeting", + "record", + "stop", + "meeting", + "prepare_notes", + "notes_review" + ], + "normal_path_rule": "A configured user completes the golden path without choosing capture backend, source device, diarization, visual enrichment, provider, model, runtime, helper, process, port, model path or routing concepts.", + "surface_hierarchy": { + "today": "understand what happened, what needs attention and what to open or do next", + "new_meeting": "optionally add meeting context, see readiness and start recording; if managed AI is already active, show truthful cancellable preparation rather than pretending recording has started", + "record": "understand capture status and safely stop with minimal live UI work", + "meeting": "use the saved meeting as the canonical workspace with one primary Prepare notes action and secondary Transcript only recovery/power action", + "prepare_notes": "coordinate transcript then notes as one recoverable user task, expose the transcript as soon as it is ready, and preserve completed work across cancel, failure, reconnect and explicit resume", + "notes_review": "open ready notes first while keeping transcript, speakers and advanced tools directly reachable" + } + }, + "complexity_disclosure": { + "tiers": [ + "essential", + "contextual", + "advanced", + "diagnostics" + ], + "essential": "Information and actions required to complete the user's current outcome.", + "contextual": "Optional outcome-level choices the user can understand without technical architecture knowledge, such as project organization. A capability is not contextual merely because it can change implementation behavior.", + "advanced": "Explicit expert trust, provider, model or quality overrides that normal use does not require.", + "diagnostics": "Internal runtime, helper, process, port, log, routing and model-path details used for troubleshooting rather than normal operation.", + "diagnostics_default_hidden": true, + "recovery_exception": "A technical condition may surface outside diagnostics only when the user must act on it; present the user-facing consequence and recovery action before raw technical detail." + }, + "resource_experience": { + "recording_priority": "While recording is active, ClosedRoom prioritizes capture and persistence and does not start heavy ASR, LLM or VLM work.", + "recording_start_priority": "When Start meeting races with managed heavy work, one capture reservation in the canonical workload owner closes the admission boundary atomically. Already-active managed work reaches its normal safe boundary without force-kill; queued work stays bounded and resumes after Stop. The UI calls the waiting state preparation, never recording, and offers cancellation while it is waiting for the active AI phase.", + "heavy_work_admission": "One canonical runtime policy and the existing bounded workload owner decide when heavy work may start; meeting preparation and recording admission never own a second heavy-work queue.", + "model_residency": "Managed AI residency is phase-scoped and reclaimed after work; external runtimes remain caller-owned.", + "background_activity": "Stable UI state must not require display-rate React updates or avoidable polling loops; bounded short-interval polling is allowed only for the transient capture-reservation handshake and stops when it grants, cancels or fails." + }, "critical_states": [ "loading", "empty", @@ -50,6 +172,7 @@ "disabled", "permission-denied", "processing", + "waiting-for-safe-boundary", "partial", "interrupted" ], @@ -68,6 +191,11 @@ "primary resizable macOS WKWebView window", "compact floating recording overlay", "browser development viewport" + ], + "workspace_rules": [ + "wide windows keep one stable workspace rail and bounded content column", + "compact windows reflow the same primary destinations and New Meeting action into one sticky top toolbar", + "narrow windows may collapse labels but must preserve semantic destinations, visible focus, utility access and avoid page-wide horizontal overflow" ] }, "design_system": { @@ -107,10 +235,13 @@ ] }, "critical_journeys": [ - {"id": "record-meeting", "name": "Start, monitor and safely stop a local meeting recording", "e2e": "required"}, - {"id": "transcribe-meeting", "name": "Transcribe a recording and recover from processing failure", "e2e": "required"}, - {"id": "review-meeting", "name": "Review transcript, speakers and derived meeting evidence", "e2e": "recommended"}, - {"id": "analyze-meeting", "name": "Run meeting analysis with an explicit provider/runtime choice", "e2e": "recommended"} + {"id": "record-meeting", "name": "Start, monitor and safely stop a local meeting recording without technical setup, including truthful safe-boundary preparation when managed AI is already active", "e2e": "required"}, + {"id": "prepare-meeting-notes", "name": "Prepare transcript and notes with one durable action, reconnect safely and resume only missing work after failure", "e2e": "required"}, + {"id": "coherent-macos-workspace", "name": "Move between Today, a Meeting and Projects with one stable hierarchy across wide, compact and narrow windows while keeping New Meeting primary and utilities secondary", "e2e": "required"}, + {"id": "transcribe-meeting", "name": "Create or replace only the transcript through the secondary/expert path and recover from processing failure", "e2e": "recommended"}, + {"id": "review-meeting", "name": "Review transcript, speakers, notes and derived meeting evidence", "e2e": "recommended"}, + {"id": "analyze-meeting", "name": "Run additional meeting analyses with automatic defaults and optional advanced overrides", "e2e": "recommended"}, + {"id": "visual-on-demand", "name": "Explicitly analyze shared visual content without making visual capture part of normal recording", "e2e": "recommended"} ], "reference_views": [ "docs/assets/0.home.png", diff --git a/docs/README.md b/docs/README.md index 7d80580a..ce874b7b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,12 +2,39 @@ Use documentation by ownership, not chronology. +- README identity sections — what ClosedRoom is, why it exists, its primary audience/outcome and stable positioning. +- README usage sections — current prerequisites, setup, run/start, configuration and public usage/examples. - `architecture.md` — current detailed architecture, ownership and system boundaries. - `features.md` — existing aggregate registry of current product behavior and verification hints. - `current-state.md` — short operational/maturity ledger and current gaps. +- `real-environment-release.md` — operator runbook for the frozen PRS-18 Apple-Silicon candidate and automated target-Mac release evidence. +- `local-real-environment.md` — one-command target-Mac evidence path for a finalized ad-hoc artifact when Apple Developer distribution authority is unavailable. - `features/` — bounded feature documents only when splitting durable behavior reduces context or duplication. - `adr/` — accepted durable decisions whose rationale remains useful. - `workstreams/` — active implementation plans only; completed plans are deleted by default after durable truth is transferred. - `assets/` — bounded reference/demo assets; generated test evidence should live in CI artifacts instead. Older implementation/refactoring plans in this directory are historical inputs, not automatically current truth. Validate them against code, `architecture.md`, `features.md` and current tests before using them for implementation decisions. + +## Documentation impact contract + +Code and durable documentation ship together. A meaningful change is not complete until every affected canonical owner describes the system as it exists after that change. Do not update every document mechanically: update only affected owners and record plausible-but-unaffected owners as `N/A` during preflight. + +Treat the README as two semantic owners: + +- **Identity** changes only when ClosedRoom's purpose, primary audience/outcome or positioning changes. Do not opportunistically rewrite it for implementation, feature, command or configuration changes. +- **Usage** changes whenever prerequisites, setup, run/start, configuration, public API/UI workflow or copy-paste examples would otherwise become incomplete, incorrect or misleading. + +A change may therefore legitimately report `README_IDENTITY: N/A` and `README_USAGE: UPDATED`. + +Use this routing for other durable impact: feature behavior -> `features.md` or its bounded `features/` owner; architecture/ownership -> `architecture.md`; durable rationale -> ADR; trust/privacy/data lifecycle -> `SECURITY.md` and/or the owning architecture/feature doc; canonical command semantics -> `.engineering/commands.json`; product-experience contracts -> `design/*`; integrated/blocker/next truth -> `current-state.md`. + +## Lifecycle + +Assess documentation impact from observable behavior, not filenames. Search for the existing owner first. Existing feature documentation must be updated in the same change when the behavior it describes changes. Create a new feature document only when durable non-obvious behavior is not sufficiently discoverable from code, public contracts, tests or the existing aggregate registry. + +Active work remains disposable: + +`plan -> implement -> validate -> transfer durable knowledge -> delete plan` + +Do not create documentation merely to record that a PR or task completed. diff --git a/docs/architecture.md b/docs/architecture.md index c8d1db90..e8d8e9ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architettura di ClosedRoom -Stato del documento: architettura implementata nel repository al 13 luglio 2026. +Stato del documento: architettura implementata nel repository al 5 settembre 2026. Questo documento descrive ClosedRoom prima ad alto livello, per chiarire confini, responsabilità e flussi principali, e poi a basso livello, per rendere espliciti @@ -19,6 +19,8 @@ I principi che guidano l'architettura sono: - persistenza progressiva per limitare la perdita di dati; - filesystem come fonte degli artefatti e SQLite come indice interrogabile; - servizi ML pesanti avviati solo quando necessari; +- la cattura attiva impedisce l'avvio di nuovi workload AI pesanti; +- un solo `HeavyWorkloadArbiter` possiede queue e concorrenza dei workload pesanti; - integrazioni cloud esclusivamente opt-in; - compatibilità esplicita tra sviluppo e bundle macOS; - fallimento controllato degli arricchimenti post-meeting: diarizzazione, @@ -53,9 +55,9 @@ quando selezionati esplicitamente. | Container | Tecnologia | Responsabilità | Stato posseduto | | --- | --- | --- | --- | -| Frontend | React, TypeScript, Vite | Navigazione, registrazione, configurazione, polling job e rendering workspace | Stato UI temporaneo; demo sintetica in memoria | +| Frontend | React, TypeScript, Vite | Navigazione, registrazione, configurazione, consumo eventi job e rendering workspace | Stato UI temporaneo; demo sintetica in memoria | | API locale | FastAPI, Python | Composition root, autenticazione locale, contratti HTTP e orchestrazione applicativa | Registry dei servizi e token di sessione del processo | -| Servizi di dominio | Python | Registrazioni, trascrizioni, analisi, settings, runtime e arricchimenti | Lock e job attivi in memoria; stato durevole delegato agli store | +| Servizi di dominio | Python | Registrazioni, trascrizioni, analisi, settings, runtime, arricchimenti e resource admission | Lock e job attivi in memoria; stato durevole delegato agli store | | Catalogo | SQLite WAL | Query su meeting, transcript, run di analisi, job, eventi e cache | `closedroom.db` | | Archivio artefatti | Filesystem | Audio, metadata, transcript esportabili, report e staging visuale | Directory registrazioni e trascrizioni | | Runtime ASR | MLX Whisper / `mlx-audio` | Trascrizione locale Apple Silicon | Cache modelli e cache risultati | @@ -79,6 +81,8 @@ flowchart TB Stores --> FS["Filesystem"] Stores --> Catalog["CatalogStore"] + Managers --> Policy["ResourcePolicy"] + Policy --> Arbiter["HeavyWorkloadArbiter"] Managers --> JobStore["JobStore"] Catalog --> SQLite["closedroom.db"] JobStore --> SQLite @@ -91,7 +95,8 @@ flowchart TB Le dipendenze devono scendere dai router verso servizi e store. `server.py` costruisce gli oggetti e non deve diventare un contenitore di logica di dominio. `AppServices` è il registry tipizzato condiviso; gli alias su `app.state` sono una -compatibilità temporanea per test e integrazione nativa. +compatibilità temporanea per test e integrazione nativa. `ResourcePolicy` decide +solo l'ammissione: non possiede queue, worker, recording state o model lifecycle. ## 5. Flussi principali @@ -102,6 +107,13 @@ ClosedRoom dispone di due backend di cattura: 1. nativo macOS, preferito, tramite AVFoundation e ScreenCaptureKit; 2. browser, con `MediaRecorder` e BlackHole per l'audio di sistema. +Nel normale New Meeting il frontend prova automaticamente microfono + audio +computer. Backend, source mode e device non fanno parte del golden path e vengono +mostrati solo come recovery quando la cattura automatica non è disponibile. Con +backend nativo pronto, `Contesto schermo` è una disclosure secondaria, disattivata +per default: solo una finestra/schermo scelti esplicitamente abilitano la cattura +di frame a bassa frequenza. Questa scelta non avvia alcun VLM durante recording. + ```mermaid sequenceDiagram actor U as Utente @@ -110,14 +122,17 @@ sequenceDiagram participant R as RecordingStore participant N as Native helper o Browser + opt Contesto schermo scelto esplicitamente + U->>F: seleziona finestra/schermo + end U->>F: Avvia registrazione F->>A: POST /v1/recordings A->>R: create() R-->>A: recording_id + tracce alt cattura nativa - F->>A: POST /capture/start + F->>A: POST /capture/start (+ visual_window_id opzionale) A->>N: avvia processo helper - N-->>A: eventi JSON e file traccia + N-->>A: eventi JSON, file traccia e frame opzionali else cattura browser F->>N: getUserMedia + MediaRecorder loop per ogni chunk e traccia @@ -134,30 +149,47 @@ sequenceDiagram Il contratto critico è la sequenza monotona dei chunk. Un retry identico è idempotente se dimensione e SHA-256 coincidono; un contenuto diverso per una sequenza già committata genera conflitto. Ogni sessione usa un lock dedicato e -le scritture dei metadata sono atomiche. +le scritture dei metadata sono atomiche. La selezione visuale è indipendente +dalla scelta audio: nessuna sorgente visuale esplicita significa nessun frame. + +### 5.2 Trascrizione e arricchimenti post-meeting -### 5.2 Trascrizione e arricchimento post-meeting +Il normale percorso Meeting separa la trascrizione dall'arricchimento visuale. +`Transcribe` produce il transcript con i default persistiti; `Analizza contesto +schermo` è un'azione successiva e opzionale, visibile solo quando esistono frame +e un transcript. I workflow tecnici/import possono ancora usare il vecchio +override visuale per-run per compatibilità, ma non sono il golden path. ```mermaid flowchart TD - Start["Job trascrizione"] --> Validate["Validazione audio e selezione provider"] - Validate --> Tracks["Trascrizione delle tracce mic/system/mixed"] - Tracks --> Diar["FluidAudio diarization
se abilitata"] - Diar --> Merge["Merge temporale dei transcript"] - Merge --> Vision["Qwen visual intelligence
se frame e setting presenti"] - Vision --> Audio["Audio intelligence
VAD/RMS e metriche"] - Audio --> Save["Salvataggio transcript JSON/TXT + catalogo"] - Save --> Done["Job completed"] - + Start["Transcribe"] --> Admission["ResourcePolicy / HeavyWorkloadArbiter"] + Admission --> Validate["Validazione audio e selezione provider"] + Validate --> Tracks["Trascrizione tracce mic/system/mixed"] + Tracks --> Diar["Diarizzazione
se prevista dal workflow"] + Diar --> Merge["Merge temporale transcript"] + Merge --> Audio["Audio intelligence
VAD/RMS e metriche"] + Audio --> Save["Salva transcript JSON/TXT + catalogo"] + Save --> TranscriptReady["Transcript pronto"] + + Admission -. "capture attiva" .-> Rejected["resource_admission failed"] Diar -. "errore non bloccante" .-> Merge - Vision -. "errore non bloccante + cleanup" .-> Audio Audio -. "errore non bloccante" .-> Save + + TranscriptReady -. "frame presenti + azione utente" .-> VisualAction["Analizza contesto schermo"] + VisualAction --> VisualJob["visual_intelligence job persistito"] + VisualJob --> VisualAdmission["stesso ResourcePolicy / Arbiter"] + VisualAdmission --> Route["candidate detection + dedupe + hard budget"] + Route --> VLM["VLM task-aware v2"] + VLM --> PersistVisual["aggiorna lo stesso transcript in place"] + PersistVisual --> VisualDone["contesto visuale pronto"] + VisualAdmission -. "capture attiva" .-> VisualRejected["resource_admission failed"] ``` Per ASR locale, l'inferenza usa MLX Whisper o Nemotron. Speechmatics Batch è un provider opzionale e crea un job remoto per ogni traccia trascrivibile. Le speaker label prodotte da un provider hanno precedenza sulla diarizzazione -locale. +locale. Il job visuale non rilancia ASR o diarizzazione e non crea un secondo +transcript. ### 5.3 Analisi @@ -166,6 +198,7 @@ sequenceDiagram participant UI as Meeting/Analysis UI participant API as Analysis router participant JM as AnalysisJobManager + participant RP as ResourcePolicy/Arbiter participant AS as AnalysisService participant RM as RuntimeServiceManager participant LLM as local-llm-server o Gemini @@ -173,6 +206,8 @@ sequenceDiagram UI->>API: POST /v1/analysis-jobs o /analysis-pipelines API->>JM: crea job persistente + JM->>RP: admission al submit + prima execution + RP-->>JM: ammesso oppure capture_active JM-->>UI: job_id JM->>AS: esegui template/pipeline AS->>RM: ensure_llm_ready (provider locale) @@ -183,12 +218,15 @@ sequenceDiagram AS->>DB: salva cache e analysis_run end JM->>DB: aggiorna job/eventi - UI->>API: polling /v1/jobs/{id} + UI->>API: SSE /v1/jobs/{id}/events ``` Le pipeline sono insiemi versionati di template. La cache include hash input, prompt, provider, modello e opzioni capaci di modificare l'output. Le credenziali -cloud entrano nella chiave solo sotto forma di hash. +cloud entrano nella chiave solo sotto forma di hash. Nel normale Meeting gli +stati attivi sono seguiti tramite eventi persistiti; un GET snapshot viene usato +solo dopo errore dello stream per riconciliare recovery/reconnect. Il wizard +tecnico/import mantiene il proprio polling legacy ed è fuori dal golden path. ## 6. Modello dei dati ad alto livello @@ -237,10 +275,10 @@ erDiagram } ``` -Una registrazione è l'aggregato che rappresenta il meeting. “Meeting” e -“Project” sono viste derivate dal catalogo, non tabelle parallele. Il filesystem -rimane necessario perché contiene audio e file esportabili; SQLite evita scansioni -ripetute e supporta query cross-feature. +Una registrazione è l'aggregato persistente che rappresenta il meeting. A livello +prodotto `Meeting` è l'oggetto primario; “Meeting” e “Project” restano viste +derivate dal catalogo, non tabelle parallele. Il filesystem contiene audio e file +esportabili; SQLite evita scansioni ripetute e supporta query cross-feature. ## 7. Deployment e modalità di esecuzione @@ -282,8 +320,8 @@ La funzione `create_app()` in `server.py` è la composition root: 3. sceglie il path del catalogo, con isolamento speciale per i test temporanei; 4. costruisce `CatalogStore` e `JobStore` sullo stesso database; 5. marca come `interrupted` i job non terminali lasciati da un riavvio; -6. costruisce recording/transcription store, manager di cattura, runtime e job; -7. registra tutto in `AppServices`; +6. costruisce `RecordingStore`, poi `ResourcePolicy` e l'unico `HeavyWorkloadArbiter` usando lo stato recording canonico; +7. costruisce transcription/capture/runtime/job manager e registra i servizi in `AppServices`; 8. pulisce dispositivi audio aggregati orfani; 9. monta asset statici e router; 10. installa il middleware di autenticazione. @@ -300,8 +338,12 @@ La funzione `create_app()` in `server.py` è la composition root: - `recordings: RecordingStore`; - `transcriptions: TranscriptionStore`. -Gli oggetti sono posseduti da una singola istanza FastAPI. Non va introdotto -nuovo stato globale quando può essere espresso come servizio nel registry. +`ResourcePolicy` e `HeavyWorkloadArbiter` sono process-wide state della composition +root, ma non duplicano gli owner di dominio: la policy legge +`RecordingStore.active_recording()` e l'arbiter possiede soltanto admission, +queue e worker dei workload pesanti. Gli altri oggetti sono posseduti da una +singola istanza FastAPI. Non va introdotto nuovo stato globale quando può essere +espresso tramite gli owner esistenti. ## 9. Layer HTTP @@ -311,6 +353,7 @@ I router sono sottili adattatori tra Pydantic/HTTP e il dominio: | --- | --- | | `routers/recordings.py` | Sessioni, chunk per traccia, frame visuali, stop/recovery, audio e metadata | | `routers/transcriptions.py` | Upload/path, job di trascrizione, storico, merge/split e API job condivise | +| `routers/visual_jobs.py` | Avvio/cancellazione del job visuale post-meeting sul transcript esistente | | `routers/analysis.py` | Analisi sincrona legacy, job, pipeline, template e run persistiti | | `routers/workspace.py` | Proiezioni `meetings` e `projects` | | `routers/settings.py` | Lettura pubblica e patch validata delle impostazioni | @@ -337,7 +380,8 @@ CORS non è aperto implicitamente: le origin ammesse provengono da configurazion - sequenze chunk, hash, dimensioni e timestamp client; - finalizzazione, recovery, discard e sincronizzazione catalogo; - staging e cleanup dei frame visuali; -- report qualità, intelligence e diarizzazione. +- report qualità, intelligence e diarizzazione; +- query canonica `active_recording()` usata anche dalla resource admission. ### Layout di sessione @@ -382,10 +426,12 @@ L'helper nativo può acquisire: - audio di sistema con ScreenCaptureKit; - frame JPEG a bassa frequenza da una sola finestra esplicitamente scelta. -La cattura visuale è uno stream separato da quella audio e non è attiva senza -setting e selezione finestra. La schermata Registrazione espone nello stesso -punto il toggle FluidAudio, il toggle Qwen e il selettore della finestra: una -configurazione parziale è dichiarata prima dell'avvio. +La capacità di cattura visuale resta tecnicamente separata da quella audio. +`NewRecordingPage` non espone toggle Qwen o modello visuale: presenta invece +`Contesto schermo` come disclosure secondaria off-by-default, disponibile solo +quando il backend nativo è pronto. La selezione esplicita di una sola sorgente +abilita i frame a 0.5 fps; nessun VLM viene eseguito durante la registrazione. +I workflow tecnici/legacy che dispongono già di frame restano compatibili. ### AudioRouter @@ -433,7 +479,8 @@ Ogni transcript salvato produce: Il JSON conserva testo, segmenti, statistiche, provider/backend, opzioni pubbliche, tracce sorgente e analisi legacy. La stessa entità viene indicizzata in SQLite. Merge e split non cancellano le fonti: aggiornano `hidden` e -`merged_into`, consentendo il ripristino. +`merged_into`, consentendo il ripristino. Gli arricchimenti visuali on-demand +aggiornano la stessa entità in place, mantenendo ID, path e `created_at`. ## 13. Pipeline di arricchimento @@ -448,15 +495,45 @@ supera `speaker_diarization_minimum_overlap`. Il valore assegnato ha forma ### Visual intelligence -`PostMeetingVisualService`: +Il normale ingresso prodotto è post-meeting e separato dalla trascrizione. +`routers/visual_jobs.py` verifica frame + transcript, impedisce un secondo job +visuale attivo per lo stesso transcript e crea un `visual_intelligence` job +persistito attraverso il `TranscriptionJobManager`. Il job passa dall'unico +`HeavyWorkloadArbiter`, quindi la ResourcePolicy blocca l'avvio se nel frattempo +la cattura è attiva. + +`PostMeetingVisualService` nel percorso on-demand: 1. legge i frame ordinati dallo staging; -2. richiede a `RuntimeServiceManager` un modello con capability `image`; -3. invia ogni frame a Qwen con prompt JSON restrittivo; -4. accetta solo nomi e indicatori visibili, senza face recognition; -5. persiste osservazioni e summary; -6. applica un mapping conservativo solo a cluster speaker già esistenti; -7. conserva i JPEG come artefatti della registrazione per consultazione e retry. +2. esegue routing task-aware `v2`, candidate detection e dedupe; +3. applica un hard ceiling di 2048 work item dopo il dedupe, usando sampling deterministico sull'intera timeline se il limite viene superato; +4. richiede a `RuntimeServiceManager` un modello con capability `image` solo quando serve inferenza; +5. invia a Qwen soltanto i candidati ammessi, con prompt JSON specifico del task; +6. accetta solo nomi e indicatori visibili, senza face recognition; +7. persiste osservazioni, summary, documento/routing v2 e aggiorna lo stesso transcript in place; +8. applica un mapping conservativo solo a cluster speaker già esistenti; +9. conserva i JPEG come artefatti della registrazione per consultazione e retry. + +L'override on-demand imposta `routing_mode="v2"` solo per quel run e non modifica +Settings. Se il router bounded fallisce mentre `v2` è richiesto esplicitamente, +il servizio fallisce chiuso: non entra nel percorso legacy `v1`, evitando di +perdere il limite di lavoro. I workflow legacy/settings-driven mantengono la +compatibilità precedente. La cancellazione è cooperativa e viene osservata fra +unità di lavoro bounded; il `job_id` usato dal run non viene persistito come campo +del transcript. + +Il sidecar LLM/VLM gestito parte con un solo modello residente. Per i modelli +di prodotto registrati (`nemotron-nano-4b`, `nemotron-nano-4b-q8`, +`qwen3-vl-4b`) `RuntimeServiceManager` usa il control plane pubblico di +local-llm-server 0.4 per attivare il modello richiesto dopo aver evacuato +quello della fase precedente. Al termine di visual intelligence o analisi +locale, tutti i runtime registrati vengono scaricati e il sidecar resta sano +in stato zero-resident. Endpoint `external` non vengono mai mutati. Modelli +con path configurato esplicitamente, custom o non registrati mantengono il +boundary più conservativo stop/restart del processo posseduto, così +l'artefatto esplicito non viene perso. Un failure +del control plane durante il cleanup degrada a stop del sidecar posseduto, +evitando residency orfana e senza mascherare il risultato del workload. Dopo tre errori infrastrutturali consecutivi dal backend visuale, un circuit breaker interrompe le richieste residue, marca il checkpoint come @@ -474,7 +551,9 @@ gli heartbeat troppo ravvicinati, lasciando sempre passare i cambi ROI. La ROI generica resta sperimentale finché non viene validata su Meet, Zoom e Teams. `inference.py` valida inoltre ogni risposta con un contratto specifico del task: valori parziali o con tipi errati restano errori diagnostici del candidato e non -raggiungono aggregazione temporale o fusion. +raggiungono aggregazione temporale o fusion. Il routing summary conserva anche +il numero di candidati uncapped e le rejection dovute al budget, distinguendo +qualità del selettore e limite di lavoro. `visual_intelligence.json` è il documento canonico v2. `fusion.py` aggiunge `semantic_links` derivati esclusivamente dalla sovrapposizione temporale fra @@ -506,9 +585,12 @@ meeting state si usa un fallback basato sui keyframe, mentre i keyframe esterni a finestre note sono esposti in `unassigned_share_keyframes`. La UI React carica il documento v2 da `MeetingDetailPage` e delega il rendering -a `components/meeting/VisualIntelligencePanel.tsx`. Il pannello gestisce timeline, -share session, mapping accettati/da verificare, astensione, loading, errore e -dataset vuoto; le soglie di tuning restano nel backend. +a `components/meeting/VisualIntelligencePanel.tsx`. `MeetingDetailPage` conta +i frame già acquisiti e presenta l'azione on-demand solo se il contesto è +realmente disponibile. `frontend/src/api/visualJobs.ts` possiede start/cancel del +job dedicato. Il pannello gestisce timeline, share session, mapping accettati/da +verificare, astensione, loading, errore e dataset vuoto; le soglie di tuning +restano nel backend. Il contratto JSON è imposto dal prompt e validato localmente. Per compatibilità con l'output osservato di Qwen MLX, il parser accetta JSON, code fence, literal @@ -520,7 +602,9 @@ oggetto vuoto quando riceve quell'opzione. La fusione richiede il numero minimo di osservazioni e il margine configurati. In caso di evidenza insufficiente si astiene. Se la visual intelligence è richiesta ma non esiste alcun frame, l'esito è -`degraded` con causa `no_visual_frames_captured`, non un successo implicito. +`degraded` con causa `no_visual_frames_captured`, non un successo implicito nei +workflow tecnici; l'endpoint on-demand prodotto rifiuta invece l'avvio prima di +creare il job quando non esistono frame. ### Audio intelligence @@ -531,26 +615,40 @@ marcati; non viene invocato un LLM. ## 14. Job, eventi e concorrenza -Trascrizioni e analisi lunghe vengono eseguite in thread daemon del processo. -Non esiste una coda distribuita esterna. +Trascrizioni, analisi e visual intelligence on-demand sono ammesse dal +process-wide `HeavyWorkloadArbiter`, con default una sola esecuzione pesante +attiva e queue bounded. Non esiste una coda distribuita esterna né un secondo +scheduler per il visuale. + +`ResourcePolicy` è un guard read-only sopra l'arbiter: consulta lazily +`RecordingStore.active_recording()` e rifiuta l'avvio di nuovi workload pesanti +quando esiste una registrazione attiva. La policy viene verificata sia al submit +sia immediatamente prima dell'esecuzione, quindi copre il race job-in-coda -> +inizio registrazione -> worker libero. Non preempta workload già attivi e non +introduce un secondo mutex/scheduler. `JobStore` persiste: - stato corrente e progress in `jobs`; -- timeline append-only in `job_events`; +- timeline sequenziale bounded in `job_events`, con al massimo 512 eventi per job; - payload e risultato JSON; - richiesta di cancellazione; - timestamp di avvio e completamento. Il transcription job pubblica esplicitamente anche `diarizing`, -`visual_processing` e `audio_intelligence`, così polling, eventi persistiti e UI -non restano fermi durante gli arricchimenti locali più lunghi. - -Stati terminali: `completed`, `failed`, `cancelled`, `interrupted`. La -cancellazione è cooperativa: passa prima a `cancelling`; il worker dichiara -`cancelled` solo dopo avere osservato la richiesta ed essere uscito. Al riavvio -del server, i job non terminali diventano `interrupted`, perché i thread non -possono sopravvivere al processo. +`visual_processing` e `audio_intelligence` per i workflow tecnici compatibili. +Il job `visual_intelligence` on-demand usa lo stesso store e pubblica progress +bounded del processing v2. Quando esiste un `JobStore`, il manager non duplica +gli eventi persistiti nella queue process-local; quella queue resta solo per il +fallback senza store. Nel normale Meeting `useMeetingJobEvents` segue lo stream +SSE persistito e ricarica lo stato canonico al terminale, senza interval polling. + +Stati terminali: `completed`, `failed`, `cancelled`, `interrupted`. Una rejection +di resource policy termina il job prima del workload con reason +`capture_active`; la cancellazione resta cooperativa: passa prima a +`cancelling`, e il worker dichiara `cancelled` solo dopo avere osservato la +richiesta ed essere uscito. Al riavvio del server, i job non terminali diventano +`interrupted`, perché i thread non possono sopravvivere al processo. Questa scelta è adeguata a una singola app desktop locale. Un futuro execution backend multi-processo richiederebbe lease, heartbeat, coda durevole e worker @@ -575,7 +673,8 @@ struttura che include Markdown. `RuntimeServiceManager` supporta tre modalità: -- `auto`: possiede il processo `local-llm-server`; +- `auto`: possiede il processo `local-llm-server`, che resta cold all'avvio app e + viene avviato on-demand da `ensure_llm_ready()`; - `external`: usa un endpoint configurato e ne interroga `/health`; - `disabled`: rifiuta l'analisi locale. @@ -583,7 +682,14 @@ struttura che include Markdown. l'eseguibile, attende readiness, raccoglie log e riavvia il processo quando cambia la configurazione effettiva: modello, path, backend, mmproj, context size, timeout o binary llama-server. La capability audio disabilita il reasoning in -modalità automatica; testo e immagine usano le policy risolte dal runtime. +modalità automatica; testo e immagine usano le policy risolte dal runtime. Dopo +le fasi locali gestite, la residency dei modelli registrati viene rilasciata. +Se il sidecar auto-mode posseduto rimane cold, `RuntimeServiceManager` programma +un bounded idle shutdown (90 secondi di default); un nuovo ensure/start/restart/ +stop invalida i timer stale e si serializza con il callback, mentre release di +residency riapre la finestra. `managed_llm_idle_shutdown_seconds=0` consente +reclamation immediata. Endpoint external/disabled non vengono mai fermati o +mutati da questa policy. Nel bundle, l'eseguibile principale gestisce anche i dispatch interni `-m local_llm_server` e `-m mlx_vlm.server`, evitando di riaprire la shell UI nei @@ -597,7 +703,7 @@ ha mostrato una regressione di ownership degli stream GPU nel worker PyInstaller | Settings | `~/Library/Application Support/ClosedRoom/settings.json` | `settings.py`, `SettingsService` | Scrittura atomica e validazione prima del commit | | Catalogo | `~/Library/Application Support/ClosedRoom/closedroom.db` | `CatalogStore`, `JobStore` | SQLite WAL, transazioni e indici | | Registrazioni | setting `recordings_dir` | `RecordingStore` | Lock per sessione, chunk monotoni, metadata atomici | -| Transcript | setting `transcriptions_dir` | `TranscriptionStore` | JSON/TXT più indice SQLite | +| Transcript | setting `transcriptions_dir` | `TranscriptionStore` | JSON/TXT più indice SQLite; visual on-demand aggiorna la stessa entità in place | | Cache ASR | cache app/dev | transcription layer | Chiave SHA-256 sugli input effettivi | | Cache analisi | tabella `analysis_cache` | `AnalysisService` | Chiave SHA-256 versionata | | Prompt | `prompts.json` in Application Support | system router/catalog helpers | Persistenza locale | @@ -612,8 +718,8 @@ ha mostrato una regressione di ownership degli stream GPU nel worker PyInstaller - `transcriptions`: testo, segmenti, provider, merge e riferimenti file; - `analysis_runs`: esecuzioni tipizzate, template, pipeline, output e stato; - `analysis_cache`: risultati riutilizzabili per chiave; -- `jobs`: stato durevole dei task lunghi; -- `job_events`: sequenza degli aggiornamenti di ciascun job. +- `jobs`: stato durevole dei task lunghi, incluso `visual_intelligence`; +- `job_events`: sequenza bounded degli aggiornamenti di ciascun job, max 512 per job. Le modifiche additive allo schema usano `_ensure_column`; non esiste al momento un framework di migrazioni versionate separato. @@ -626,13 +732,20 @@ configurazione effettiva deriva da: 1. default di codice; 2. `settings.json`; 3. variabili ambiente per secret e configurazione di processo; -4. override espliciti nel payload di una singola analisi. +4. override espliciti nel payload di una singola analisi o job. I secret salvati (`gemini_api_key`, `speechmatics_api_key`) non vengono restituiti dall'API; il frontend riceve solo flag `*_configured`. Cataloghi di modelli, provider e preset devono restare centralizzati nei rispettivi moduli backend e in `frontend/src/api/config.ts` per la presentazione. +La Settings UI separa la configurazione persistita dalla gerarchia prodotto: +storage, preferenze meeting e sintesi privacy/processing sono normali; provider, +modelli e qualità sono Advanced; endpoint, model path, parametri interni, +lifecycle e log runtime sono Developer/Diagnostics. La disclosure non cambia il +contratto backend né elimina gli override esperti. L'azione visuale on-demand usa +un override `v2` per-run e non modifica le Settings persistite. + ## 18. Frontend ### Struttura @@ -641,17 +754,23 @@ in `frontend/src/api/config.ts` per la presentazione. health, lingua, demo e tour. Le pagine principali sono: - `DashboardPage`: vista Oggi e digest; -- `RecordingPage` e `RecordingOverlayPage`: cattura e controllo; -- `TranscriptionPage`: sorgenti, job, risultati, merge e split; -- `MeetingDetailPage`: workspace del singolo meeting; +- `NewRecordingPage`: golden path dei nuovi meeting, con readiness/recovery audio e disclosure opzionale `Contesto schermo` quando la cattura nativa è pronta; +- `RecordingPage` e `RecordingOverlayPage`: dettaglio/compatibilità cattura e controllo; +- `TranscriptionPage`: sorgenti, job, risultati, merge e split; è il workflow tecnico/import, mentre la trascrizione normale parte in una azione dal Meeting; +- `MeetingDetailPage`: workspace del singolo meeting, con Transcribe/Generate Notes one-action e CTA visuale secondaria solo quando esistono frame; - `ProjectsPage`: proiezione per progetto; - `AnalysisPage`: analisi libera o su transcript; -- `SettingsPage`: configurazione e runtime locale. - -`frontend/src/api/apiClient.ts` è il contratto HTTP tipizzato. Le pagine non -devono duplicare URL o serializzazione. `useRecorder` orchestra il complesso -lifecycle di cattura; `useAudioDevices` possiede dispositivi, permission e -routing. I testi vivono in `i18n/locales/it.ts` e `en.ts`. +- `SettingsPage`: preferenze, Advanced processing e Developer/Diagnostics. + +`frontend/src/api/apiClient.ts` è il contratto HTTP tipizzato generale; +`frontend/src/api/visualJobs.ts` possiede start/cancel del job visuale on-demand; +`frontend/src/api/jobEvents.ts` possiede il follower SSE e il recovery GET, mentre +`hooks/useMeetingJobEvents.ts` collega i job attivi al normale Meeting. Le pagine +non devono duplicare URL o serializzazione. `useRecorder` orchestra il complesso +lifecycle di cattura; `useAudioDevices` possiede dispositivi, permission e routing. +Il meter di recording conserva i valori high-frequency in ref e limita +redraw/state updates a una cadenza umana; quando il documento è hidden evita il +lavoro visuale del meter. I testi vivono in `i18n/locales/it.ts` e `en.ts`. ### Distribuzione @@ -666,9 +785,12 @@ mano. `static_vanilla_backup/` è una copia legacy, non la superficie runtime. - ogni stop o errore di cattura deve ripristinare il routing audio; - file nativi vengono verificati con `ffprobe` prima di essere dichiarati validi; - job attivi al restart diventano `interrupted`; +- un workload pesante non ammesso durante capture termina prima dell'esecuzione con reason `capture_active`; - cambio configurazione LLM provoca restart controllato del sidecar; -- diarizzazione, Qwen e audio intelligence salvano errore/stato ma non bloccano - il transcript; +- diarizzazione, Qwen e audio intelligence salvano errore/stato ma non invalidano un transcript già valido; +- il job visuale on-demand rifiuta l'avvio se mancano frame/transcript e un errore del router `v2` esplicito fallisce senza fallback al percorso legacy non bounded; +- la cancellazione visuale viene osservata fra unità di lavoro bounded; +- un errore dello stream job nel Meeting provoca una riconciliazione GET e un reconnect ritardato, non il ripristino del polling normale; - i checkpoint visuali terminali vengono chiusi senza eliminare i frame; - scritture settings e principali JSON di registrazione usano file temporaneo e `os.replace`. @@ -683,7 +805,8 @@ mano. `static_vanilla_backup/` è una copia legacy, non la superficie runtime. - provider cloud disattivati per default; - secret rimossi dalle risposte e dai metadata; - hash delle credenziali, non credenziali, nelle cache key; -- selezione esplicita della finestra per acquisizione visuale; +- la cattura visuale è off-by-default e fuori dal golden path: richiede una sorgente esplicita nel New Meeting e non avvia AI visuale durante recording; +- l'analisi dei frame richiede una seconda azione esplicita post-meeting e resta soggetta alla stessa resource admission dei workload pesanti; - frame visuali persistenti nella directory privata della registrazione; - bundle helper con usage description macOS e code signing. @@ -700,7 +823,7 @@ L'osservabilità è locale e orientata al desktop: - `/health` espone identità, versione, PID e stato bundle; - API runtime espongono status, PID, porta, modello caricato ed errori sidecar; -- `job_events` conserva progress e transizioni; +- `job_events` conserva in modo bounded progress e transizioni, inclusi i job visuali on-demand; - `timeline.json`, quality report e warning registrano il lifecycle cattura; - log Uvicorn coprono backend e arricchimenti; - il sidecar LLM ha un file log dedicato consultabile dalla Settings UI; @@ -717,7 +840,7 @@ Non sono presenti metriche remote, tracing distribuito o telemetry SaaS. 2. collocare la route nel router di dominio; 3. mettere decisioni applicative in un service; 4. usare store/catalogo esistenti per la persistenza; -5. aggiornare `apiClient.ts` e i chiamanti; +5. aggiornare il client frontend owner e i chiamanti; 6. aggiungere test TestClient e aggiornare documentazione. ### Nuovo provider ASR o LLM @@ -731,12 +854,12 @@ Non sono presenti metriche remote, tracing distribuito o telemetry SaaS. ### Nuovo arricchimento post-meeting -1. inserirlo nella pipeline dopo l'ASR nel punto semanticamente corretto; +1. decidere esplicitamente se appartiene al golden path o a un'azione on-demand; 2. renderlo idempotente e non bloccante salvo requisito contrario; 3. definire owner e formato persistito; -4. evitare di duplicare metadata nel filesystem e nel catalogo senza una - strategia di sincronizzazione; -5. dichiarare cleanup, timeout e comportamento su cancel/restart. +4. evitare di duplicare metadata nel filesystem e nel catalogo senza una strategia di sincronizzazione; +5. dichiarare cleanup, timeout e comportamento su cancel/restart; +6. se è pesante, passare dall'arbiter/resource policy invece di creare worker o concorrenza indipendenti. ### Nuovo helper nativo @@ -749,20 +872,17 @@ Non sono presenti metriche remote, tracing distribuito o telemetry SaaS. ## 23. Vincoli e debito architetturale noto -- i job sono persistiti ma i worker sono thread in-processo: non riprendono dopo - restart; +- i job sono persistiti ma i worker sono thread in-processo: non riprendono dopo restart; - lo schema SQLite evolve con colonne additive, senza migration ledger; - `AppServices` mantiene alias legacy su `app.state` durante la migrazione; - filesystem e catalogo richiedono sincronizzazione esplicita; -- alcuni store rileggono settings dinamicamente, quindi una directory globale - può prevalere su un default iniettato; +- alcuni store rileggono settings dinamicamente, quindi una directory globale può prevalere su un default iniettato; - il frontend è una SPA hash-based senza router library dedicata; - audio intelligence produce ancora insight mock; -- gli arricchimenti, ASR/VAD, cattura nativa e overlay espongono fallback - espliciti, verificati anche dalla `.app`; l'eseguibile congelato inoltra - `inspect-meeting` alla CLI senza avviare la shell grafica; -- la baseline test include casi storicamente non allineati per AudioRouter e - directory recording, da distinguere dalle regressioni reali; +- la ROI visuale generica resta sperimentale finché non viene validata su Meet, Zoom e Teams; la cadenza 0.5 fps resta soggetta al benchmark F0 prima di qualsiasi semplificazione evidence-led; +- il wizard tecnico/import `TranscriptionPage` usa ancora polling job; il normale Meeting usa eventi bounded e recovery GET; +- gli arricchimenti, ASR/VAD, cattura nativa e overlay espongono fallback espliciti, verificati anche dalla `.app`; l'eseguibile congelato inoltra `inspect-meeting` alla CLI senza avviare la shell grafica; +- la baseline test include casi storicamente non allineati per AudioRouter e directory recording, da distinguere dalle regressioni reali; - build, cattura nativa e diarizzazione sono intenzionalmente Apple Silicon/macOS. ## 24. Mappa delle fonti di verità @@ -770,10 +890,13 @@ Non sono presenti metriche remote, tracing distribuito o telemetry SaaS. | Concetto | Fonte di verità | | --- | --- | | Composition e dipendenze | `src/local_asr_server/server.py`, `app_services.py` | -| Contratti request/response | `schemas.py`, router, `frontend/src/api/apiClient.ts` | +| Contratti request/response | `schemas.py`, router, `frontend/src/api/apiClient.ts`, `frontend/src/api/visualJobs.ts` | | Stati e tracce recording | `recordings.py` | +| Resource admission / heavy concurrency | `runtime/resource_policy.py`, `runtime/workload_arbiter.py` | | Provider ASR | `asr_provider.py`, `asr_models.py` | | Workflow trascrizione | `services/transcription_service.py`, `transcription_jobs.py` | +| Job progress/event history | `jobs/job_store.py`, `transcription_jobs.py`, `frontend/src/api/jobEvents.ts`, `frontend/src/hooks/useMeetingJobEvents.ts` | +| Job visuale on-demand | `routers/visual_jobs.py`, `transcription_jobs.py`, `TranscriptionStore.replace_visual_intelligence()` | | Template/pipeline analisi | `analysis_templates.py`, `analysis_jobs.py` | | Provider LLM | `llm.py` | | Lifecycle sidecar | `runtime/service_manager.py`, `runtime/llm_sidecar.py` | @@ -782,10 +905,11 @@ Non sono presenti metriche remote, tracing distribuito o telemetry SaaS. | Default settings | `settings.py` | | Cattura macOS | `native_capture.py`, `native_capture_helper/` | | Diarizzazione | `speaker_diarization.py`, `speaker_diarization_helper/` | -| Visual intelligence | `visual_intelligence/`: `service.py` orchestra; `contracts.py` possiede schemi/configurazione; `signatures.py` le firme economiche; `router.py` la selezione task-aware; `inference.py` i prompt per task; `temporal.py` intervalli/eventi/sessioni; `fusion.py` il mapping conservativo sui cluster provider. | +| Visual intelligence | `visual_intelligence/`: `service.py` orchestra; `contracts.py` possiede schemi/configurazione/budget; `signatures.py` le firme economiche; `router.py` la selezione task-aware e il ceiling; `inference.py` i prompt per task; `temporal.py` intervalli/eventi/sessioni; `fusion.py` il mapping conservativo sui cluster provider. | | Processor visuali | `visual_intelligence/processors.py` converte risposte legacy e task-aware in osservazioni persistibili; parsing e validazione restano in `inference.py`, mentre `service.py` coordina policy, routing e lifecycle. | -| Fetch visuale React | `api/visualIntelligence.ts` possiede i contratti e `hooks/useVisualIntelligence.ts` loading/error/abort; `MeetingDetailPage` coordina il workspace senza attendere il documento v2. | +| Fetch visuale React | `api/visualIntelligence.ts` possiede i contratti documento, `api/visualJobs.ts` start/cancel, `hooks/useVisualIntelligence.ts` loading/error/abort; `MeetingDetailPage` coordina il workspace. | | Frontend navigation | `frontend/src/App.tsx` | +| Product experience contract | `design/ux-contract.json` | | Build e packaging | `build.sh`, `ClosedRoom.spec`, `pyproject.toml` | | Registro funzionale | `docs/features.md` | @@ -809,4 +933,4 @@ Per modifiche a helper, bundle, risorse o path PyInstaller: ``` Non usare una trascrizione Whisper reale come smoke test: può scaricare modelli -grandi. Preferire TestClient, provider mock e test mirati sui service boundary. +grandi. Preferire TestClient, provider mock e test mirati sui service boundary. \ No newline at end of file diff --git a/docs/assets/closedroom-meeting-to-memory-workflow.png b/docs/assets/closedroom-meeting-to-memory-workflow.png new file mode 100644 index 00000000..62a75dcc Binary files /dev/null and b/docs/assets/closedroom-meeting-to-memory-workflow.png differ diff --git a/docs/assets/closedroom-product-overview.png b/docs/assets/closedroom-product-overview.png new file mode 100644 index 00000000..edd86355 Binary files /dev/null and b/docs/assets/closedroom-product-overview.png differ diff --git a/docs/assets/closedroom-speaker-intelligence.png b/docs/assets/closedroom-speaker-intelligence.png new file mode 100644 index 00000000..3c890e38 Binary files /dev/null and b/docs/assets/closedroom-speaker-intelligence.png differ diff --git a/docs/current-state.md b/docs/current-state.md index 72f6395f..28ddb22d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -2,51 +2,50 @@ ## Engineering baseline -ClosedRoom follows `daniele21/repo-template-sw` **0.8.0** with target maturity **L2** and profiles `python`, `typescript`, `macos`, `local-ai`, `product-ui`. +ClosedRoom follows `daniele21/repo-template-sw` **0.10.0**, maturity **L2**, with `python`, `typescript`, `macos`, `local-ai`, `product-ui`. Changes integrate through `dev`; `dev -> main` is RELEASE. Target-Mac evidence is blocking only at release. -The 0.8 baseline is integrated on `speaker_detection`, the advanced product branch that was verified ahead of `main`, `pipeline`, `tech-improvements` and `ux-refactoring` at adoption time. +## Integrated baseline -## Strong existing evidence to preserve +- Exact-head/tree-equivalent preflight, immutable finalized artifacts and packaged-app lifecycle smoke are established. +- PRS-5..9 integrated Meeting-first defaults, visual on-demand, bounded model residency, SSE progress and privacy-safe audio-strategy benchmark tooling; dual-track audio remains canonical pending representative evidence. +- PRS-11..17 integrated saved Meetings/Prepare notes, structured verifiable notes, bounded archive search, safe capture priority and the adaptive Today/Meeting/Projects workspace. +- PRS-17 candidate `c1c79f31` passed FULL preflight #306, including 411 Python tests and declared Meeting FULL_MEDIA journeys. +- PRS-18 release tooling integrated through PR #48; PR #53 added one aggregate REAL_ENVIRONMENT runner over the measured target-Mac and PRS-16 contention owners while keeping release qualification fail-closed. +- PRS-18 measured release remains the only active workstream. -- Detailed current architecture and a broad Python unit/integration suite. -- Explicit runtime/service/job/port ownership. -- Native macOS capture, diarization and visual-intelligence boundaries with focused tests. -- Existing visual + diarization smoke tooling and representative datasets. -- Version-aware macOS app packaging and native-helper validation. -- Code-first semantic UI tokens/components in the React frontend. +## Current integration state -## Baseline gaps now implemented +`HeavyWorkloadArbiter` remains the sole heavy-work owner. Capture waits for the next safe managed-work boundary, holds bounded queued work during recording and releases it afterward; `ResourcePolicy` remains the fail-safe. `RecordingStore` remains the persistence owner and external runtimes remain caller-owned. -- Blast radius is machine-selected by `scripts/select_validation_profile.py` into LEAN / SCOPED / STRONG / FULL; unknown paths fail safe to FULL. -- `.github/workflows/preflight.yml` provides exact-head, read-only remote preflight and routes source/package validation by selected profile. -- Canonical builds use `scripts/build_artifact.sh`; successful artifacts receive unique identity and immutable lineage directories under `dist/artifacts/`. -- `scripts/finalize_build_artifact.py` creates `build-manifest.json`, aggregate SHA-256 evidence, `SHA256SUMS`, `BUILD_CHANGELOG.md` against the previous successful comparable build and bounded local retention. -- `scripts/clean_build_state.py` removes transient build state without deleting finalized successful artifacts by default. -- `scripts/smoke_packaged_app.py` exercises the finalized `.app` frozen executable, bundled FastAPI/static frontend, readiness, graceful stop, listener cleanup and observed child cleanup. -- Packaging no longer requires a developer-machine absolute `local-llm-server` wheel path; the current 0.3.8 integration points to its published release artifact and digest. -- Packaging precompiles the Core Audio helper without invoking user-facing `setup-audio`, so CI build does not install BlackHole or mutate audio routing merely to produce an artifact. +The frontend reserves before capture, shows truthful cancellable preparation and starts the timer only with real capture. `App.tsx` + `workspace.css` own the adaptive shell. -## Residual target-environment evidence +PRS-18 has two separate target-Mac evidence paths: -Automated packaged-app smoke is deliberately classified as `representative_virtual`, not complete target evidence. These remain separate when a change makes them material: +- **release evidence**: `release_build` produces the exact Developer-ID-signed, securely timestamped, notarized/stapled app + DMG and Gatekeeper proof; `release_evidence` exercises that immutable artifact through WKWebView/TCC FULL_MEDIA, real local MLX/resource/thermal, PRS-9 and PRS-16 contention evidence; +- **LOCAL REAL_ENVIRONMENT**: `python3 scripts/run_local_real_environment_suite.py` builds or reuses one exact finalized **ad-hoc** Apple-Silicon app and runs the same physical evidence owners through a local-only adapter. Evidence is written under `dist/evidence/local-real-environment//`. This proves target-Mac behavior but never establishes distribution or release readiness. -- interactive WKWebView/window/focus behavior; -- real TCC prompts and permission identity; -- physical microphone and system-audio device behavior; -- production signing/notarization identity; -- production MLX/Metal model compatibility, memory, latency, throughput and quality. +The local adapter does not modify the canonical release runners or their Developer ID/notarization requirement. -Source-contract tests likewise do not upgrade these claims. +## Release evidence still pending -## Current evidence status +Stable promotion remains blocked until **RELEASE / FULL** automation and applicable target-environment evidence agree on the exact candidate. -The gap-closing implementation is on `close-baseline-gaps`. Its deterministic unit checks and exact-head GitHub Actions preflight must pass on the resulting PR before these mechanisms are considered proven on `speaker_detection`. Until that run exists, implementation is present but remote execution evidence is pending. +Still required: -Historical planning documents still need a separate lifecycle cleanup; they are not treated as current operational truth. +- Developer-ID/notary authority for the exact production artifact; without Apple Developer Program membership this is an explicit external release blocker, not a failed local physical test; +- target-Mac WKWebView/accessibility/focus + TCC-backed native `both` capture with non-empty mic/system tracks and clean lifecycle; +- PRS-16 real AI-busy contention: local MLX active while `Preparing recording` is visible, no premature capture, then capture after the safe boundary with mic/system persistence; +- representative CPU/RSS/thermal + local MLX completion and the PRS-9 dual-vs-mixed benchmark; +- subjective VoiceOver/usability where materially required and representative evidence for material production ASR/LLM quality or latency claims. + +A passing LOCAL REAL_ENVIRONMENT run can satisfy the applicable physical target-Mac observations for its exact ad-hoc artifact, but cannot satisfy Developer ID/notarization/stapling/distribution-Gatekeeper qualification. + +## Active workstream + +- [`meeting-value-efficiency.md`](workstreams/meeting-value-efficiency.md): PRS-11..17 integrated; PRS-18 measured release active. ## Next highest-value work -1. Obtain green exact-head remote preflight for the gap-closing PR, including finalized `.app` package smoke on macOS arm64. -2. Fix any failing gate at its owning invariant rather than weakening the profile or check. -3. Merge the gap closure into `speaker_detection` only after deterministic evidence is green. -4. Continue moving reproducible failures found only during final macOS testing into the cheapest sufficient automated environment while preserving genuinely physical/TCC/model evidence separately. +1. Freeze the exact `dev` candidate and live `main` base and obtain RELEASE/FULL automation. +2. Without Apple distribution authority, run `python3 scripts/run_local_real_environment_suite.py` on the representative Mac and retain its exact evidence while keeping distribution authority blocked. +3. If distribution authority becomes available, run the canonical signed/notarized release path and promote only when all blocking evidence matches the frozen candidate. diff --git a/docs/features.md b/docs/features.md index e7aa00be..f1c581b6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -29,7 +29,7 @@ e cambiata. | Trascrizione, cache e streaming | `src/local_asr_server/transcriber.py` | | Runtime locale e servizi gestiti | `src/local_asr_server/runtime/` | | Routing audio macOS | `src/local_asr_server/audio_router.py` e `src/local_asr_server/macos_audio_helper/` | -| App menu bar e WKWebView | `src/local_asr_server/menubar.py` e `src/local_asr_server/window.py` | +| App macOS menu bar e WKWebView | `src/local_asr_server/menubar.py` e `src/local_asr_server/window.py` | | Frontend React sorgente | `frontend/src/` | | Frontend statico servito | `src/local_asr_server/static/` | @@ -37,28 +37,32 @@ e cambiata. | Feature | Valore business | Superficie tecnica | Persistenza e configurazione | Verifica minima | | --- | --- | --- | --- | --- | -| Avvio server locale | Espone ClosedRoom come servizio locale per registrare, trascrivere e analizzare audio con accesso protetto same-origin, mantenendo separato il backend dev dall'app macOS. | CLI `local-asr serve`, risoluzione porta in `cli.py` (`1236` normale, `1237` con `--reload` salvo `--port` esplicito), `create_app()`, endpoint pubblici `/health` e `/v1/session`, static serving, middleware auth locale. | Token sessione generato in app state o `LOCAL_ASR_API_TOKEN`; CORS solo da `LOCAL_ASR_ALLOWED_ORIGINS`; cache in `.cache/` dev o `~/Library/Caches/ClosedRoom/` bundle. | `UV_CACHE_DIR=.cache/uv uv run local-asr serve --reload`, `curl http://127.0.0.1:1237/health`, `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_cli.py' -v` e bootstrap cookie via `/v1/session` prima delle API protette. | +| Avvio server locale | Espone ClosedRoom come servizio locale per registrare, trascrivere e analizzare audio con accesso protetto same-origin, mantenendo separato il backend dev dall'app macOS. In modalità LLM locale gestita il sidecar resta cold all'avvio, parte solo alla prima fase AI che lo richiede e, dopo il rilascio della residency, viene fermato se rimane inutilizzato oltre la finestra idle posseduta da ClosedRoom. | CLI `local-asr serve`, risoluzione porta in `cli.py` (`1236` normale, `1237` con `--reload` salvo `--port` esplicito), `create_app()`, endpoint pubblici `/health` e `/v1/session`, static serving, middleware auth locale; `RuntimeServiceManager.ensure_llm_ready()` resta l'owner dell'avvio on-demand, `release_llm_residency()` del rilascio modelli e il service manager serializza il bounded idle shutdown con ensure/start/restart/stop. | Token sessione generato in app state o `LOCAL_ASR_API_TOKEN`; CORS solo da `LOCAL_ASR_ALLOWED_ORIGINS`; cache in `.cache/` dev o `~/Library/Caches/ClosedRoom/` bundle. `managed_llm_idle_shutdown_seconds` governa la finestra idle dell'auto-mode posseduto; external/disabled non vengono mutati. | `UV_CACHE_DIR=.cache/uv uv run local-asr serve --reload`, `curl http://127.0.0.1:1237/health`, `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_cli.py' -v`, `test_server_resource_policy_contract.py`, test runtime service manager e bootstrap cookie via `/v1/session` prima delle API protette. | | App macOS menu bar | Offre avvio nativo, finestra WKWebView e accesso rapido da barra menu, possedendo anche il lifecycle dei sidecar runtime quando avviati dalla app. | `local-asr app`, `menubar.py`, `window.py`, `launchd.py`, `app_identity.py`, `build.sh`, `ClosedRoom.spec`; `build.sh` legge la versione da `pyproject.toml`, produce `dist/ClosedRoom-.app`, rimuove artefatti `.app` non versionati rimasti da build precedenti e passa nome file e display name versionati alla spec PyInstaller, mentre l'eseguibile interno resta `ClosedRoom`; `menubar.py` confronta l'identità esposta da `/health` e, se la porta standard è occupata da una build diversa, sceglie una porta locale libera invece di riusare il vecchio server; `launchd.py` installa l'auto-start puntando al binario dell'app versionata corrente quando è in bundle; `menubar.py` usa le costanti runtime condivise e chiama `RuntimeServiceManager.shutdown()` in uscita. | Risorse bundle risolte da `paths.py`; dati utente in Application Support; sidecar locali fermati allo shutdown dell'app quando la modalità è gestita; `/health` espone `app_version`, `bundle_identifier`, `bundle_display_name`, `bundled` e `pid` per distinguere processi vecchi e build correnti. | Build mirata con `./build.sh --no-dmg` quando cambiano bundle, risorse o helper nativo; `PYTHONPATH=src python -m py_compile src/local_asr_server/menubar.py src/local_asr_server/runtime/service_manager.py`. | -| Registrazione audio locale | Salva audio progressivamente senza avviare automaticamente Whisper e permette di recuperare sessioni interrotte da crash o tab chiusa. | Frontend recording UI, `RecordingStore`, endpoint `/v1/recordings`, `/chunks`, `/tracks/{track_id}/chunks`, `/stop`, `/recover`, `/discard`, `/audio`; client React con retry upload, bootstrap sessione ed avviso UI in caso di registrazione vuota. `/v1/recordings/active`, overlay e `/health` derivano lo stato dalla registrazione persistita e dai job, non da flag globali FastAPI. | `///metadata.json` con ledger chunk `sequence`/SHA-256/dimensione; file `.part` finche non finalizzati; stati `interrupted` e `recoverable`; metadati anche in `CatalogStore`. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_recordings.py' -v` e `test_recording_api.py`. | -| Cattura nativa macOS | Registra microfono e audio computer senza configurazione BlackHole quando macOS 13+, permessi macOS e helper nativo sono disponibili, rendendo evidente il fallback solo quando serve. | Helper Swift `native_capture_helper` con AVFoundation e ScreenCaptureKit, `NativeCaptureManager`, endpoint `/v1/capture/capabilities`, `/v1/capture/permissions`, `/v1/capture/request-permissions`, `/v1/capture/ensure-permissions`, `/v1/capture/diagnostics` e `/v1/recordings/{id}/capture/*` (incluso l'event-stream di volume in tempo reale), frontend capability check e pannello backend registrazione con CTA permessi mode-aware; il preflight di avvio richiede i permessi necessari alla modalità selezionata prima di creare la cattura e timer/overlay partono solo sull'evento `ready`. | Tracce WAV `mic.wav`, `system.wav` (16 kHz mono downsampled) e `recording.wav` (mixed via ffmpeg post-stop); `RecordingStore.finalize()` preserva i WAV scritti direttamente dal helper quando esistono placeholder `.part` vuoti; capability/permission/diagnostics separate per Screen Recording/Microfono; `capture_backend`, `capture_status`, `timeline.json` (allineato con offset), `quality_report.json`; `build.sh` crea `ClosedRoomNativeCapture.app` con bundle id `com.closedroom.nativecapture`, lo copia manualmente in `Contents/Helpers` dopo PyInstaller per preservare l'estensione `.app`, blocca packaging `__dot__app`, verifica diagnostics helper dopo firma, usa il runtime hook `build_assets/hooks/pyi_rth_mlx.py` e il hook PyInstaller `build_assets/hooks/hook-mlx.py` per mantenere `mlx/lib/` come unica collocazione canonica di `libmlx.dylib`/`libjaccl.dylib` e `mlx.metallib`, mentre `paths.py` risolve il nuovo bundle helper da `Contents/Helpers` dell'app con fallback legacy al binario nudo. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_native_capture.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_recordings.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_paths.py' -v`; build frontend quando cambia la schermata registrazione; `./build.sh --no-dmg` quando cambia helper/bundle. | +| Workspace macOS adattiva | Mantiene Today, Meeting e Projects dentro una sola gerarchia di prodotto coerente: Meeting resta figlio di Today, Projects è la seconda destinazione peer, New Meeting resta l'azione primaria persistente e Settings/tema/lingua/tour/demo/runtime rimangono utility. La stessa gerarchia resta utilizzabile su finestre wide, compact e narrow senza introdurre un secondo modello di navigazione. | `frontend/src/App.tsx` possiede una sola workspace shell; `frontend/src/workspace.css` possiede rail desktop, toolbar sticky compact/narrow, riduzione label, bounded overflow e reduced-motion. Le pagine esistenti continuano a possedere Today, Meeting, Projects, registrazione, ricerca e preparazione; `design/ux-contract.json` è il contratto UX della gerarchia/adattività. | Nessuna nuova persistenza, API o configurazione runtime. Tema, lingua e altre preferenze continuano a usare i proprietari esistenti; navigazione e responsive chrome sono stato UI derivato, non un nuovo store. | `test/test_frontend_workspace_coherence.py`, frontend lint/typecheck, browser FULL_MEDIA `coherent-macos-workspace` dentro `scripts/browser_meeting_ui_e2e.mjs`, più packaged-app build/lifecycle smoke quando selezionato dal preflight. | +| Registrazione audio locale | Salva audio progressivamente senza avviare automaticamente AI pesante e permette di recuperare sessioni interrotte. Nel normale New Meeting titolo/progetto sono opzionali e ClosedRoom prova mic + audio computer senza chiedere backend, device o diarizzazione; il contesto schermo non entra nel golden path e resta una disclosure secondaria esplicita, disattivata per default. Sorgenti/device audio compaiono solo come recovery quando l'automatico non è disponibile. | `NewRecordingPage`, `useRecorder`, `RecordingStore`, endpoint recording/chunk/stop/recovery. Il meter visibile limita il redraw a circa 12,5 Hz, gli aggiornamenti React dei livelli a 4 Hz, timer a 1 Hz e overlay a 2 Hz; il documento hidden salta il lavoro del meter senza cambiare capture/finalizzazione. Con backend nativo pronto, la disclosure `Contesto schermo` carica le finestre catturabili e passa al recorder una sola sorgente scelta esplicitamente. `/v1/recordings/active`, overlay e `/health` derivano lo stato dalla registrazione persistita, non da flag globali FastAPI. | `///metadata.json` con ledger chunk `sequence`/SHA-256/dimensione; file `.part` finche non finalizzati; stati `interrupted` e `recoverable`; metadati anche in `CatalogStore`. Senza selezione esplicita della sorgente visuale non vengono acquisiti frame. | `test_recordings.py`, `test_recording_api.py`, `test_frontend_new_meeting_simplicity.py`, `test_frontend_recording_efficiency.py`, `test_frontend_visual_on_demand.py` e frontend lint/typecheck. | +| Cattura nativa macOS | Registra microfono e audio computer senza configurazione BlackHole quando macOS 13+, permessi macOS e helper nativo sono disponibili, rendendo evidente il fallback solo quando serve. Può inoltre catturare, solo su opt-in, frame a bassa frequenza da una finestra/schermo scelti per un successivo arricchimento locale. | Helper Swift `native_capture_helper` con AVFoundation e ScreenCaptureKit, `NativeCaptureManager`, endpoint `/v1/capture/capabilities`, `/v1/capture/permissions`, `/v1/capture/request-permissions`, `/v1/capture/ensure-permissions`, `/v1/capture/diagnostics` e `/v1/recordings/{id}/capture/*` (incluso l'event-stream di volume in tempo reale); il New Meeting usa il backend nativo automaticamente quando pronto, mostra selezione sorgente/device audio solo nel recovery e separa la scelta opzionale del contesto schermo. Il preflight richiede i permessi necessari prima della cattura e timer/overlay partono solo sull'evento `ready`. | Tracce WAV `mic.wav`, `system.wav` (16 kHz mono downsampled) e `recording.wav` (mixed via ffmpeg post-stop); `RecordingStore.finalize()` preserva i WAV scritti direttamente dal helper quando esistono placeholder `.part` vuoti; capability/permission/diagnostics separate per Screen Recording/Microfono; `capture_backend`, `capture_status`, `timeline.json`, `quality_report.json`; JPEG visuali restano locali nella sessione; packaging helper invariato. | `test_native_capture.py`, `test_recordings.py`, `test_paths.py`, `test_frontend_visual_on_demand.py`; frontend checks quando cambia New Meeting; `./build.sh --no-dmg` quando cambia helper/bundle. | | Routing audio macOS | Cattura microfono e audio computer con ripristino dell'uscita originale. | `AudioRouter`, helper Swift/Core Audio, endpoint `/v1/system/audio/*`. | Stato routing in `.cache/audio-routing-state.json`; requisiti BlackHole/helper. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_audio_router.py' -v`, confrontando la baseline nota in `AGENTS.md`. | -| Trascrizione audio | Converte upload o registrazioni locali in testo con provider ASR locale MLX o, se esplicitamente selezionato, Speechmatics Batch cloud; la scelta resta local-first di default e vale anche per tracce mic/system registrate. | Endpoint `/v1/audio/transcriptions`, `/path`, `/v1/asr/providers`, `asr_provider.py` come catalogo/provider resolver e owner dei metadati pubblici ASR, `speechmatics_asr.py` per Batch API lazy, `asr_models.py` per runtime locali, `runtime/asr_worker.py`, `services/transcription_service.py`, `routers/transcriptions.py`, `transcriber.py`, `transcription_quality.py`, `TranscriptionStore`, frontend Transcription con selettore provider e opzioni Speechmatics e helper `transcriptionMetadata.ts` per renderizzare provider/modello da campi strutturati. | `settings.json` include `asr_provider`, `speechmatics_region`, `speechmatics_model`, `speechmatics_diarization`, timeout/poll interval e `speechmatics_api_key` write-only. `GET /v1/settings` espone solo `speechmatics_api_key_configured`. La cache SHA-256 include provider, backend, regione, modello provider e diarization; transcript JSON/SQLite salvano `asr_provider`, `backend`, `model`, `provider_options` e `source_tracks[].transcription_metadata` senza API key/header. Speechmatics risolve sempre il modello effettivo dalle opzioni provider/settings, non dal default locale Whisper, crea un job separato per ogni traccia transcrivibile e preserva eventuali speaker label provider come metadata/segment fields senza sostituire le label ClosedRoom `mic`/`system`. Nemotron/Whisper mantengono la pipeline locale precedente. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_caching.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_speechmatics_asr.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_recording_api.py' -v`; `cd frontend && npm run build`; evitare test rapido con modelli reali per non scaricare modelli grandi. | -| Job trascrizione | Esegue trascrizioni di registrazioni come job monitorabili e cancellabili senza dichiarare concluso il cancel prima dell'uscita effettiva del worker; il job usa il provider ASR selezionato, locale o cloud e riusa anche il risultato post-ASR quando input e impostazioni sono identici. Durante la finalizzazione distingue in linguaggio semplice verifica speaker, dinamiche audio e salvataggio; la fase visuale mostra lavoro completato/rimanente e tempo stimato, lasciando contatori e log tecnici espandibili. | `JobStore`, `TranscriptionJobManager`, `TranscriptionService`, `TranscriptionStore`, endpoint `/v1/recordings/{id}/transcription-jobs`, `/v1/jobs`, `/v1/jobs/{job_id}`, `/events`, `/cancel`; frontend Transcription polling, `utils/jobs.ts` come mapping centralizzato degli step e payload provider/opzioni. Il contratto `VisualProcessingProgress` distingue immagini v1 da task visuali v2 ed espone ETA e contatori di filtro/inferenza. | Snapshot job, `progress_detail` ed eventi persistenti nelle tabelle SQLite `jobs` e `job_events`; il transcript JSON salvato conserva diagnostica, attribuzione speaker e `recording_pipeline_cache_key`, derivata da risultati tracce, impostazioni diarizzazione/visuali e frame visuali. Un hit riusa la trascrizione salvata senza ripetere diarizzazione, merge, visual/audio intelligence o creare un duplicato. Al riavvio i job non terminali sono marcati `interrupted`; il cancel segue `cancelling` → `cancelled` quando il worker torna. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_caching.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_job_store.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_recording_api.py' -v`; `cd frontend && npm run build`; verifica manuale della checklist di finalizzazione e delle disclosure per dettagli visuali/log. | -| Diagnostica meeting e fallback | Rende visibile quando il transcript è valido ma un arricchimento è fallito o ha usato un backend sostitutivo, evitando successi verdi ingannevoli. | Contratto centrale `diagnostics.py`; report condiviso `meeting_diagnostics.py`; outcome ed eventi in `TranscriptionService`/`TranscriptionJobManager`; endpoint autenticato `/v1/meetings/{id}/diagnostics` consumato dal drawer; la pagina risultato Trascrizione mostra immediatamente cause e backend effettivi; CLI `local-asr inspect-meeting`, inoltrata anche dall'eseguibile PyInstaller. `macos_permissions.py` e `/v1/system/accessibility` impediscono l'avvio silenzioso dei global hotkey senza permesso e alimentano il warning Settings. | `stats.diagnostics` e `stats.outcome_status` nel transcript JSON/SQLite, payload degli eventi job e log ruotato `~/Library/Logs/ClosedRoom/closedroom.log`; registra backend richiesto/effettivo per traccia, fallback, causa, errore, contatori e durata. I log includono `recording_id`/`job_id` e redigono token e secret. Il permesso Accessibilità riguarda solo le scorciatoie globali. | `.venv/bin/python -m unittest discover -s test -p 'test_diagnostics.py' -v`; `test_frontend_diagnostics.py`; `test_macos_permissions.py`; `test_bundled_module_dispatch.py`; test diarizzazione/visual/audio intelligence e matrice negativa; `cd frontend && pnpm run build`; `./build.sh --no-dmg` e smoke `Contents/MacOS/ClosedRoom inspect-meeting … --json`. | +| Trascrizione audio | Converte upload o registrazioni locali in testo con provider ASR locale MLX o, se esplicitamente selezionato, Speechmatics Batch cloud; la scelta resta local-first di default e vale anche per tracce mic/system registrate. | Endpoint `/v1/audio/transcriptions`, `/path`, `/v1/asr/providers`, `asr_provider.py` come catalogo/provider resolver e owner dei metadati pubblici ASR, `speechmatics_asr.py` per Batch API lazy, `asr_models.py` per runtime locali, `runtime/asr_worker.py`, `services/transcription_service.py`, `routers/transcriptions.py`, `transcriber.py`, `transcription_quality.py`, `TranscriptionStore`, frontend Transcription con selettore provider e opzioni Speechmatics e helper `transcriptionMetadata.ts` per renderizzare provider/modello da campi strutturati. | `settings.json` include `asr_provider`, `speechmatics_region`, `speechmatics_model`, `speechmatics_diarization`, timeout/poll interval e `speechmatics_api_key` write-only. `GET /v1/settings` espone solo `speechmatics_api_key_configured`. La cache SHA-256 include provider, backend, regione, modello provider e diarization; transcript JSON/SQLite salvano `asr_provider`, `backend`, `model`, `provider_options` e `source_tracks[].transcription_metadata` senza API key/header. Speechmatics risolve sempre il modello effettivo dalle opzioni provider/settings, non dal default locale Whisper, crea un job separato per ogni traccia trascrivibile e preserva eventuali speaker label provider come metadata/segment fields senza sostituire le label ClosedRoom `mic`/`system`. Nemotron/Whisper mantengono la pipeline locale precedente. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_caching.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_speechmatics_asr.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_recording_api.py' -v`; `cd frontend && npm run build`; evitare test rapido con modelli reali per non scaricare modelli grandi. | +| Job trascrizione, analisi e preparazione Meeting | Esegue trascrizioni/analisi come job monitorabili e cancellabili e offre nel Meeting una sola azione durevole `Prepara note` che riusa risultati validi, rende il transcript leggibile appena pronto e riduce il default da quattro inferenze sovrapposte a una estrazione strutturata condivisa per brief, azioni, decisioni e rischi. | `JobStore`, `TranscriptionJobManager`, `AnalysisJobManager`, `MeetingPreparationManager`, `HeavyWorkloadArbiter`, `structured_notes.py`, `structured_notes_projection.py`, `structured_notes_evaluation.py` e `runtime/resource_policy.py`. Il parent `meeting_preparation` persiste composizione e link agli stessi child job esistenti e non introduce queue/worker pesanti. `meeting_default` crea un solo child `meeting_notes_shared` v2; `meeting_deep`, `analysis_types` espliciti e percorsi expert restano compatibili. Input lunghi usano chunk source-aware bounded + aggregazione; input oltre il budget falliscono esplicitamente invece di essere troncati. Le quattro viste legacy sono proiezioni read-only del run canonico. | Job, eventi e relazioni parent→child restano in SQLite. `analysis_runs` persiste solo l'esecuzione fisica canonica v2; le proiezioni `meeting_brief`, `action_items`, `decisions` e `risks_blockers` non creano job o righe DB sintetiche. La cache v2 include identità di segmenti, timing, speaker e testo; ogni output non vuoto richiede `source_refs` validi e i vecchi run v1 restano leggibili. La dedupe di preparazione continua a includere recording/source, opzioni ASR e identità template/analisi; cancel/restart/resume mantengono gli invarianti PRS-12. | `test_shared_analysis_pipeline.py`, `test_structured_notes.py`, `test_structured_notes_source_boundaries.py`, `test_meeting_preparation.py`, suite job/analysis/transcription, frontend lint/typecheck, browser FULL_MEDIA `meeting-preparation-recovery` e packaged-app smoke quando selezionati dal preflight. | +| Note Meeting verificabili e modificabili | Permette di verificare azioni e decisioni contro la registrazione, correggerle senza perdere l'output generato e mantenere le correzioni dopo reload/restart. Una rigenerazione crea una revisione: correzioni compatibili vengono riapplicate, mentre cambi o rimozioni diventano conflitti espliciti invece di essere rimappati in silenzio. | `structured_note_edits.py` assegna identità deterministica e fingerprint agli item e applica l'overlay; `structured_notes_projection.py` ricostruisce revisioni/proiezioni; `routers/analysis.py` espone PATCH/DELETE sugli item con 409 per fingerprint stale; `StructuredNotesEditor.tsx` rende azioni/decisioni editabili, fonti temporali e recovery dei conflitti nel Meeting. | `CatalogStore.analysis_runs.result_json` resta l'unico owner persistente: conserva `generated`, `user_edits`, `effective`, `revision` e `conflicts` senza nuova tabella o migrazione. Ogni nuovo edit conserva anche lo snapshot generato di base con `source_refs`; `item_missing` mantiene quindi evidenza verificabile finché l'utente non scarta esplicitamente la correzione. Un `user_edits: []` esplicito sulla nuova revisione impedisce di re-ereditare edit già scartati. I vecchi v2 vengono decorati read-time e i v1 restano invariati. | `test_structured_note_edits.py`, `test_structured_note_projection_edits.py`, `test_structured_note_catalog_persistence.py`, `test_structured_note_api.py`, `test_frontend_structured_notes_editor.py`, frontend lint/typecheck, browser FULL_MEDIA `meeting-note-edit-revision` e packaged-app smoke quando selezionati dal preflight. | +| Diagnostica meeting e fallback | Rende subito utilizzabile un Meeting salvato anche se diagnostica o servizi visuali sono lenti/non disponibili, e rende visibile quando un arricchimento è fallito o ha usato un backend sostitutivo senza trasformare il problema accessorio in un errore dell'intero Meeting. | `MeetingDetailPage` carica come core solo `/v1/meetings/{id}`. Il report condiviso `meeting_diagnostics.py` e l'endpoint autenticato `/v1/meetings/{id}/diagnostics` vengono richiesti solo quando l'utente apre Dettagli; il frame-list `/v1/recordings/{id}/visual-frames` e il dettaglio visual intelligence sono disclosure dell'area Analisi. Errori accessori hanno stato/retry locale, le risposte stale A→B vengono ignorate e i reload terminali sovrapposti vengono coalescati. Restano inoltre il contratto centrale `diagnostics.py`, outcome/eventi in `TranscriptionService`/`TranscriptionJobManager`, la pagina risultato Trascrizione, CLI `local-asr inspect-meeting`, `macos_permissions.py` e `/v1/system/accessibility`. | `stats.diagnostics` e `stats.outcome_status` nel transcript JSON/SQLite, payload degli eventi job e log ruotato `~/Library/Logs/ClosedRoom/closedroom.log`; registra backend richiesto/effettivo per traccia, fallback, causa, errore, contatori e durata. I log includono `recording_id`/`job_id` e redigono token e secret. I nuovi loading boundary non aggiungono persistenza, telemetry o chiamate AI. | `test_diagnostics.py`, `test_frontend_diagnostics.py`, `test_frontend_meeting_fast_open.py`, browser journey `saved-meeting-fast-open` con ready/partial/error/recovery screenshot + MP4, `test_macos_permissions.py`, `test_bundled_module_dispatch.py`, test diarizzazione/visual/audio intelligence; frontend lint/typecheck e packaged-app smoke quando selezionato dal preflight. | | Audio intelligence shadow | Arricchisce le trascrizioni di registrazioni con metriche locali leggere su canali, tempo parlato, pause, overlap, speech rate, energia e insight mock provvisori, senza chiamare LLM e senza generare clip audio persistenti; la UI mostra una card dedicata nel dettaglio registrazione e badge sui segmenti trascritti. | Modulo `audio_intelligence`, integrazione in `run_recording_transcription()`, endpoint read-only `/v1/recordings/{id}/intelligence`, client React `ApiClient.recordingIntelligence()` e pannello `AudioIntelligencePanel`; calcolo RMS a finestre con lettura streaming WAV o pipe `ffmpeg`. | `intelligence.json` compatto nella directory registrazione; summary in `stats.audio_intelligence`; segmenti arricchiti con `channel`, `pause_before`, `speech_rate_wpm`, `energy`, `overlap`; `analysis` resta riservato al futuro risultato LLM. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_audio_intelligence.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_recording_api.py' -v`; `cd frontend && npm run build`. | -| Diarizzazione speaker post-meeting | Separa gli interlocutori in cluster temporali senza richiedere a priori il numero di partecipanti. Nella fase Configura l'utente sceglie per ogni run `Disattivata`, FluidAudio locale o Speechmatics cloud solo diarizzazione, indipendentemente dal provider ASR e anche per file importati; su una trascrizione salvata può inoltre ricalcolare soltanto gli speaker senza rilanciare l'ASR. Nelle registrazioni a tracce separate il MIC resta un unico speaker noto e soltanto SYSTEM viene processato dal backend scelto. Tutti i cluster rilevati nell'audio restano nominabili anche quando non hanno testo Whisper sovrapposto. | `speaker_diarization.py` possiede FluidAudio, lease runtime, cluster grezzi, copertura transcript e assegnazione temporale; l'helper Swift usa il profilo Community-1 accurato (`stepRatio=0.1`, durata minima embedding `0`, zero-vote re-embedding). `transcription_diarization.py` orchestra diarizzazione iniziale e rerun track-aware, riusa `SpeechmaticsBatchASRProvider`, scarta il testo cloud e sostituisce solo cluster/label; `speaker_labels.py` unisce cluster grezzi e segmenti assegnati. `TranscriptionService` applica la scelta per-run alle registrazioni e `routers/transcriptions.py` ai file upload/path. Il payload usa `diarization_provider`; endpoint rerun `POST /v1/transcriptions/{id}/diarization-jobs`, `TranscriptionJobManager`/`JobStore`, `TranscriptionStore.replace_diarization()`, frontend `ConfigureStep.tsx`/`ResultsStep.tsx`. | La cache pipeline è versionata e include provider, regione e modello di diarizzazione. `speaker-diarization.json` conserva timeline e risultato più recente; transcript JSON/SQLite salvano `provider_speaker`, `speaker_label`, `stats.speaker_diarization`, `clusters_by_track`, `assigned_cluster_count` e `unassigned_clusters_by_track`. Il conteggio speaker deriva dalla timeline del backend, non dai soli segmenti ASR assegnati; ogni mapping espone `transcript_segment_count`. Il rerun rimuove mapping/nominativi precedenti perché gli ID cluster non sono stabili. Speechmatics richiede API key e conferma UI e può generare costi. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_transcription_diarization.py' -v`; `test_speaker_diarization.py`; `test_speaker_labels.py`; `test_caching.py`; `test_job_store.py`; `test_recording_api.py`; compilazione release SwiftPM; `cd frontend && pnpm run build`; `./build.sh --no-dmg`; niente chiamate Speechmatics reali nei test automatici. | -| Visual intelligence post-meeting | Raccoglie durante la registrazione frame JPEG temporizzati persistenti e, durante il transcription job post-meeting, usa Qwen3-VL per produrre evidenze strutturate senza riconoscimento facciale. La schermata di configurazione e l'overlay flottante indicano esplicitamente quale finestra o schermo ClosedRoom sta acquisendo, così la sorgente visiva resta verificabile per tutta la registrazione. I frame restano disponibili dopo successi, errori e retry e vengono rimossi soltanto con la cancellazione esplicita della registrazione. La policy stabile `v1` deduplica con dHash e associa cluster `provider_speaker`; la soglia configurabile `visual_frame_similarity_threshold` (default `12`, range `0..64`) tratta come riusabili anche i frame quasi duplicati, riducendo le chiamate Qwen senza contarli come inferenze indipendenti. L'evoluzione task-aware, tracciata in `docs/task-aware-visual-intelligence-plan.md`, separa task (speaker, stato meeting, contenuto condiviso), usa prompt dedicati e aggrega lo stato temporale. Supporta il tracciamento dettagliato tramite run.json, trace.jsonl e routing.jsonl scritti in visual-runs//, con un tab Visual Debug UI in React. La pipeline ottimizza la memoria riducendo le dimensioni dei frame in base al task e salvando preview compresse WEBP per il debugging, serializzando l'esecuzione tramite ModelRuntimeLeaseManager ed eseguendo ASR in un processo separato. | `visual_intelligence/` con contratti, firme, router, prompt, fusion, trace_store.py e pipeline run config; `PostMeetingVisualService` orchestratore. API `POST /v1/recordings/{id}/visual-frames`, `GET /v1/recordings/{id}/visual-frames`, `GET /v2/recordings/{id}/visual-debug`, `GET /v2/recordings/{id}/visual-runs/{gen_id}/previews/{file}`. Frontend React `RecordingPage.tsx`, `useRecorder.ts`, `RecordingOverlayPage.tsx`, `ResultsStep.tsx` e `VisualDebugPanel.tsx`; il nome della sorgente selezionata viene propagato all'overlay tramite il canale di stato della registrazione senza introdurre nuova persistenza. Runtime worker ASR `ASRProcessRunner` in `runtime/asr_worker.py` e CLI `local-asr transcribe`. `RecordingStore` possiede la retention dei frame e `ModelRuntimeLeaseManager` serializza il runtime. | JPEG e manifest restano in `.visual-staging/` nella directory della registrazione; gli artefatti di analisi vengono promossi a `visual-runs//` (run.json, routing.jsonl, trace.jsonl, observations.jsonl, result.json, metrics.json, previews/) con `current_visual_generation.json` nel root. Il cleanup TTL rimuove soltanto checkpoint e generazioni incomplete, non i frame. Settings `visual_frame_similarity_threshold` per la forza del filtro v1 e `visual_debug_previews_enabled` per abilitare le anteprime; l'etichetta mostrata nell'overlay è stato UI effimero e non viene salvata. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_visual_intelligence_service.py' -v`, `test_frontend_diagnostics.py`, `test_recording_api.py` e `cd frontend && npm run build`; verifica manuale che overlay compatto ed espanso mostrino la sorgente selezionata. | +| Diarizzazione speaker post-meeting | Separa gli interlocutori in cluster temporali senza richiedere a priori il numero di partecipanti. Nel workflow tecnico/import l'utente può ancora scegliere per-run `Disattivata`, FluidAudio locale o Speechmatics cloud e può ricalcolare soltanto gli speaker; il normale New Meeting non espone più un toggle diarizzazione. Nelle registrazioni a tracce separate il MIC resta un unico speaker noto e soltanto SYSTEM viene processato dal backend scelto. | `speaker_diarization.py` possiede FluidAudio, lease runtime, cluster grezzi, copertura transcript e assegnazione temporale; `transcription_diarization.py` orchestra diarizzazione iniziale/rerun track-aware; `speaker_labels.py` unisce cluster grezzi e segmenti. `TranscriptionService`, `routers/transcriptions.py`, `ConfigureStep.tsx`/`ResultsStep.tsx` mantengono l'override per i power workflow. | La cache pipeline è versionata e include provider, regione e modello di diarizzazione. `speaker-diarization.json` e transcript JSON/SQLite conservano timeline, cluster e mapping; il rerun rimuove mapping/nominativi precedenti perché gli ID cluster non sono stabili. Speechmatics richiede API key e può generare costi. | `test_transcription_diarization.py`, `test_speaker_diarization.py`, `test_speaker_labels.py`, `test_caching.py`, `test_job_store.py`, `test_recording_api.py`; compilazione SwiftPM e frontend checks. | +| Visual intelligence post-meeting | Offre contesto visivo locale solo quando l'utente lo sceglie: New Meeting mantiene l'audio come golden path e presenta `Contesto schermo` come disclosure secondaria off-by-default. Se viene selezionata una sorgente, ClosedRoom conserva frame a bassa frequenza senza avviare VLM durante la registrazione; dopo la trascrizione Meeting verifica la disponibilità dei frame soltanto entrando in Analisi ed espone `Analizza contesto schermo` solo se i frame esistono. | `visual_intelligence/`, `PostMeetingVisualService`, `routers/visual_jobs.py`, API visual-frame/v2 visual intelligence/debug, `frontend/src/api/visualJobs.ts` e consumer Meeting/Results. La normale apertura di Meeting non enumera i frame e non carica visual intelligence; l'azione esplicita crea un job persistito/cancellabile `visual_intelligence` tramite l'esistente `TranscriptionJobManager`/`HeavyWorkloadArbiter`, arricchisce la trascrizione esistente in place e forza per-run il routing task-aware `v2` senza mutare Settings. Il router esegue candidate detection/dedupe e poi applica un hard ceiling di 2048 work item con sampling deterministico sull'intera timeline; un errore del router in `v2` esplicito fallisce chiuso e non degrada al legacy non bounded. | JPEG e manifest restano in `.visual-staging/`; artefatti promossi in `visual-runs//` e `current_visual_generation.json`. La trascrizione mantiene lo stesso ID/path/created_at e riceve solo gli artefatti/statistiche visuali aggiornati; il `job_id` runtime non viene persistito come campo del transcript. Cleanup TTL rimuove checkpoint/generazioni incomplete, non i frame. Settings visuali restano compatibili in Advanced per i workflow tecnici/legacy. | `test_visual_intelligence_service.py`, `test_visual_intelligence_router.py`, `test_visual_on_demand.py`, `test_frontend_visual_on_demand.py`, `test_frontend_diagnostics.py`, `test_frontend_meeting_fast_open.py`, browser journey `saved-meeting-fast-open`, `test_recording_api.py`, frontend lint/typecheck e packaged-app smoke. | Scelta Qwen per-run: prima di trascrivere una registrazione salvata, la UI -permette di abilitare o disabilitare l'analisi immagini per quella singola -esecuzione. Il valore iniziale deriva da `visual_intelligence_enabled`, non -modifica le impostazioni persistenti e viene incluso nella cache della pipeline. -Quando è disabilitato, il job non entra nello step `visual_processing`, non -invia progress immagini e conserva i frame per un eventuale run futuro. -Durante l'elaborazione la UI espone inoltre `Interrompi elaborazione`: per le -registrazioni richiede il cancel del job persistente e la pipeline si arresta -al successivo confine sicuro, inclusa la fine dell'inferenza visuale corrente; -per gli upload interrompe direttamente lo stream HTTP. +tecnica permette ancora di abilitare o disabilitare l'analisi immagini per +quella singola esecuzione. Il valore iniziale deriva da +`visual_intelligence_enabled`, non modifica le impostazioni persistenti e viene +incluso nella cache della pipeline. Quando è disabilitato, il job non entra +nello step `visual_processing`, non invia progress immagini e conserva i frame +per un eventuale run futuro. Questo resta un power/compatibility workflow distinto +dall'azione visuale on-demand di Meeting. Durante l'elaborazione la UI espone +inoltre `Interrompi elaborazione`: per le registrazioni richiede il cancel del +job persistente e la pipeline si arresta al successivo confine sicuro, inclusa +la fine dell'inferenza visuale corrente; per gli upload interrompe direttamente +lo stream HTTP. Nota qualità P2: in modalità v2 `visual_intelligence/adapter.py` confronta le firme dei bordi possedute da `signatures.py`; quando rileva un unico nuovo @@ -131,115 +135,4 @@ registrate o avviate su un'altra porta, attendono il rilascio della socket e registrano atomicamente l'unica nuova ownership. Wrapper `uv`, worker ASR, helper nativi e processi estranei non vengono terminati. Server standard, reload e menu bar sono quindi mutuamente esclusivi. Verifica: -`test/test_port_manager.py`, `test/test_cli.py` e `test/test_paths.py`. - -Nota cleanup C0-C2: ogni run terminale sostituisce il set coerente di artefatti, -quindi un rerun v1 rende nuovamente indisponibile `/v2`; il run disabilitato -preserva l'ultima generazione. I payload Qwen v2 passano da contratti tipizzati -per task e gli errori restano diagnostica del candidato. Le transizioni share -start/stop delimitano sessioni separate; i keyframe esterni sono esposti in -`unassigned_share_keyframes`. - -Nota cleanup C3-C5: i test API condividono settings deterministiche e non -scaricano modelli durante la suite. Il resume v2 valida schema, task, prompt e -ID, ricostruisce la cadenza nell'ordine dei candidati e conserva staging e -checkpoint per un massimo di 24 ore. Gli artefatti terminali condividono un -`generation_id`, vengono promossi da staging e aggiornano metadata/catalogo per -ultimi; le API non espongono generazioni parziali. - -Nota cleanup C6-C8: processor legacy e task-aware possiedono la costruzione -delle osservazioni, mentre parsing/validazione e orchestrazione restano separati. -Nel frontend i contratti visuali hanno un modulo dedicato e il fetch v2 usa un -hook cancellabile, indipendente dal caricamento meeting. La suite visuale è -organizzata per contracts, router, temporal, fusion, persistence e service con -fixture condivise. -| Storico trascrizioni | Permette consultazione, eliminazione, merge e split delle trascrizioni, mostrando il conteggio stimato dei token per ogni elemento visualizzato o selezionato per l'analisi. | Endpoint `/v1/transcriptions`, `/merge`, `/{id}/split`, `TranscriptionStore`, `CatalogStore`, frontend Transcription history e storico/dettaglio. | File `transcript__.json/.txt`, flag `hidden` e `merged_into` nel catalogo. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_transcription_merge.py' -v`. | -| Meeting workspace | Riorganizza registrazioni, trascrizioni, diarizzazione, analisi e stato job attorno al meeting come unità operativa, senza introdurre una tabella Meeting parallela. Tutti gli ingressi alla trascrizione di una registrazione aprono lo stesso workflow guidato e al completamento tornano al dettaglio meeting canonico, identico a quello aperto da storico, ricerca e progetti. Nel meeting sono visibili stato/backend/conteggi della diarizzazione, nomi speaker modificabili e transcript segmentato per speaker e timestamp; i file importati senza registrazione mantengono invece un risultato transcript standalone. | Endpoint `/v1/meetings`, `/v1/meetings/{recording_id}`, helper `_build_meeting()`, UI React `DashboardPage` ("Oggi"), `MeetingDetailPage`, `RecordingPage`, `ProjectsPage`, `TranscriptionPage` e `ResultsStep`; `SpeakerDiarizationEditor` è il componente condiviso per visibilità diarizzazione e modifica nomi; `utils/transcriptionRoute.ts` possiede costruzione e parsing dei token route. `TranscriptionPage` risolve il risultato completato sul meeting quando esiste `recording_id`; la pagina detail del meeting riorganizza le informazioni principali in un sistema a tab (Trascrizione, Analisi AI, Speaker) che rende la trascrizione immediatamente visibile ed accessibile all'ingresso. | Recording, transcription, `analysis_runs` e `jobs` restano le fonti di verita; lo stato meeting è derivato (`recorded`, `transcribed`, `analyzing`, `ready`) e non persistito separatamente. I nomi speaker sono aggiornati tramite `PATCH /v1/transcriptions/{id}/speakers` e il transcript segmentato usa subito `speaker_name`, mapping salvato o cluster come fallback. L'identità persistita è `Transcription.id`; `saved_id` è accettato soltanto come alias del payload di completamento. | `cd frontend && npm run build`; TestClient su `/v1/meetings`; verifica manuale Oggi -> dettaglio meeting -> Trascrivi -> ritorno allo stesso dettaglio; modificare un nome speaker, salvare e verificare l'etichetta nei segmenti; controllare stato/backend/conteggi, fallback senza cluster e accesso ai tab. | -| Progetti | Raggruppa audio, trascrizioni e analisi per contesto di lavoro o meeting in un workspace master-detail con sidebar ottimizzata (descrizione a tooltip, glow ridotto, collassabile su desktop con avatar iniziali e tooltip) e mobile drawer overlay da sinistra, hero bar compatto con pulsante trigger hamburger, grid orizzontale di KPI clickabili, sezione situazione progetto integrata full-width con CTA "Domanda custom" e "Genera" incluse, azioni a tutta larghezza, decisioni e rischi affiancati, timeline collassabile e progressive disclosure. | `project_name` su registrazioni, `_build_projects()`, endpoint `/v1/projects` e `/v1/recordings/{id}/project`, frontend `ProjectsPage`, componenti workspace React (`ProjectSidebar`, pannelli insight, `TaskProcessingLoader`) e storage key frontend per precompilare il progetto quando si avvia un nuovo meeting dal progetto. `_build_projects()` espone anche `analysis_runs` e preferisce l'ultima run completata rispetto al campo legacy `transcription.analysis`; il sistema visuale premium condiviso vive in `frontend/src/index.css` e nei primitive UI React. | Campo `project_name` in metadata registrazione e tabella `recordings`; run analisi in `CatalogStore.analysis_runs`; la situazione progetto MVP è composta lato frontend da `latest_analysis`/`analysis_runs` già disponibili, mostra uno stato di generazione breve e non crea ancora un digest progetto persistente. | `cd frontend && npm run build`; verifica API `/v1/projects`; verifica UI sidebar progetto collassabile (desktop) e mobile drawer con backdrop (mobile), filtro range, CTA nuovo meeting con progetto precompilato, loader situazione progetto, microcopy "cosa aspettarti", digest locale e toggle timeline (Mostra tutti/meno). | -| Analisi AI | Trasforma trascrizioni, registrazioni o testo inline in sintesi, punti chiave e azioni tramite job persistenti osservabili e tipizzati per output meeting; il run permette di scegliere provider, modello e setup senza dipendere solo dai default globali. | UI React e API client usano `POST /v1/analysis-jobs`, `POST /v1/analysis-pipelines`, `GET /v1/analysis/templates`, `GET /v1/analysis/pipelines` e polling di `GET /v1/jobs/{job_id}`; `/v1/analysis` resta legacy/debug. `analysis_templates.py` è il registry backend; `AnalysisJobManager`, `AnalysisService`, `LLMService`, provider `mock`, Gemini e locali; `frontend/src/api/config.ts` centralizza cataloghi provider/modelli/preset, `AnalysisSetupModal` apre il setup per le pipeline da meeting e `AnalysisPage` passa gli stessi override. | Provider, `gemini_model`, API key e default locali in `settings.json`; `GET /v1/settings` non restituisce mai la chiave, ma `gemini_api_key_configured`. `AnalysisRequest` e `AnalysisPipelineRequest` accettano override per-run per Gemini e local LLM; `analysis_runs.model`, `analysis_runs.llm_options` e la cache analysis includono provider, modello, preset qualita, temperatura, reasoning, max token, JSON mode, backend/path locali e hash credenziale Gemini senza salvare secret. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_analysis_api.py' -v`; `cd frontend && npm run build`; TestClient pipeline mock. | -| Analisi locale tramite local-llm-server | Abilita l'analisi locale offline delle trascrizioni (Nemotron locale) e delle registrazioni audio (Voxtral locale con audio diretto) senza chiamate cloud; in modalita `auto` ClosedRoom avvia il sidecar al bisogno e ricarica automaticamente il runtime quando cambia la configurazione effettiva del modello. L'header offre inoltre un accesso diretto alla Web UI del runtime in una finestra separata. | Modulo `llm.py` (`NemotronLocalProvider`, `VoxtralLocalProvider`), `runtime/models.py` per host/porte default, `runtime/llm_sidecar.py` per processo, porta dinamica, readiness, confronto configurazione e log, `runtime/service_manager.py` per facciata runtime e override per-run, `services/analysis_service.py` per merge impostazioni/request, `ensure_llm_ready` e chiave cache, `CatalogStore.analysis_cache`, `schemas.py` (`AnalysisRequest`, `AnalysisPipelineRequest`, `SettingsRequest`), `routers/system.py` (`POST /v1/analysis`, `GET /v1/runtime/status`, `GET /v1/runtime/services`, `GET /v1/runtime/services/llm`, `POST /start`, `/stop`, `/restart`, `GET /logs`, `POST /v1/settings`), frontend `App.tsx`, `AnalysisSetupModal`, `AnalysisPage`, `SettingsPage` e `apiClient.ts`; il pulsante header legge l'URL effettivo dal runtime e, in modalità gestita, avvia il sidecar se necessario; `run.sh` segue il log sidecar nel terminale di sviluppo. | `llm_provider` sceglie l'adapter, `local_llm_model` il modello caricato; il client locale invia esplicitamente quel modello. Analisi testuali e audio riusano un risultato SQLite quando coincidono contenuto (SHA-256), prompt/domanda/task, provider/modello e impostazioni che possono modificare l'output; per Gemini viene registrato soltanto l'hash della credenziale. `local_llm_mode` (`auto`, `external`, `disabled`), `local_llm_url` come override external/dev, `local_llm_model_path`, `local_llm_backend`, `local_llm_mmproj_path`, `local_llm_ctx_size`, `local_llm_startup_timeout`, `local_llm_llama_server_bin` e `local_llm_model_paths` vivono nei default persistenti ma possono essere risolti per singolo run tramite payload analysis; il sidecar gestito si riavvia se cambia uno di questi valori ed e bindato a `127.0.0.1` con porta interna disponibile. | `PYTHONPATH=src python -m unittest discover -s test -p 'test_caching.py' -v`; `PYTHONPATH=src python -m unittest discover -s test -p 'test_analysis_api.py' -v`; `cd frontend && npm run build`; test runtime esistenti quando il wheel locale `local-llm-server` e coerente. | -| Aggiornamento di local-llm-server | Mantiene il runtime locale allineato all'ultimo tag semver stabile pubblicato, usando esclusivamente wheel precompilati e installando in modo riproducibile l'extra `vision` richiesto da Qwen. | `scripts/update_local_llm_server.py` interroga i tag GitHub, riusa il wheel esatto da `../local-llm-server/dist/` oppure lo scarica dagli asset della release con `gh`, preserva gli extra nel requisito `file://`, aggiorna `pyproject.toml` e rigenera `uv.lock`; non contiene fallback a build da sorgente. `LocalLLMSidecar.ensure_ready()` esegue il preflight di `mlx_vlm` per capability image. La dipendenza macOS `mlx==0.31.2` è fissata nella stessa fonte per evitare la regressione di ownership degli stream GPU osservata con `mlx 0.32.0` nel worker PyInstaller. | Wheel versionato nel `dist/` del repository collegato, `pyproject.toml` e `uv.lock`; in caso di errore del lock i due file di dipendenza vengono ripristinati. | `.venv/bin/python -m unittest discover -s test -p 'test_update_local_llm_server.py' -v`; `.venv/bin/python -m unittest discover -s test -p 'test_llm_sidecar.py' -v`; `python3 scripts/update_local_llm_server.py --check`; build bundle e inferenza visuale reale per ogni cambio del pin MLX. | -| Runtime visuale nel bundle macOS | Permette alla `.app` PyInstaller di avviare il sidecar Qwen e il server MLX-VLM come processi figli senza richiedere un Python esterno. | `bundled_module_dispatch.py` intercetta esclusivamente `-m local_llm_server` e `-m mlx_vlm.server` prima del lifecycle Cocoa; `ClosedRoom.spec` include moduli e dati dinamici; `build.sh` fissa Python 3.10, versione verificata con MLX, invece di scegliere implicitamente l'interprete più recente. | Runtime e librerie dentro `Contents/Frameworks`; modello riusato dal path utente configurato, inclusa la directory `.lmstudio`; log sidecar in Application Support/Logs. | `test_bundled_module_dispatch.py`; `./build.sh --no-dmg`; esecuzione dei due `--help` dal binario bundle; health e inferenza Qwen positiva dal bundle. | -| Impostazioni | Centralizza directory, default trascrizione, provider ASR, provider LLM, workflow meeting e opzioni locali/cloud avanzate senza esporre al frontend i secret già salvati. | Endpoint `/v1/settings`, `/v1/asr/providers`, `settings.py`, `env.py`, `schemas.py`, `asr_provider.py`, Settings UI, directory picker macOS. | `~/Library/Application Support/ClosedRoom/settings.json`, merge con `DEFAULT_SETTINGS`; `env.py` carica `.env` locale in dev senza sovrascrivere variabili già presenti e risolve secret env in modo case-insensitive; `gemini_api_key` e `speechmatics_api_key` sono write-only nella risposta API e gli stati sono esposti solo da `*_api_key_configured`; `asr_provider` default `local`, opzioni Speechmatics e `gemini_model` persistiti; `meeting_auto_analysis` abilita la pipeline asincrona post-trascrizione; scrittura atomica con temp file e `os.replace`. Speechmatics/Gemini sono opt-in cloud e possono inviare audio/testo a terze parti solo quando selezionati. | Testare lettura/salvataggio e regressioni su `RecordingStore.root`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_env.py' -v`; `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_analysis_api.py' -v`; `cd frontend && npm run build`. | -| Oggi e suggerimenti | Mostra un workspace operativo "Oggi" con gerarchia premium da private intelligence workspace: hero compatta con filtro periodo, microcopy di fiducia, statistiche, meeting del periodo a sinistra (con rinomina in-place ed alert "Da completare" integrati direttamente nelle card), digest a destra, più azioni aperte, decisioni e rischi in pannelli densi sotto la griglia. | Frontend `DashboardPage`, `MeetingCard` in `MeetingWorkspace.tsx`, componenti workspace React (`GuidanceCallout`, pannelli insight e disclosure avanzata), drawer `InsightDetailDialog`/`MeetingListDialog`, `TaskProcessingLoader`, helper `meetingInsights`, endpoint `/v1/meetings`, `/v1/projects`, `/v1/transcription/source-data` e API client React. Il sistema visuale condiviso vive in `frontend/src/index.css` e nei primitive `Button`, `Card`, `Badge`, `Tooltip`, `Dialog`, `Sheet` e workspace components, con surface levels, gradienti high-contrast in dark mode, bordi/shadow centralizzati, CTA gradient, motion entrance/stagger e loader narrativi. | Deriva da catalogo, registrazioni, trascrizioni, jobs e `analysis_runs`; azioni, decisioni, rischi e digest sono letti dagli ultimi output strutturati già salvati e non persistono contatori UI separati. Gli empty state nel periodo restano contenuti e mostrano CTA contestuali solo quando serve; il testo UI è centralizzato in `frontend/src/i18n/locales/`. | Verifica empty state, default Oggi senza meeting più vecchi, cambio range, dati recenti, rinomina titolo, visualizzazione alert integrati per trascrizione/analisi mancanti nelle card, rimozione vecchio widget sidebar; `cd frontend && npm run build`. | -| Tour e showcase | Dal menu More/Settings o dal banner demo avvia una modalità demo guidata e read-only che mostra Home, mock registrazione, Progetti, trascrizione e analisi già pieni con dati realistici, facendo spotlight animato sulle aree chiave della UI e spiegando valore prodotto invece di dettagli tecnici. Include passaggi ponte espliciti per rendere percepibili i cambi Registrazione → Oggi e Oggi → Progetti; il ponte verso Progetti evidenzia la nav e avanza quando l'utente clicca davvero il pulsante Progetti. Non avvia registrazioni reali, Whisper o LLM, non carica file e non salva dati; uscendo ripristina la pagina precedente. Supporta la localizzazione dinamica e l'allineamento completo dei testi tra Italiano ed Inglese per l'intera UI, inclusi mock data e drawer del tour. | UI React in `frontend/src/features/demo/` (`demoData.ts`) e `frontend/src/features/tour/` (`fixtures.ts`, `tourSteps.ts`, `TourRecordingMock`, viste mock e `TourOverlay` con progress, Back/Next/Skip e spotlight), avvio da `App.tsx`; le pagine principali ricevono `demoMode={demoMode || tourStep}` e la route `recording` in demo renderizza il mock frontend invece del recorder reale. `App.tsx` espone target `data-tour` sulla nav e fa avanzare lo step `today-to-projects` a `project-sidebar` sul click di Progetti. `TourOverlay` prova il posizionamento laterale accanto al target quando non c'è spazio utile sopra o sotto, evita il fallback basso del viewport per sidebar e pannelli larghi, e richiama lo scroll automatico una sola volta per step così lo scroll manuale resta libero; gli step alti del progetto usano `start`/`nearest` per non tagliare la hero. I testi e i passaggi ponte sono localizzati in `frontend/src/i18n/locales/` (`it.ts` e `en.ts`). | `fixtures.ts` è un adattatore sopra `demoData.ts`, quindi tour e demo persistente usano lo stesso dataset in memoria tipizzato come `Meeting[]`/`Project[]`, tradotto dinamicamente in base alla lingua e mai inviato alle API; durante il tour le pagine demo non chiamano gli endpoint di dominio, il mock registrazione non usa `useRecorder` e le azioni mutative restano disabilitate; la route hash precedente è mantenuta solo durante il tour e ripristinata alla chiusura. | `cd frontend && npm run build`; verifica manuale: More/Settings → seleziona lingua (IT/EN) → Tour guidato, spotlight Home → mock registrazione → ponte verso Oggi → insight Oggi → step "Apri Progetti" → clic sul pulsante Progetti → Progetti → Trascrizione → Analisi in lingua allineata, usare Indietro/Avanti/Esci e confermare popover alto accanto a sidebar/drawer, hero progetto non tagliata e scroll manuale non bloccato. | -| Modalità demo frontend | Permette di avviare una modalità demo persistente che mostra dati realistici (4 meeting, trascrizioni, decisioni, azioni e rischi) senza scrivere nel database backend locale e senza chiamare API reali mentre la demo è attiva. | Dataset `frontend/src/features/demo/demoData.ts`, adattatore tour `frontend/src/features/tour/fixtures.ts`, `App.tsx` con flag `demoMode` in `localStorage`, `DemoBanner` come unico indicatore visibile e azioni Start tour/Exit demo; `DashboardPage`, `ProjectsPage`, `MeetingDetailPage`, `TranscriptionPage` e `AnalysisPage` ricevono il flag demo effettivo. Gli endpoint legacy `/v1/system/mock-data` e `/v1/system/clear-mock-data` restano nel backend ma non sono usati dal flusso UI demo. | I dati demo restano in memoria nel frontend e vengono rigenerati dinamicamente da lingua/data corrente; il banner è l'unico indicatore demo fuori dal tour, header e nav non mostrano badge o exit duplicati, e l'uscita dalla demo aggiorna solo `localStorage`/stato React senza reload. | Cliccare "Esplora con dati demo" dall'empty state, verificare Home/Progetti/Dettaglio Meeting con dati fittizi senza richieste API dominio, avviare il tour dal banner, poi "Exit demo"; `cd frontend && npm run build`. | -| Miniatura di registrazione fluttuante (Overlay) | Permette di monitorare visivamente lo stato della registrazione (timer, dB segnale, chunk e MB) in primo piano su tutti i desktop virtuali, con supporto per Stage Manager, fullscreen spaces, drag handle nativo e angoli arrotondati. | NSPanel fluttuante in `window.py`, DragHandleView nativo in primo piano per il drag, rotte `/v1/system/window/overlay`, `/v1/system/window/overlay/resize`, `/v1/recordings/active`, `/v1/recordings/{id}/control/stop` e `/v1/recordings/{id}/overlay/events` in Python backend, `apiClient.ts`, `useRecorder.ts` e `RecordingOverlayPage.tsx`. | Posizione salvata in `settings.json`; trasparenza WKWebView (`drawsBackground = False`); corner radius (22px) con layer clipping nativo su contentView e webview; sincronizzazione robusta tramite SSE con fallback su `BroadcastChannel('closedroom-recording')`. | Avvio di `local-asr serve/app`, click su Start registrazione, verifica comparsa overlay fluttuante e dB meter attivo, interruzione tramite pulsante rosso dell'overlay con ack e arresto diretto backend, e drag-positioning persistente. | -| Trascrizione guidata da VAD e ri-trascrizione | Frammenta di default l'audio locale in finestre di parlato per evitare lavoro Whisper sul silenzio; mantiene inoltre Silero come post-filtro advisory dei segmenti full-track. VAD non è un gate: se non trova parlato, fallisce o produce un transcript vuoto, la trascrizione completa resta disponibile automaticamente. | `asr_provider.py` possiede i default `VAD_GUIDED_DEFAULT`/`VAD_POST_FILTER_DEFAULT`; `audio_intelligence/vad.py` implementa Silero ONNX; `transcriber.py` orchestra `_transcribe_vad_guided` e fallback; `transcription_quality.py`, `schemas.py`, `routers/transcriptions.py`, `frontend/src/api/config.ts` e `TranscriptionPage.tsx` mantengono coerenti contratto e toggle. | `vad_guided=true` e `vad_post_filter=true` per API e nuova UI. Finestre, scarti e cause di fallback sono registrati nei metadata; entrambe le opzioni partecipano alla cache ASR. L'utente può disattivare VAD-guided per singolo run. | `UV_CACHE_DIR=.cache/uv uv run python -m unittest discover -s test -p 'test_transcriber_vad.py' -v`; test unitari VAD/quality; `cd frontend && npm run build`. | - - -Copy transcript: la schermata risultato espone un'azione esplicita `Copia tutta -la trascrizione`, che copia sempre `transcription.text` completo -indipendentemente dal tab attivo e conferma l'esito tramite toast localizzato. - -Nota F5: il percorso shared-content `v2` persiste fonte, confidence e fallback -della ROI; normalizza slide, documenti, fogli, codice, browser, video e -dashboard e usa la prima classificazione per limitare gli heartbeat successivi. -I cambi ROI informativi restano sempre eleggibili. La ROI generica non è ancora -promossa oltre lo stato sperimentale senza fixture reali Meet/Zoom/Teams. - -Nota F6: `visual_intelligence.json` è il documento canonico task-aware; il nuovo -endpoint `GET /v2/recordings/{id}/visual-intelligence` lo espone con tipi React -dedicati senza cambiare la risposta `/v1`. I collegamenti con il transcript sono -derivati per overlap temporale, citano osservazione e segmenti sorgente e non -sovrascrivono nessuno dei due. - -Nota F7: il dettaglio meeting React mostra il documento v2 con una timeline -responsive di eventi osservati e share espandibili. Le attribuzioni distinguono -stato accettato, da verificare e astensione; loading, errore e dataset vuoto -hanno stati persistenti e localizzati IT/EN. Le soglie avanzate restano interne. - -Nota F8: il processing v2 è idempotente per candidato dopo crash tramite un -checkpoint con fingerprint; a fine job il checkpoint viene rimosso e i JPEG -restano disponibili con la registrazione. I -link transcript conservano solo riferimenti temporali/ID per evitare duplicazione -di testo sensibile. Lo smoke reale supporta `--routing-mode v2` e richiede gli -artefatti canonici; la promozione resta subordinata alla verifica `.app` e TCC. - -## Ownership applicativa dei workflow - -- `app_services.py` definisce `AppServices`, il registry tipizzato di store, - job manager, runtime e servizi applicativi creati da `server.py`. I router e - i servizi leggono il registry tramite `get_services()`; gli alias storici in - `app.state` restano temporaneamente sincronizzati per compatibilità con app - nativa e test che sostituiscono collaboratori a runtime. -- `routers/settings.py` possiede il contratto HTTP `/v1/settings`, mentre - `services/settings_service.py` applica patch parziali, valida directory e - provider e costruisce la risposta pubblica senza credenziali. Valida inoltre - provider LLM, modalità/preset/reasoning locali, task, pipeline e timeout; - `default_temperature` resta numerica end-to-end. `settings.py` resta la fonte - di verita per default e persistenza atomica. -- `services/transcription_service.py` possiede risoluzione provider, cache ASR - condivisa e workflow di registrazione multi-traccia fino a merge, audio - intelligence e persistenza. `routers/transcriptions.py` mantiene endpoint, - parsing HTTP e wrapper compatibili con i test esistenti. -- Gli endpoint di analisi e workspace vivono rispettivamente in - `routers/analysis.py` e `routers/workspace.py`; `server.py` resta l'unico - composition root che registra i router. Gli endpoint pubblici non sono - cambiati da questa riorganizzazione. -- Gli endpoint legacy `/v1/system/mock-data` e - `/v1/system/clear-mock-data`, incluse fixture, scritture su catalogo/file e - cleanup, sono isolati in `routers/demo.py`; il flusso demo React continua a - usare esclusivamente i dati frontend in memoria. -- `frontend/src/features/config/asrConfig.ts` e `llmConfig.ts` sono gli owner - delle conversioni tra settings API, stato dei form e payload ASR/LLM. - `SettingsPage`, `AnalysisPage`, `AnalysisSetupModal` e - `TranscriptionModelModal` condividono queste funzioni pure senza cambiare la - composizione visuale o il contratto HTTP. -- `runtime/models.py::resolve_local_llm_model_path()` è l'unica regola di - risoluzione dei path modello usata da CLI, runtime, job e cache: il path - specifico del modello prevale sul path globale legacy. - -Verifica minima del confine: `test_app_services.py`, -`test_settings_service.py`, `test_runtime_services.py`, `test_caching.py`, -`test_recording_api.py` e `test_analysis_api.py`. I test non invocano una -trascrizione reale e non scaricano modelli. Per i confini frontend eseguire -`cd frontend && npm run build`. - -## Template per nuove feature - -Quando aggiungi una feature, inserisci o aggiorna una riga con: - -| Feature | Valore business | Superficie tecnica | Persistenza e configurazione | Verifica minima | -| --- | --- | --- | --- | --- | -| Nome feature | Risultato utente o decisione business supportata. | Moduli, route, pagine, controller e client coinvolti. | File, database, settings, cache, path e owner dei dati. | Test automatizzati o controllo manuale minimo e ripetibile. | +`test/test_port_manager.py`, `test/test_cli.py` e `test/test_paths.py`. \ No newline at end of file diff --git a/docs/features/README.md b/docs/features/README.md index 8d6f666f..6ae8b8c8 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -3,3 +3,5 @@ `../features.md` remains ClosedRoom's canonical aggregate feature registry during the 0.8 baseline adoption. Do not duplicate the same behavior here. Create a bounded file in this directory only when a feature has enough durable behavior, failure semantics, persistence/configuration or verification detail that splitting it materially improves agent context and ownership. Link the new file once from the aggregate registry and keep one canonical owner for each fact. + +Feature documentation describes current durable behavior, not implementation progress. When a change alters behavior already described by `../features.md` or a bounded feature document, update that owner in the same change. Do not create a new document for a small feature when code, public contracts, tests and the existing registry already make the behavior sufficiently discoverable. diff --git a/docs/local-real-environment.md b/docs/local-real-environment.md new file mode 100644 index 00000000..06c9023c --- /dev/null +++ b/docs/local-real-environment.md @@ -0,0 +1,111 @@ +# LOCAL REAL_ENVIRONMENT runbook + +Use this path when ClosedRoom must be exercised on a representative Apple-Silicon Mac but Apple Developer Program distribution authority is unavailable. + +This evidence is real target-environment evidence for packaged WKWebView, TCC-backed audio, local MLX, resource behavior, PRS-16 contention and the PRS-9 audio-strategy benchmark. It does **not** establish Developer ID distribution signing, notarization, stapling, distribution Gatekeeper acceptance or final release readiness. + +## Preconditions + +Use the exact current `dev` candidate and keep the checkout clean: + +```bash +git checkout dev +git pull --ff-only +git status --short +``` + +`git status --short` must print nothing. + +Required local tools: + +```bash +uv --version +pnpm --version +ffmpeg -version +``` + +The test requires an Apple-Silicon Mac. During the first run macOS may request permissions for ClosedRoom and for the dedicated `closedroom-ax-helper` Accessibility client. Grant the requested permissions for: + +- Microphone; +- Screen & System Audio Recording / system-audio capture; +- Accessibility for `closedroom-ax-helper` when requested by the UI automation path. + +The Accessibility helper is compiled into a stable source-versioned path under `.cache/closedroom/macos-ax-helper/`. `AXIsProcessTrusted` applies to that helper process itself, not generically to Terminal. The helper requests the standard macOS Accessibility prompt; if the run is blocked, enable `closedroom-ax-helper` in System Settings > Privacy & Security > Accessibility and run the same command again. The stable cache path allows that grant to be reused across reruns of the same helper source. + +If macOS asks you to quit/reopen an app after granting a permission, do so and run the same command again. The suite prefers reusing the exact finalized local app for that commit so TCC identity remains stable across reruns. + +## Run + +From the repository root: + +```bash +python3 scripts/run_local_real_environment_suite.py +``` + +No Apple Developer ID, notary profile or signing environment variables are required. The local build path explicitly removes `CLOSEDROOM_SIGN_IDENTITY` and produces a finalized ad-hoc artifact. + +Use `--rebuild` only when you intentionally want a fresh local artifact: + +```bash +python3 scripts/run_local_real_environment_suite.py --rebuild +``` + +## What is automated + +The local suite executes the same physical evidence owners used by the release workflow: + +1. measured target-Mac evidence: + - packaged WKWebView/window/accessibility/focus/keyboard journey; + - native TCC `both` capture; + - persisted non-empty microphone and system-audio tracks; + - clean packaged-app lifecycle; + - real local MLX transcription completion; + - `HeavyWorkloadArbiter` activity; + - CPU/RSS/runtime scheduler observations; + - `pmset -g therm` thermal observation; + - PRS-9 dual-track vs mixed-track benchmark. +2. PRS-16 AI-busy contention evidence: + - managed local AI active before recording starts; + - visible `Preparing recording` while AI owns the safe boundary; + - no premature physical capture; + - waiting -> recording FULL_MEDIA transition; + - native microphone + system-audio persistence after the safe boundary. + +The Accessibility driver treats the helper signal `closedroom_window_missing` as a bounded transient condition because WKWebView/window transitions can briefly expose no AX window. It retries only that exact condition within the existing action timeout; permission failures, action failures, invalid bounds and other UI automation errors remain terminal. + +If that bounded retry is exhausted, the helper collects a privacy-safe diagnostic snapshot before failing. The snapshot compares raw AX window availability with WindowServer process-window counts plus running/active/hidden state. Only numeric/boolean process-window metadata is whitelisted into the aggregate: no window titles, UI labels, meeting text or transcript content is collected. This distinguishes an AX exposure gap from a genuinely hidden/closed window or missing process without weakening the failing gate. + +## Result + +A successful run ends with: + +```text +LOCAL REAL_ENVIRONMENT: PASS +distribution authority............. BLOCKED (Apple Developer membership) +release qualification.............. NOT ESTABLISHED +``` + +The command returns exit code `0` when the local physical evidence passes. Distribution authority remains a separately classified blocker and therefore does not turn valid local evidence into a false test failure. + +The aggregate JSON is written under: + +```text +dist/evidence/local-real-environment//local-real-environment-suite.json +``` + +The same directory contains the two detailed child reports and bounded media evidence. The aggregate never copies arbitrary child error payloads, transcript or meeting text. For an exhausted `closedroom_window_missing` failure only, it may include the explicitly whitelisted non-content `ui_failure_diagnostic` process/window fields described above. + +When a physical check fails the command returns non-zero and prints the failed check names. Rerun with `--keep-sandbox` only when diagnosing a failure. + +## What remains blocked without Apple Developer membership + +Even after `LOCAL REAL_ENVIRONMENT: PASS`, stable release qualification still lacks: + +- Developer ID Application distribution signing; +- secure timestamp evidence; +- app/DMG notarization; +- stapling; +- distribution Gatekeeper assessment; +- any materially required subjective VoiceOver/usability observation. + +Do not relabel LOCAL REAL_ENVIRONMENT evidence as full release evidence. diff --git a/docs/real-environment-release.md b/docs/real-environment-release.md new file mode 100644 index 00000000..e8deb425 --- /dev/null +++ b/docs/real-environment-release.md @@ -0,0 +1,79 @@ +# REAL_ENVIRONMENT release runbook + +Use this runbook only for the frozen ClosedRoom `dev -> main` release candidate on a representative Apple-Silicon Mac. Hosted CI is not a substitute for TCC, physical audio, packaged WKWebView or representative MLX evidence. + +## 1. Preconditions + +- Check out the exact candidate commit and keep the checkout clean. +- Install repository prerequisites (`uv`, `pnpm`, `ffmpeg`, Xcode command-line tools). +- Configure a valid **Developer ID Application** identity in `CLOSEDROOM_SIGN_IDENTITY`. +- Configure a `notarytool` keychain profile in `CLOSEDROOM_NOTARY_KEYCHAIN_PROFILE`. +- Allow the packaged ClosedRoom app the microphone, system-audio/screen-recording and accessibility permissions requested by macOS during the run. + +The runner never enables cloud fallback. Production evidence must remain local and bound to the exact artifact revision. + +## 2. Build the immutable production artifact + +```bash +python3 scripts/build_production_artifact.py +``` + +The command fails closed unless the app and DMG are Developer-ID signed, securely timestamped, notarized, stapled and accepted by Gatekeeper. Its final stdout contains the exact `app` path to use below. + +Example shape: + +```json +{ + "status": "pass", + "artifact_dir": "...", + "app": ".../ClosedRoom---.app", + "dmg": ".../ClosedRoom---.dmg" +} +``` + +## 3. Run all automated target-Mac evidence + +```bash +python3 scripts/run_real_environment_release_suite.py \ + --app "" +``` + +The suite executes the existing evidence owners against that same app: + +1. `measured_release_target_mac.py` + - packaged WKWebView/window/accessibility/focus/keyboard journey; + - native TCC `both` capture with non-empty microphone + system tracks; + - clean lifecycle; + - real local MLX transcription completion and `HeavyWorkloadArbiter` activity; + - bounded CPU/RSS/runtime scheduler observations and `pmset -g therm` evidence; + - PRS-9 `dual_track_vs_mixed_asr` benchmark on the representative recording. +2. `record_while_ai_busy_target_mac.py` + - managed local MLX active before Start; + - truthful `Preparing recording` state while MLX still owns the safe boundary; + - no premature capture; + - FULL_MEDIA waiting -> recording transition; + - native `both` mic/system persistence after the safe boundary. + +Default aggregate report: + +```text +dist/evidence/measured-release//real-environment-suite.json +``` + +Child reports and media stay under the same revision-scoped evidence directory. + +## 4. Interpret the result + +Successful automated evidence prints a summary ending with: + +```text +AUTOMATED REAL_ENVIRONMENT: PASS +``` + +A failure returns a non-zero exit code. The aggregate JSON lists the failed child runner/check names, child error counts and local child-evidence paths; detailed child error payloads remain only in their local reports. Transcript and meeting text are never copied into the aggregate. + +A PASS proves only the automated REAL_ENVIRONMENT portion for that exact source/artifact identity. Before stable promotion, still record any materially required subjective VoiceOver/usability observation and recheck candidate/base freshness plus the full `dev -> main` diff. + +## 5. Useful options + +Use `--output ` to choose the aggregate JSON path and `--keep-sandbox` only when diagnosing a failed run. Timing and benchmark repeat options are exposed by `--help`; release defaults should normally remain unchanged so evidence stays comparable. diff --git a/docs/workstreams/meeting-value-efficiency.md b/docs/workstreams/meeting-value-efficiency.md new file mode 100644 index 00000000..3fa74433 --- /dev/null +++ b/docs/workstreams/meeting-value-efficiency.md @@ -0,0 +1,114 @@ +# ClosedRoom: useful notes, simple journeys and efficient execution + +Status: active — PRS-11..17 integrated; PRS-18 measured release in progress +Owner: meeting product, canonical job/persistence owners and local runtime +Integration checkpoint: PRS-18 tooling merged on `dev` at `94fa3d3`, 2026-09-08; final release candidate not yet frozen. + +## Outcome and invariants + +Record, prepare useful notes, verify decisions and find them later while the Mac stays usable. No production performance, memory, audio-strategy or release-readiness claim is accepted without representative evidence. + +- Meeting is primary; normal recording requires no technical choice. +- `Prepare notes` is explicit after Stop; `Transcript only` is secondary. +- Reuse valid transcript and notes; ready notes open first unless the user selects another tab. +- Audio/transcript survive enrichment failure/cancel; local-first and explicit cloud opt-in remain unchanged. +- Canonical owners remain RecordingStore, JobStore, CatalogStore, HeavyWorkloadArbiter and runtime services. +- Excluded: rewrite, second scheduler/runtime/index owner, implicit cloud, mandatory visuals, unsafe kill, unproven audio strategy. +- Stable promotion is `dev -> main`, always RELEASE/FULL, and applicable REAL_ENVIRONMENT evidence is blocking. + +## Work graph + +| ID | Observable outcome | State | +| --- | --- | --- | +| PRS-11 | Fast saved Meeting open | DONE | +| PRS-12 | One recoverable Prepare notes action | DONE | +| PRS-13 | Consistent notes with less repeated inference | DONE | +| PRS-14 | Verify/edit actions and decisions | DONE | +| PRS-15 | Search complete local archive | DONE | +| PRS-16 | Record safely while AI is busy | DONE | +| PRS-17 | Coherent macOS workspace | DONE | +| PRS-18 | Measured production release | IN PROGRESS | + +## Integrated slices + +PRS-11..14 established independent saved-Meeting loading, one durable recoverable `meeting_preparation` parent, one structured default notes analysis and source-anchored editable actions/decisions with revision/conflict semantics. + +PRS-15 added bounded server-side FTS5 archive search inside canonical `closedroom.db`; PR #39 integrated source/frontend tests, `meeting-archive-search` FULL_MEDIA and packaged FTS5 smoke. + +PRS-16 kept `HeavyWorkloadArbiter` as the sole heavy-work scheduler and added one transient capture reservation. Active managed work finishes normally; queued work remains bounded and waits during capture; the frontend shows preparation until real capture starts. PR #41 integrated STRONG source/browser/package evidence. Physical audio/TCC and representative MLX/thermal behavior remained release-only. + +PRS-17 converged Today, saved Meeting and Projects into one adaptive macOS workspace without changing routing/data/runtime ownership. PR #43 integrated on `dev`; exact candidate `c1c79f31` passed FULL preflight #306 with guards, frontend checks, 411 Python tests, every declared Meeting browser FULL_MEDIA journey and packaged lifecycle validation. + +PRS-18 release tooling integrated through PR #48. Exact source head `9bfd7efc` passed INTEGRATION/FULL preflight #329; squash merge `94fa3d3` preserved the validated source tree. The integrated tooling owns production signing/notarization, measured target-Mac release evidence and the physical PRS-16 AI-busy contention confirmation. This proves tooling integration only: production authority and target-environment evidence remain pending and blocking for stable promotion. + +## PRS-18 — measured release + +### Outcome + +Promote only an exact production candidate that is fully validated automatically and on representative Apple-Silicon hardware. Final-environment evidence confirms the candidate; it must not discover basic deterministic regressions that belonged in integration. + +### Production artifact owner + +Canonical command: + +```bash +python3 scripts/build_production_artifact.py +``` + +Required properties: + +- clean Apple-Silicon checkout and full source revision identity; +- Developer ID Application signing with hardened runtime and secure timestamp; +- notarize the signed `.app` archive, staple/validate the `.app`, and pass Gatekeeper execution assessment; +- build the DMG from that stapled app, notarize/staple/validate the DMG and pass Gatekeeper open assessment; +- restore only generated frontend source output after packaging and fail if the checkout is otherwise dirty or moves; +- write production release evidence before immutable build manifest/checksums, with no post-finalization artifact mutation; +- missing signing identity, notary profile, tools or Apple acceptance fails closed. + +### Target-Mac evidence owner + +Canonical command: + +```bash +python3 scripts/measured_release_target_mac.py --app && \ +python3 scripts/record_while_ai_busy_target_mac.py --app +``` + +One exact production `.app` must prove: + +1. packaged WKWebView/window/accessibility-tree/keyboard-focus journey with FULL_MEDIA; +2. TCC-backed native `both` capture and non-empty persisted `mic` + `system` tracks; +3. clean application/runtime lifecycle; +4. a real local transcription job on the captured meeting, with `HeavyWorkloadArbiter` activity observed; +5. bounded privacy-safe CPU/RSS/runtime scheduler samples and a macOS thermal/performance observation (`pmset -g therm`); missing data stays `unknown`, never zero; +6. local MLX completion rather than a cloud fallback; +7. PRS-9 `dual_track_vs_mixed_asr` benchmark on that same representative recording, using its real schema/repeat count and retaining no transcript text; +8. the PRS-16 physical contention boundary: navigate to a ready New Meeting, start a real local MLX transcription on an existing recording, observe `HeavyWorkloadArbiter` active before pressing Start and still active while the packaged UI visibly waits in `Preparing recording`, retain ClosedRoom-window FULL_MEDIA for the truthful waiting → active recording transition, require absence of active capture while AI runs, then require real native `both` capture with non-empty mic/system tracks after that workload reaches its normal boundary. + +No numeric performance threshold is invented without a comparable baseline. The evidence runner records observations and completion truth; a later product/architecture change is required if benchmark evidence justifies changing the canonical dual-track strategy. Source/browser tests remain the primary proof for bounded queue ordering/cancellation; target-Mac contention evidence confirms the packaged WKWebView/TCC/physical-audio/MLX boundary instead of replacing those lower-level tests. + +### Human evidence + +VoiceOver spoken-output quality and subjective usability remain human judgement when materially required. Accessibility tree, focus and keyboard paths are automated and do not need to be reclassified as human work. + +### Promotion + +After tooling integration: + +1. finish durable-state closeout, freeze the exact `dev` candidate and live `main` base, and keep/open the canonical `dev -> main` release PR; +2. run selector-owned RELEASE/FULL automation on that exact candidate/base; +3. build the production artifact from that exact source; +4. run both measured target-Mac evidence runners against the exact notarized artifact; +5. record any genuinely required subjective VoiceOver observation; +6. recheck candidate/base freshness and full diff; +7. promote to `main` only if all blocking evidence matches the candidate. + +A candidate/base move, material source edit, build/signing mutation or artifact rebuild invalidates affected evidence. + +## Evidence policy + +INTEGRATION requires fresh `dev`, reviewed diff/current contracts, selector `auto` and affected deterministic/E2E gates. Missing deterministic automation is `AUTOMATION_CAPABILITY_GAP`, not user work. + +RELEASE requires FULL automation plus applicable target-Mac evidence. Hosted CI is never relabeled as physical TCC/audio/WKWebView/MLX proof. + +Durable owners: `.engineering/commands.json`, `.engineering/e2e.json`, `docs/current-state.md`, `design/ux-contract.json`, `docs/features.md`, `docs/architecture.md`, tests and this active workstream. Complete PRS-18 only when source, artifact, automation, target-Mac evidence, docs and stable branch state agree. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e50de95d..f3dbe4a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,23 @@ -import { useState, useEffect } from 'react'; -import { BarChart3, ChevronDown, ExternalLink, FolderKanban, Languages, Mic, Moon, Palette, PlayCircle, Settings, Sparkles, Sun } from 'lucide-react'; +import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useRef, useState } from 'react'; +import { + BarChart3, + ChevronDown, + FolderKanban, + Languages, + Mic, + Moon, + Palette, + PlayCircle, + Settings, + Sparkles, + Sun, +} from 'lucide-react'; import { I18nProvider, useTranslation } from './i18n/i18n'; import { ToastProvider, useToast } from './context/ToastContext'; import { ApiClient } from './api/apiClient'; import { HEALTH_CHECK_INTERVAL_MS } from './api/config'; import DashboardPage from './pages/DashboardPage'; +import NewRecordingPage from './pages/NewRecordingPage'; import RecordingPage from './pages/RecordingPage'; import TranscriptionPage from './pages/TranscriptionPage'; import ProjectsPage from './pages/ProjectsPage'; @@ -14,11 +27,11 @@ import RecordingOverlayPage from './pages/RecordingOverlayPage'; import MeetingDetailPage from './pages/MeetingDetailPage'; import { Badge } from './components/ui/Badge'; import { Button } from './components/ui/Button'; -import { Tooltip } from './components/ui/Tooltip'; import { DemoBanner } from './components/ui/DemoBanner'; import { TourOverlay } from './features/tour/TourOverlay'; import { TourRecordingMock } from './features/tour/TourRecordingMock'; import { TOUR_STEPS, TourStepId, tourStepIndex } from './features/tour/tourSteps'; +import './workspace.css'; function MainApp() { const { t, lang, setLang } = useTranslation(); @@ -29,18 +42,35 @@ function MainApp() { const [defaultModel, setDefaultModel] = useState(''); const [theme, setTheme] = useState<'dark' | 'light'>('dark'); const [moreOpen, setMoreOpen] = useState(false); - const [openingLocalLlmUi, setOpeningLocalLlmUi] = useState(false); const [routeDetail, setRouteDetail] = useState(null); const [tourStep, setTourStep] = useState(null); const [tourReturnHash, setTourReturnHash] = useState(''); + const moreTriggerRef = useRef(null); + const moreMenuRef = useRef(null); const [demoMode, setDemoMode] = useState(() => { - // Support ?demo=true URL param in addition to localStorage const urlParams = new URLSearchParams(window.location.search); return urlParams.get('demo') === 'true' || localStorage.getItem('demoMode') === 'true'; }); const isDemoActive = demoMode || Boolean(tourStep); - // Demo mode uses backend-populated mock database entries and client-side flags. + const navigateTo = (page: string, detail: string | null = null) => { + const pageRouteMap: Record = { + home: 'home', + recording: 'recording', + transcription: 'transcription', + projects: 'projects', + analysis: 'analysis', + settings: 'settings', + meeting: 'meeting', + }; + const route = pageRouteMap[page] || page; + window.location.hash = detail ? `${route}/${detail}` : route; + if (tourStep === 'today-to-projects' && route === 'projects') { + const nextStep = TOUR_STEPS[tourStepIndex(tourStep) + 1]; + if (nextStep?.id === 'project-sidebar') setTourStep(nextStep.id); + } + }; + const activateDemo = async () => { try { await ApiClient.populateMockData(lang); @@ -67,46 +97,12 @@ function MainApp() { } }; - const openLocalLlmUi = async () => { - const popup = window.open('', 'ClosedRoomLocalLlmUi'); - if (!popup) { - showToast(t('header.localLlmPopupBlocked'), 'warning'); - return; - } - - popup.opener = null; - setOpeningLocalLlmUi(true); - - try { - let service = await ApiClient.getLlmService(); - if (!service.url && service.mode === 'auto') { - service = await ApiClient.startLlmService(); - } - if (!service.url) { - throw new Error(service.error || t('header.localLlmUnavailable')); - } - - popup.location.replace(service.url); - popup.focus(); - } catch (err) { - popup.close(); - const message = err instanceof Error && err.message - ? err.message - : t('header.localLlmUnavailable'); - showToast(message, 'error'); - } finally { - setOpeningLocalLlmUi(false); - } - }; - - // Sync hash with activePage useEffect(() => { const handleHashChange = () => { const hash = window.location.hash.replace('#', ''); const parts = hash.split('/'); const pageName = parts[0]; const detail = parts.slice(1).join('/'); - const pageMap: Record = { home: 'home', record: 'recording', @@ -119,45 +115,17 @@ function MainApp() { overlay: 'overlay', meeting: 'meeting', }; - - const targetPage = pageMap[pageName] || 'home'; - setActivePage(targetPage); + setActivePage(pageMap[pageName] || 'home'); setRouteDetail(detail || null); + setMoreOpen(false); }; window.addEventListener('hashchange', handleHashChange); - // Initial check - if (!window.location.hash) { - window.location.hash = '#home'; - } else { - handleHashChange(); - } - - return () => { - window.removeEventListener('hashchange', handleHashChange); - }; + if (!window.location.hash) window.location.hash = '#home'; + else handleHashChange(); + return () => window.removeEventListener('hashchange', handleHashChange); }, []); - const navigateTo = (page: string, detail: string | null = null) => { - const pageRouteMap: Record = { - home: 'home', - recording: 'recording', - transcription: 'transcription', - projects: 'projects', - analysis: 'analysis', - settings: 'settings', - meeting: 'meeting', - }; - const route = pageRouteMap[page] || page; - window.location.hash = detail ? `${route}/${detail}` : route; - if (tourStep === 'today-to-projects' && route === 'projects') { - const nextStep = TOUR_STEPS[tourStepIndex(tourStep) + 1]; - if (nextStep?.id === 'project-sidebar') { - setTourStep(nextStep.id); - } - } - }; - const startTour = () => { setTourReturnHash(window.location.hash || '#home'); const firstStep = TOUR_STEPS[0]; @@ -188,7 +156,6 @@ function MainApp() { if (returnHash) window.location.hash = returnHash; }; - // Theme Sync useEffect(() => { const savedTheme = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', savedTheme); @@ -202,12 +169,9 @@ function MainApp() { setTheme(next); }; - // Server health polling useEffect(() => { const checkHealth = async () => { - if (isDemoActive) { - return; - } + if (isDemoActive) return; try { const data = await ApiClient.health(); setServerOnline(true); @@ -217,23 +181,57 @@ function MainApp() { } }; - checkHealth(); + void checkHealth(); const interval = setInterval(checkHealth, HEALTH_CHECK_INTERVAL_MS); return () => clearInterval(interval); }, [isDemoActive]); - // Close more panel on outside click useEffect(() => { - const handleClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - if (!target.closest('.more-menu-container')) { - setMoreOpen(false); - } + const handleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('.more-menu-container')) setMoreOpen(false); }; document.addEventListener('click', handleClick); return () => document.removeEventListener('click', handleClick); }, []); + useEffect(() => { + if (!moreOpen) return; + const frame = window.requestAnimationFrame(() => { + const selectedLanguage = moreMenuRef.current + ?.querySelector('[role="menuitemradio"][aria-checked="true"]'); + const firstItem = moreMenuRef.current + ?.querySelector('[role="menuitem"], [role="menuitemradio"]'); + (selectedLanguage || firstItem)?.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [moreOpen]); + + const handleMoreMenuKeyDown = (event: ReactKeyboardEvent) => { + const items = Array.from( + moreMenuRef.current?.querySelectorAll( + '[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)', + ) ?? [], + ); + if (!items.length) return; + + const currentIndex = Math.max(0, items.indexOf(document.activeElement as HTMLButtonElement)); + let nextIndex = currentIndex; + if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % items.length; + else if (event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + items.length) % items.length; + else if (event.key === 'Home') nextIndex = 0; + else if (event.key === 'End') nextIndex = items.length - 1; + else if (event.key === 'Escape') { + event.preventDefault(); + setMoreOpen(false); + moreTriggerRef.current?.focus(); + return; + } else return; + + event.preventDefault(); + items[nextIndex]?.focus(); + }; + const renderPage = () => { switch (activePage) { case 'home': @@ -247,10 +245,10 @@ function MainApp() { case 'meeting': return ; case 'recording': - if (isDemoActive) { - return ; - } - return ; + if (isDemoActive) return ; + return routeDetail + ? + : ; case 'transcription': return ; case 'projects': @@ -270,163 +268,154 @@ function MainApp() { } }; - if (activePage === 'overlay') { - return ; - } + if (activePage === 'overlay') return ; + + const todayActive = activePage === 'home' || activePage === 'meeting'; + const navItems = [ + { id: 'home', label: t('nav.home'), icon: BarChart3, active: todayActive }, + { id: 'projects', label: t('nav.projects'), icon: FolderKanban, active: activePage === 'projects' }, + ]; return (
-
- {/* Header */} -
- {/* Brand */} - - - {/* Navigation */} - +
+
- - {/* Demo mode banner — shown below header when demo is active */} - {isDemoActive && !tourStep && ( - { - startTour(); - showToast(t('tour.started'), 'info'); - }} - /> - )} + + +
+ {isDemoActive && !tourStep && ( + { + startTour(); + showToast(t('tour.started'), 'info'); + }} + /> + )} - {/* Main page content area */} -
- {renderPage()} -
+
+ {renderPage()} +
- {/* Footer */} -
- -
- {tourStep && } +
+ +
+
+ + {tourStep && ( + + )}
); } diff --git a/frontend/src/api/captureAdmission.ts b/frontend/src/api/captureAdmission.ts new file mode 100644 index 00000000..17178719 --- /dev/null +++ b/frontend/src/api/captureAdmission.ts @@ -0,0 +1,64 @@ +export interface CaptureReservation { + reservation_id: string; + status: 'waiting' | 'granted'; + active_workloads: number; + queued_workloads: number; + waited_seconds: number; +} + +class CaptureReservationRequestError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function reservationRequest( + url: string, + options: RequestInit = {}, + retrying = false, +): Promise { + const response = await fetch(url, { + ...options, + credentials: options.credentials ?? 'same-origin', + }); + if (response.ok) return response; + + if (response.status === 401 && !retrying) { + const session = await fetch('/v1/session', { credentials: 'same-origin' }); + if (session.ok) return reservationRequest(url, options, true); + } + + let detail = `HTTP ${response.status}`; + try { + const payload = await response.json(); + detail = payload.detail || detail; + } catch { + const text = await response.text(); + if (text) detail = text; + } + throw new CaptureReservationRequestError(response.status, detail); +} + +export async function createCaptureReservation(): Promise { + return (await reservationRequest('/v1/capture/reservations', { method: 'POST' })).json(); +} + +export async function getCaptureReservation(reservationId: string): Promise { + return (await reservationRequest(`/v1/capture/reservations/${reservationId}`)).json(); +} + +export async function releaseCaptureReservation(reservationId: string): Promise { + try { + await reservationRequest(`/v1/capture/reservations/${reservationId}`, { method: 'DELETE' }); + } catch (error) { + if (error instanceof CaptureReservationRequestError && error.status === 404) return; + throw error; + } +} + +export function releaseCaptureReservationOnUnload(reservationId: string): void { + navigator.sendBeacon?.(`/v1/capture/reservations/${reservationId}/release`, new Blob()); +} diff --git a/frontend/src/api/jobEvents.ts b/frontend/src/api/jobEvents.ts new file mode 100644 index 00000000..9aa9b713 --- /dev/null +++ b/frontend/src/api/jobEvents.ts @@ -0,0 +1,109 @@ +import { ApiClient, type TranscriptionJob } from './apiClient'; + +export const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'cancelled', 'interrupted']); +const RECOVERY_RECONNECT_DELAY_MS = 5000; + +export interface JobEventSnapshot extends TranscriptionJob { + sequence?: number; + event_id?: number; + message?: string | null; + event_payload?: Record | null; + event_created_at?: number; +} + +export interface JobEventFollowerOptions { + onUpdate: (job: JobEventSnapshot) => void; + onTerminal?: (job: JobEventSnapshot) => void; + onRecoveryError?: (error: unknown) => void; +} + +/** + * Follow one persisted job through the canonical SSE event stream. + * + * The stream is the normal path. A GET snapshot is used only after a stream + * failure so reconnect/recovery can reconcile terminal state without turning + * normal progress back into polling. + */ +export function followJobEvents(jobId: string, options: JobEventFollowerOptions): () => void { + let source: EventSource | null = null; + let reconnectTimer: number | null = null; + let stopped = false; + let lastSequence = 0; + + const closeSource = () => { + source?.close(); + source = null; + }; + + const clearReconnect = () => { + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + const stop = () => { + stopped = true; + clearReconnect(); + closeSource(); + }; + + const publish = (job: JobEventSnapshot) => { + if (stopped) return; + const sequence = Number(job.sequence || 0); + if (sequence > 0 && sequence <= lastSequence) return; + if (sequence > 0) lastSequence = sequence; + + const normalized: JobEventSnapshot = { + ...job, + progress_detail: job.event_payload ?? job.progress_detail, + }; + options.onUpdate(normalized); + if (TERMINAL_JOB_STATUSES.has(normalized.status)) { + options.onTerminal?.(normalized); + stop(); + } + }; + + const scheduleReconnect = () => { + if (stopped || reconnectTimer !== null) return; + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + connect(); + }, RECOVERY_RECONNECT_DELAY_MS); + }; + + const recover = async () => { + try { + const snapshot = await ApiClient.getJob(jobId); + if (stopped) return; + publish(snapshot); + if (!stopped) scheduleReconnect(); + } catch (error) { + if (stopped) return; + options.onRecoveryError?.(error); + scheduleReconnect(); + } + }; + + const connect = () => { + if (stopped) return; + closeSource(); + source = new EventSource(`/v1/jobs/${encodeURIComponent(jobId)}/events`); + source.onmessage = (message) => { + try { + publish(JSON.parse(message.data) as JobEventSnapshot); + } catch (error) { + options.onRecoveryError?.(error); + } + }; + source.onerror = () => { + if (stopped) return; + closeSource(); + void recover(); + }; + }; + + connect(); + return stop; +} diff --git a/frontend/src/api/meetingPreparation.ts b/frontend/src/api/meetingPreparation.ts new file mode 100644 index 00000000..cea6999b --- /dev/null +++ b/frontend/src/api/meetingPreparation.ts @@ -0,0 +1,41 @@ +import type { TranscriptionJob } from './apiClient'; + +async function requestPreparation( + path: string, + init: RequestInit = {}, +): Promise { + const response = await fetch(path, { + ...init, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + ...(init.headers || {}), + }, + }); + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`.trim(); + try { + const payload = await response.json(); + detail = String(payload?.detail || detail); + } catch { + // Keep the status text when the response is not JSON. + } + throw new Error(detail); + } + return response.json(); +} + +export function prepareMeetingNotes(recordingId: string): Promise { + return requestPreparation(`/v1/meetings/${recordingId}/prepare`, { + method: 'POST', + }); +} + +export function cancelMeetingPreparation( + recordingId: string, + jobId: string, +): Promise { + return requestPreparation(`/v1/meetings/${recordingId}/preparation-jobs/${jobId}/cancel`, { + method: 'POST', + }); +} diff --git a/frontend/src/api/meetingSearchApi.ts b/frontend/src/api/meetingSearchApi.ts new file mode 100644 index 00000000..22055069 --- /dev/null +++ b/frontend/src/api/meetingSearchApi.ts @@ -0,0 +1,53 @@ +import type { Meeting } from './apiClient'; + +export interface MeetingArchiveSearchResponse { + items: Meeting[]; + total: number; + page: number; + limit: number; + has_more: boolean; +} + +let sessionPromise: Promise | null = null; + +async function ensureSession(): Promise { + if (!sessionPromise) { + sessionPromise = fetch('/v1/session', { credentials: 'same-origin' }).then((response) => { + if (!response.ok) throw new Error(`Session bootstrap failed: HTTP ${response.status}`); + }); + } + return sessionPromise; +} + +async function requestArchive(url: string, signal?: AbortSignal, retrying = false): Promise { + await ensureSession(); + const response = await fetch(url, { credentials: 'same-origin', signal }); + if (response.ok) return response; + if (response.status === 401 && !retrying) { + sessionPromise = null; + return requestArchive(url, signal, true); + } + + let detail = `HTTP ${response.status}`; + try { + const payload = await response.json(); + detail = payload.detail || detail; + } catch { + // Preserve the HTTP status when the server did not return a JSON error payload. + } + throw new Error(detail); +} + +export async function searchMeetingArchive( + query: string, + page = 1, + limit = 25, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + q: query, + page: String(Math.max(1, page)), + limit: String(Math.max(1, Math.min(limit, 50))), + }); + return (await requestArchive(`/v1/meetings?${params.toString()}`, signal)).json(); +} diff --git a/frontend/src/api/structuredNotes.ts b/frontend/src/api/structuredNotes.ts new file mode 100644 index 00000000..0c553707 --- /dev/null +++ b/frontend/src/api/structuredNotes.ts @@ -0,0 +1,125 @@ +import type { AnalysisRun } from './apiClient'; + +export type StructuredNoteItemKind = 'action' | 'decision'; + +export interface StructuredNoteSourceRef { + segment_id: string | number; + start?: number | null; + end?: number | null; + speaker?: string | null; +} + +export interface StructuredNoteItem { + item_id: string; + generated_hash: string; + text: string; + source_refs: StructuredNoteSourceRef[]; + user_edited?: boolean; + owner?: string | null; + due?: string | null; + status?: string | null; + rationale?: string | null; + impact?: string | null; +} + +export interface StructuredNoteEdit { + item_kind: StructuredNoteItemKind; + item_id: string; + base_generated_hash: string; + base_run_id: string; + base_generated?: StructuredNoteItem | null; + fields: Record; + updated_at: number; +} + +export interface StructuredNoteConflict { + item_kind: StructuredNoteItemKind; + item_id: string; + reason: 'generated_changed' | 'item_missing' | string; + retained_edit: StructuredNoteEdit; + generated?: StructuredNoteItem | null; +} + +export interface StructuredNotesResult { + schema: { id: string; version: number }; + generated: { + summary?: { text?: string; source_refs?: StructuredNoteSourceRef[] }; + actions?: StructuredNoteItem[]; + decisions?: StructuredNoteItem[]; + risks?: StructuredNoteItem[]; + }; + effective?: { + summary?: { text?: string; source_refs?: StructuredNoteSourceRef[] }; + actions?: StructuredNoteItem[]; + decisions?: StructuredNoteItem[]; + risks?: StructuredNoteItem[]; + }; + revision?: { number?: number; run_id?: string; supersedes_run_id?: string | null }; + user_edits?: StructuredNoteEdit[]; + conflicts?: StructuredNoteConflict[]; + markdown?: string; +} + +export function isStructuredNotesResult(value: unknown): value is StructuredNotesResult { + if (!value || typeof value !== 'object') return false; + const result = value as StructuredNotesResult; + return result.schema?.id === 'closedroom.meeting_notes' + && result.schema?.version === 2 + && Boolean(result.generated); +} + +export function structuredSourceRunId(run: AnalysisRun): string { + const sourceRunId = (run as AnalysisRun & { source_run_id?: string }).source_run_id; + return sourceRunId || run.id.split('::', 1)[0]; +} + +async function requestStructuredRun(path: string, init: RequestInit): Promise { + const response = await fetch(path, { + ...init, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + ...(init.headers || {}), + }, + }); + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`.trim(); + try { + const payload = await response.json(); + detail = typeof payload?.detail === 'string' ? payload.detail : JSON.stringify(payload?.detail || detail); + } catch { + // Keep HTTP status text for non-JSON failures. + } + const error = new Error(detail) as Error & { status?: number }; + error.status = response.status; + throw error; + } + return response.json(); +} + +export function editStructuredNoteItem( + runId: string, + itemKind: StructuredNoteItemKind, + itemId: string, + payload: { base_generated_hash: string; fields: Record }, +): Promise { + return requestStructuredRun( + `/v1/analysis-runs/${encodeURIComponent(runId)}/items/${itemKind}/${encodeURIComponent(itemId)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }, + ); +} + +export function discardStructuredNoteEdit( + runId: string, + itemKind: StructuredNoteItemKind, + itemId: string, +): Promise { + return requestStructuredRun( + `/v1/analysis-runs/${encodeURIComponent(runId)}/items/${itemKind}/${encodeURIComponent(itemId)}/edit`, + { method: 'DELETE' }, + ); +} diff --git a/frontend/src/api/visualJobs.ts b/frontend/src/api/visualJobs.ts new file mode 100644 index 00000000..fc95e29b --- /dev/null +++ b/frontend/src/api/visualJobs.ts @@ -0,0 +1,38 @@ +import type { TranscriptionJob } from './apiClient'; + +async function requestVisualJob( + path: string, + init: RequestInit = {}, +): Promise { + const response = await fetch(path, { + ...init, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + ...(init.headers || {}), + }, + }); + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`.trim(); + try { + const payload = await response.json(); + detail = String(payload?.detail || detail); + } catch { + // Keep the status text when the response is not JSON. + } + throw new Error(detail); + } + return response.json(); +} + +export function createVisualIntelligenceJob(recordingId: string): Promise { + return requestVisualJob(`/v1/recordings/${recordingId}/visual-intelligence-jobs`, { + method: 'POST', + }); +} + +export function cancelVisualIntelligenceJob(jobId: string): Promise { + return requestVisualJob(`/v1/visual-intelligence-jobs/${jobId}/cancel`, { + method: 'POST', + }); +} diff --git a/frontend/src/components/meeting/StructuredNotesEditor.tsx b/frontend/src/components/meeting/StructuredNotesEditor.tsx new file mode 100644 index 00000000..15381305 --- /dev/null +++ b/frontend/src/components/meeting/StructuredNotesEditor.tsx @@ -0,0 +1,495 @@ +import { useMemo, useState } from 'react'; +import { AlertTriangle, Check, Clock3, Pencil, RotateCcw, Save, X } from 'lucide-react'; + +import type { AnalysisRun } from '../../api/apiClient'; +import { + discardStructuredNoteEdit, + editStructuredNoteItem, + isStructuredNotesResult, + structuredSourceRunId, + type StructuredNoteConflict, + type StructuredNoteItem, + type StructuredNoteItemKind, + type StructuredNoteSourceRef, +} from '../../api/structuredNotes'; +import { Badge } from '../ui/Badge'; +import { Button } from '../ui/Button'; + +interface StructuredNotesEditorProps { + run: AnalysisRun; + analysisType: string; + lang: string; + onSeek: (seconds: number) => void; + onChanged: () => Promise | void; + readOnly?: boolean; +} + +interface EditingState { + kind: StructuredNoteItemKind; + itemId: string; + fields: Record; +} + +const fieldLabels: Record> = { + action: { + text: { it: 'Azione', en: 'Action' }, + owner: { it: 'Responsabile', en: 'Owner' }, + due: { it: 'Scadenza', en: 'Due' }, + status: { it: 'Stato', en: 'Status' }, + }, + decision: { + text: { it: 'Decisione', en: 'Decision' }, + rationale: { it: 'Motivazione', en: 'Rationale' }, + impact: { it: 'Impatto', en: 'Impact' }, + }, +}; + +function formatTimestamp(seconds: number): string { + const rounded = Math.max(0, Math.floor(seconds)); + const minutes = Math.floor(rounded / 60); + const secs = rounded % 60; + return `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; +} + +function EvidenceRefs({ refs, onSeek }: { refs?: StructuredNoteSourceRef[]; onSeek: (seconds: number) => void }) { + if (!refs?.length) return null; + return ( +
+ {refs.map((ref, index) => { + const label = typeof ref.start === 'number' + ? `${formatTimestamp(ref.start)}${ref.speaker ? ` · ${ref.speaker}` : ''}` + : `S${ref.segment_id}`; + if (typeof ref.start !== 'number') { + return ( + + + ); + } + return ( + + ); + })} +
+ ); +} + +function itemFields(kind: StructuredNoteItemKind, item: StructuredNoteItem): Record { + if (kind === 'action') { + return { + text: item.text || '', + owner: item.owner || '', + due: item.due || '', + status: item.status || '', + }; + } + return { + text: item.text || '', + rationale: item.rationale || '', + impact: item.impact || '', + }; +} + +function conflictForItem(conflicts: StructuredNoteConflict[], kind: StructuredNoteItemKind, itemId: string) { + return conflicts.find((conflict) => conflict.item_kind === kind && conflict.item_id === itemId); +} + +export function StructuredNotesEditor({ + run, + analysisType, + lang, + onSeek, + onChanged, + readOnly = false, +}: StructuredNotesEditorProps) { + const result = isStructuredNotesResult(run.result) ? run.result : null; + const [editing, setEditing] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [error, setError] = useState(null); + const sourceRunId = structuredSourceRunId(run); + + const generated = result?.generated || {}; + const effective = result?.effective || generated; + const conflicts = result?.conflicts || []; + const generatedById = useMemo(() => { + const index = new Map(); + for (const item of [...(generated.actions || []), ...(generated.decisions || [])]) { + if (item?.item_id) index.set(item.item_id, item); + } + return index; + }, [generated.actions, generated.decisions]); + + if (!result) return null; + + const startEdit = (kind: StructuredNoteItemKind, item: StructuredNoteItem) => { + setError(null); + setEditing({ kind, itemId: item.item_id, fields: itemFields(kind, item) }); + }; + + const saveEdit = async () => { + if (!editing) return; + const sourceItem = generatedById.get(editing.itemId); + if (!sourceItem?.generated_hash) { + setError(lang === 'it' ? 'La nota è cambiata. Ricarica il meeting.' : 'The note changed. Reload the meeting.'); + return; + } + const key = `${editing.kind}:${editing.itemId}:save`; + setBusyKey(key); + setError(null); + try { + await editStructuredNoteItem(sourceRunId, editing.kind, editing.itemId, { + base_generated_hash: sourceItem.generated_hash, + fields: Object.fromEntries(Object.entries(editing.fields).map(([field, value]) => [field, value || null])), + }); + setEditing(null); + await onChanged(); + } catch (err: any) { + setError(err?.status === 409 + ? (lang === 'it' ? 'La nota è stata rigenerata. Ricarica e scegli quale versione mantenere.' : 'The note was regenerated. Reload and choose which version to keep.') + : (err?.message || (lang === 'it' ? 'Impossibile salvare la modifica.' : 'Could not save the edit.'))); + if (err?.status === 409) await onChanged(); + } finally { + setBusyKey(null); + } + }; + + const resolveConflict = async (conflict: StructuredNoteConflict, keepEdit: boolean) => { + const key = `${conflict.item_kind}:${conflict.item_id}:${keepEdit ? 'keep' : 'discard'}`; + setBusyKey(key); + setError(null); + try { + if (keepEdit && conflict.generated?.generated_hash) { + await editStructuredNoteItem(sourceRunId, conflict.item_kind, conflict.item_id, { + base_generated_hash: conflict.generated.generated_hash, + fields: conflict.retained_edit.fields, + }); + } else { + await discardStructuredNoteEdit(sourceRunId, conflict.item_kind, conflict.item_id); + } + await onChanged(); + } catch (err: any) { + setError(err?.message || (lang === 'it' ? 'Impossibile risolvere il conflitto.' : 'Could not resolve the conflict.')); + if (err?.status === 409) await onChanged(); + } finally { + setBusyKey(null); + } + }; + + const renderItem = (kind: StructuredNoteItemKind, item: StructuredNoteItem) => { + const sourceItem = generatedById.get(item.item_id) || item; + const conflict = conflictForItem(conflicts, kind, item.item_id); + const isEditing = editing?.kind === kind && editing.itemId === item.item_id; + const labels = fieldLabels[kind]; + const editableFields = kind === 'action' + ? ['text', 'owner', 'due', 'status'] + : ['text', 'rationale', 'impact']; + + return ( +
+ {conflict && ( +
+
+
+
+ )} + + {isEditing ? ( +
+ {editableFields.map((field) => ( +