diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 7266368..76d7be0 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -74,6 +74,15 @@ jobs:
build test \
CODE_SIGNING_ALLOWED=NO
+ - name: Build + test meeting-notes macOS
+ working-directory: apps/meeting-notes/macos-swift
+ run: |
+ xcodebuild \
+ -scheme MeetingNotesMac \
+ -destination 'platform=macOS' \
+ build test \
+ CODE_SIGNING_ALLOWED=NO
+
native-windows:
runs-on: windows-latest
steps:
@@ -100,6 +109,12 @@ jobs:
dotnet restore DocApproval.Tests/DocApproval.Tests.csproj
dotnet test DocApproval.Tests/DocApproval.Tests.csproj -c Release --nologo
+ - name: Restore + test meeting-notes WinUI logic
+ working-directory: apps/meeting-notes/windows-winui
+ run: |
+ dotnet restore MeetingNotes.Tests/MeetingNotes.Tests.csproj
+ dotnet test MeetingNotes.Tests/MeetingNotes.Tests.csproj -c Release --nologo
+
# Required: GTK shells (AdwApplicationWindowExt / GString fixes landed).
native-linux-gtk:
runs-on: ubuntu-latest
@@ -168,3 +183,9 @@ jobs:
env:
TRAVERSE_REPO: ${{ github.workspace }}/.traverse-checkout
run: ./gradlew --no-daemon testDebugUnitTest
+
+ - name: Gradle test meeting-notes
+ working-directory: apps/meeting-notes/android-compose
+ env:
+ TRAVERSE_REPO: ${{ github.workspace }}/.traverse-checkout
+ run: ./gradlew --no-daemon testDebugUnitTest
diff --git a/AGENTS.md b/AGENTS.md
index 1f36ad6..348bd4f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,7 +21,8 @@ gh project item-list 2 --owner traverse-framework --format json --limit 300 \
| `phase2-sidecar-nightly` | Future | Defer — low demo value |
| `embed-trace-explorer` | **Done** (#225) | Embedded Trace API in Trace Explorer web |
| `registry-ref-starter-process` | **Done** (#224) | Process component uses `registry_ref` |
-| `registry-ref-full-kit-cutover` | **In Progress** | All six primary components use `registry_ref` |
+| `registry-ref-full-kit-cutover` | **Done** (#235) | All six primary components use `registry_ref` |
+| `meeting-notes-wave2-os-ports` | **In Progress** | iOS + macOS + Windows + Android meeting-notes embeds |
| `consume-product-wasm-agents` | **Done** (#227) | Digest-pinned Traverse-published starter agents |
Full gap table + wave notes: [`docs/production-reference-plan.md`](docs/production-reference-plan.md).
diff --git a/README.md b/README.md
index 03f849c..b366a97 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ UI examples for [Traverse](https://github.com/traverse-framework/Traverse).
|---|---|---|
| **traverse-starter** | Submit a short note → title, tags, note type, next action, status | [web](apps/traverse-starter/web-react/) · [all OS](#by-os--target) |
| **doc-approval** | Paste a document → type, parties, amounts, confidence, recommendation | [web](apps/doc-approval/web-react/) |
-| **meeting-notes** | Paste a transcript → action items, decisions, follow-ups, summary | [web](apps/meeting-notes/web-react/) |
+| **meeting-notes** | Paste a transcript → action items, decisions, follow-ups, summary | [web](apps/meeting-notes/web-react/) · [all OS](#by-os--target) |
| **trace-explorer** | Browse execution traces (debugger — not a product shell to copy) | [web](apps/trace-explorer/web-react/) |
**Extra demos / kits** — useful samples, **not** the production pattern to copy (prefer traverse-starter / doc-approval / meeting-notes):
@@ -54,7 +54,7 @@ Same apps on other platforms (`—` = not shipped yet):
|---|---|---|---|---|---|---|---|
| traverse-starter | [link](apps/traverse-starter/web-react/) | [link](apps/traverse-starter/macos-swift/) | [link](apps/traverse-starter/ios-swift/) | [link](apps/traverse-starter/android-compose/) | [link](apps/traverse-starter/windows-winui/) | [link](apps/traverse-starter/linux-gtk/) | [link](apps/traverse-starter/cli-rust/) |
| doc-approval | [link](apps/doc-approval/web-react/) | [link](apps/doc-approval/macos-swift/) | [link](apps/doc-approval/ios-swift/) | [link](apps/doc-approval/android-compose/) | [link](apps/doc-approval/windows-winui/) | [link](apps/doc-approval/linux-gtk/) | [link](apps/doc-approval/cli-rust/) |
-| meeting-notes | [link](apps/meeting-notes/web-react/) | — | — | — | — | [link](apps/meeting-notes/linux-gtk/) | [link](apps/meeting-notes/cli-rust/) |
+| meeting-notes | [link](apps/meeting-notes/web-react/) | [link](apps/meeting-notes/macos-swift/) | [link](apps/meeting-notes/ios-swift/) | [link](apps/meeting-notes/android-compose/) | [link](apps/meeting-notes/windows-winui/) | [link](apps/meeting-notes/linux-gtk/) | [link](apps/meeting-notes/cli-rust/) |
---
diff --git a/apps/meeting-notes/MeetingNotesCore/.gitignore b/apps/meeting-notes/MeetingNotesCore/.gitignore
new file mode 100644
index 0000000..0ca9c38
--- /dev/null
+++ b/apps/meeting-notes/MeetingNotesCore/.gitignore
@@ -0,0 +1,3 @@
+.build/
+.swiftpm/
+Package.resolved
diff --git a/apps/meeting-notes/MeetingNotesCore/Package.swift b/apps/meeting-notes/MeetingNotesCore/Package.swift
new file mode 100644
index 0000000..2165741
--- /dev/null
+++ b/apps/meeting-notes/MeetingNotesCore/Package.swift
@@ -0,0 +1,28 @@
+// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "MeetingNotesCore",
+ platforms: [
+ .iOS(.v17),
+ .macOS(.v14),
+ ],
+ products: [
+ .library(name: "MeetingNotesCore", targets: ["MeetingNotesCore"]),
+ ],
+ dependencies: [
+ .package(path: "../../../vendor/traverse-embedder-swift"),
+ ],
+ targets: [
+ .target(
+ name: "MeetingNotesCore",
+ dependencies: [
+ .product(name: "TraverseEmbedder", package: "traverse-embedder-swift"),
+ ]
+ ),
+ .testTarget(
+ name: "MeetingNotesCoreTests",
+ dependencies: ["MeetingNotesCore"]
+ ),
+ ]
+)
diff --git a/apps/meeting-notes/MeetingNotesCore/README.md b/apps/meeting-notes/MeetingNotesCore/README.md
new file mode 100644
index 0000000..51d8a96
--- /dev/null
+++ b/apps/meeting-notes/MeetingNotesCore/README.md
@@ -0,0 +1,11 @@
+# MeetingNotesCore
+
+Shared Swift package for meeting-notes iOS and macOS shells.
+
+- `EmbeddedHost` — `RuntimeTraverseEmbedder` / `InMemoryTraverseEmbedder` boundary
+- `AppStateViewModel` — Zone 1 Ready/Unavailable + submit/reset
+- `MeetingNotesOutput` — runtime-owned field decoding only
+
+```bash
+cd apps/meeting-notes/MeetingNotesCore && swift test
+```
diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift
new file mode 100644
index 0000000..f3cccdb
--- /dev/null
+++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/AppStateViewModel.swift
@@ -0,0 +1,119 @@
+import Combine
+import Foundation
+
+public enum RuntimeStatus: Equatable, Sendable {
+ case starting
+ case ready
+ case unavailable
+}
+
+/// Drives UI state from an embedded Traverse host.
+/// Contains no local business-field computation.
+@MainActor
+public final class AppStateViewModel: ObservableObject {
+ @Published public private(set) var currentState: String = "idle"
+ @Published public private(set) var output: MeetingNotesOutput?
+ @Published public private(set) var errorMessage: String?
+ @Published public private(set) var sessionId: String?
+ @Published public private(set) var trace: [TraceEvent] = []
+ @Published public private(set) var runtimeStatus: RuntimeStatus = .starting
+ @Published public private(set) var submitting: Bool = false
+ @Published public var transcript: String = ""
+ @Published public var showTrace: Bool = false
+
+ public let appId: String
+ public let transcriptMaxLength: Int
+ public let runtimeMode: String
+ public let workflowId: String
+ public private(set) var workspaceId: String
+
+ private let host: EmbeddedHostProtocol?
+
+ public init(
+ host: EmbeddedHostProtocol?,
+ workspaceId: String,
+ appId: String = EmbeddedHost.defaultAppId,
+ transcriptMaxLength: Int = 5_000
+ ) {
+ self.host = host
+ self.workspaceId = workspaceId
+ self.appId = appId
+ self.transcriptMaxLength = transcriptMaxLength
+ self.runtimeMode = EmbeddedHost.runtimeModeEmbedded
+ self.workflowId = host?.workflowId ?? EmbeddedHost.defaultWorkflowId
+ self.runtimeStatus = host?.isReady == true ? .ready : .unavailable
+ }
+
+ public var canSubmit: Bool {
+ runtimeStatus == .ready &&
+ !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !isRunning
+ }
+
+ public var isRunning: Bool {
+ submitting || currentState == "processing"
+ }
+
+ public func updateWorkspace(_ workspaceId: String) {
+ self.workspaceId = workspaceId
+ }
+
+ public func refreshRuntimeStatus() {
+ runtimeStatus = host?.isReady == true ? .ready : .unavailable
+ }
+
+ public func submit() {
+ guard canSubmit, let host else { return }
+ let trimmed = String(transcript.trimmingCharacters(in: .whitespacesAndNewlines).prefix(transcriptMaxLength))
+ submitting = true
+ currentState = "processing"
+ errorMessage = nil
+ output = nil
+ trace = []
+ showTrace = false
+ sessionId = nil
+
+ Task { [weak self] in
+ guard let self else { return }
+ do {
+ let result = try await Task.detached {
+ try host.submitTranscript(trimmed)
+ }.value
+ await MainActor.run {
+ self.sessionId = result.sessionId
+ self.trace = result.events
+ self.showTrace = !result.events.isEmpty
+ self.submitting = false
+ if let error = result.error {
+ self.currentState = "error"
+ self.errorMessage = error
+ } else {
+ self.output = result.output ?? .empty
+ self.currentState = "completed"
+ }
+ }
+ } catch {
+ await MainActor.run {
+ self.submitting = false
+ self.currentState = "error"
+ self.errorMessage = error.localizedDescription
+ }
+ }
+ }
+ }
+
+ public func reset() {
+ submitting = false
+ currentState = "idle"
+ sessionId = nil
+ output = nil
+ trace = []
+ errorMessage = nil
+ showTrace = false
+ }
+
+ /// Compatibility alias for shell call sites.
+ public func resetLocal() {
+ reset()
+ }
+}
diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift
new file mode 100644
index 0000000..f2d7acc
--- /dev/null
+++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/EmbeddedHost.swift
@@ -0,0 +1,299 @@
+import Foundation
+import TraverseEmbedder
+
+/// Successful or failed embedded workflow run.
+public struct HostRunResult: Equatable, Sendable {
+ public let sessionId: String
+ public let output: MeetingNotesOutput?
+ public let events: [TraceEvent]
+ public let error: String?
+
+ public init(
+ sessionId: String,
+ output: MeetingNotesOutput?,
+ events: [TraceEvent],
+ error: String?
+ ) {
+ self.sessionId = sessionId
+ self.output = output
+ self.events = events
+ self.error = error
+ }
+}
+
+/// Embedded Traverse host boundary for SwiftUI shells.
+public protocol EmbeddedHostProtocol: AnyObject, Sendable {
+ var workspaceId: String { get }
+ var workflowId: String { get }
+ var isReady: Bool { get }
+ func submitTranscript(_ transcript: String) throws -> HostRunResult
+}
+
+/// Factory helpers for production and test hosts.
+public enum EmbeddedHost {
+ public static let runtimeModeEmbedded = "Embedded"
+ public static let defaultWorkflowId = "meeting-notes.process"
+ public static let defaultWorkspace = "local-default"
+ public static let defaultAppId = "meeting-notes"
+ public static let pinnedRuntimeWasmDigest =
+ "sha256:aa801023ba4eb20b8c1b4004bdd964a78fed9540478b252b77eac04c80811852"
+ public static let defaultRelativeBundlePath = "bundles/meeting-notes"
+
+ /// Production host backed by the digest-pinned runtime WASM bridge.
+ public static func tryCreateProduction(
+ bundleRoot: URL? = nil,
+ workspaceId: String? = nil,
+ digest: String? = nil
+ ) -> EmbeddedHostProtocol? {
+ do {
+ guard let root = resolveBundleRoot(override: bundleRoot) else { return nil }
+ let pinned = digest ?? readPinnedDigest(bundleRoot: root) ?? pinnedRuntimeWasmDigest
+ let workspace = (workspaceId?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
+ $0.isEmpty ? nil : $0
+ } ?? defaultWorkspace
+ return try ProductionEmbeddedHost(bundleRoot: root, digest: pinned, workspaceId: workspace)
+ } catch {
+ return nil
+ }
+ }
+
+ /// Deterministic test double (spec 068 / #751).
+ public static func createTestHost(
+ output: MeetingNotesOutput,
+ workspaceId: String = defaultWorkspace
+ ) throws -> EmbeddedHostProtocol {
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(output)
+ let harness = InMemoryTraverseEmbedder().withTargetOutput(data)
+ let bundle = try TraverseBundle(rootURL: URL(fileURLWithPath: "test-root"), runtimeWasmDigest: "sha256:test")
+ try harness.initialize(bundle: bundle)
+ return TestEmbeddedHost(harness: harness, workspaceId: workspaceId, workflowId: defaultWorkflowId)
+ }
+
+ public static func resolveBundleRoot(override: URL? = nil) -> URL? {
+ if let override {
+ let runtime = override.appendingPathComponent("runtime").appendingPathComponent("runtime.wasm")
+ if FileManager.default.fileExists(atPath: runtime.path) {
+ return override
+ }
+ }
+
+ let candidates: [URL] = [
+ Bundle.main.resourceURL?
+ .appendingPathComponent(defaultRelativeBundlePath),
+ Bundle.main.bundleURL
+ .appendingPathComponent("Contents/Resources")
+ .appendingPathComponent(defaultRelativeBundlePath),
+ URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
+ .appendingPathComponent(defaultRelativeBundlePath),
+ ].compactMap { $0 }
+
+ for candidate in candidates {
+ let runtime = candidate.appendingPathComponent("runtime").appendingPathComponent("runtime.wasm")
+ if FileManager.default.fileExists(atPath: runtime.path) {
+ return candidate
+ }
+ }
+ return nil
+ }
+
+ private static func readPinnedDigest(bundleRoot: URL) -> String? {
+ let release = bundleRoot.appendingPathComponent("runtime").appendingPathComponent("runtime-release.json")
+ guard let data = try? Data(contentsOf: release),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let hex = json["sha256"] as? String,
+ !hex.isEmpty else {
+ return nil
+ }
+ return hex.hasPrefix("sha256:") ? hex : "sha256:\(hex)"
+ }
+}
+
+private final class ProductionEmbeddedHost: EmbeddedHostProtocol, @unchecked Sendable {
+ let workspaceId: String
+ let workflowId: String
+ let isReady: Bool
+ private let client: WasmiHostBridgeClient
+ private let runtime: RuntimeTraverseEmbedder
+
+ init(bundleRoot: URL, digest: String, workspaceId: String) throws {
+ self.workspaceId = workspaceId
+ self.workflowId = EmbeddedHost.defaultWorkflowId
+ let bundle = try TraverseBundle(rootURL: bundleRoot, runtimeWasmDigest: digest)
+ self.client = try WasmiHostBridgeClient(bundle: bundle)
+ self.runtime = RuntimeTraverseEmbedder(client: client)
+ let config = try JSONSerialization.data(withJSONObject: ["workspace_id": workspaceId])
+ _ = try runtime.initialize(configJSON: config)
+ self.isReady = true
+ }
+
+ func submitTranscript(_ transcript: String) throws -> HostRunResult {
+ let input = try JSONSerialization.data(withJSONObject: ["transcript": transcript])
+ let submission = try TraverseSubmission(targetID: workflowId, inputJSON: input)
+ let accepted = try runtime.submit(submission)
+ guard accepted.status.lowercased() == "accepted" else {
+ return HostRunResult(
+ sessionId: accepted.sessionID,
+ output: nil,
+ events: [],
+ error: "submit \(accepted.status)"
+ )
+ }
+ return try drainEvents(sessionId: accepted.sessionID)
+ }
+
+ private func drainEvents(sessionId: String) throws -> HostRunResult {
+ var events: [TraceEvent] = []
+ var output: MeetingNotesOutput?
+ var error: String?
+
+ while let bytes = try client.nextEvent() {
+ guard let root = try JSONSerialization.jsonObject(with: bytes) as? [String: Any] else {
+ continue
+ }
+ let eventType = (root["type"] as? String)
+ ?? (root["event_type"] as? String)
+ ?? "event"
+ if let eventSession = root["session_id"] as? String,
+ eventSession != sessionId {
+ continue
+ }
+ let data = root["data"].map { JSONValue.fromAny($0) } ?? nil
+ events.append(TraceEvent(event_type: eventType, timestamp: "\(events.count)", data: data))
+
+ if eventType == "error" {
+ error = extractError(root["data"]) ?? "execution failed"
+ break
+ }
+ if eventType == "capability_result" {
+ output = parseOutput(root["data"])
+ break
+ }
+ }
+
+ if let error {
+ return HostRunResult(sessionId: sessionId, output: nil, events: events, error: error)
+ }
+ if output == nil, events.isEmpty {
+ return HostRunResult(
+ sessionId: sessionId,
+ output: nil,
+ events: events,
+ error: "embedder emitted no capability_result"
+ )
+ }
+ return HostRunResult(
+ sessionId: sessionId,
+ output: output ?? .empty,
+ events: events,
+ error: nil
+ )
+ }
+
+ deinit {
+ _ = try? runtime.shutdown()
+ }
+}
+
+private final class TestEmbeddedHost: EmbeddedHostProtocol, @unchecked Sendable {
+ let workspaceId: String
+ let workflowId: String
+ let isReady: Bool = true
+ private let harness: InMemoryTraverseEmbedder
+
+ init(harness: InMemoryTraverseEmbedder, workspaceId: String, workflowId: String) {
+ self.harness = harness
+ self.workspaceId = workspaceId
+ self.workflowId = workflowId
+ }
+
+ func submitTranscript(_ transcript: String) throws -> HostRunResult {
+ let input = try JSONSerialization.data(withJSONObject: ["transcript": transcript])
+ let submission = try TraverseSubmission(targetID: workflowId, inputJSON: input)
+ let accepted = try harness.submit(submission)
+ let runtimeEvents = try harness.subscribe()
+ var events: [TraceEvent] = []
+ var output: MeetingNotesOutput?
+ var error: String?
+
+ for evt in runtimeEvents {
+ if let sid = evt.sessionID, sid != accepted.sessionID { continue }
+ let eventType = evt.eventType ?? evt.status
+ var data: JSONValue?
+ if let out = evt.output,
+ let obj = try? JSONSerialization.jsonObject(with: out) {
+ data = JSONValue.fromAny(obj)
+ }
+ events.append(TraceEvent(event_type: eventType, timestamp: "\(evt.sequence)", data: data))
+ if eventType == "error" {
+ error = evt.errorData.flatMap { String(data: $0, encoding: .utf8) } ?? "execution failed"
+ break
+ }
+ if eventType == "capability_result", let out = evt.output {
+ output = (try? JSONDecoder().decode(MeetingNotesOutput.self, from: out)) ?? .empty
+ break
+ }
+ }
+
+ if let error {
+ return HostRunResult(sessionId: accepted.sessionID, output: nil, events: events, error: error)
+ }
+ return HostRunResult(
+ sessionId: accepted.sessionID,
+ output: output ?? .empty,
+ events: events,
+ error: output == nil ? "embedder emitted no capability_result" : nil
+ )
+ }
+
+ deinit {
+ harness.shutdown()
+ }
+}
+
+private func parseOutput(_ raw: Any?) -> MeetingNotesOutput {
+ guard let raw else { return .empty }
+ var value = raw
+ if let dict = raw as? [String: Any], let nested = dict["output"] {
+ value = nested
+ }
+ if let dict = value as? [String: Any],
+ let parsed = MeetingNotesOutputParser.parse(dict) {
+ return parsed
+ }
+ if let data = try? JSONSerialization.data(withJSONObject: value),
+ let decoded = try? JSONDecoder().decode(MeetingNotesOutput.self, from: data) {
+ return decoded
+ }
+ return .empty
+}
+
+private func extractError(_ raw: Any?) -> String? {
+ guard let dict = raw as? [String: Any] else { return nil }
+ if let err = dict["error"] as? String { return err }
+ if let err = dict["error"] as? [String: Any], let message = err["message"] as? String {
+ return message
+ }
+ return nil
+}
+
+private extension JSONValue {
+ static func fromAny(_ value: Any) -> JSONValue {
+ switch value {
+ case let s as String: return .string(s)
+ case let n as NSNumber:
+ if CFGetTypeID(n) == CFBooleanGetTypeID() {
+ return .bool(n.boolValue)
+ }
+ return .number(n.doubleValue)
+ case let dict as [String: Any]:
+ return .object(dict.mapValues { fromAny($0) })
+ case let arr as [Any]:
+ return .array(arr.map { fromAny($0) })
+ case is NSNull:
+ return .null
+ default:
+ return .null
+ }
+ }
+}
diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesCommand.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesCommand.swift
new file mode 100644
index 0000000..bb40226
--- /dev/null
+++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesCommand.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+public struct MeetingNotesCommand: Equatable, Sendable {
+ public let name: String
+ public let payload: [String: String]
+ public let sessionId: String?
+
+ public init(name: String, payload: [String: String] = [:], sessionId: String? = nil) {
+ self.name = name
+ self.payload = payload
+ self.sessionId = sessionId
+ }
+
+ public static func submit(transcript: String, sessionId: String? = nil) -> MeetingNotesCommand {
+ MeetingNotesCommand(name: "submit", payload: ["transcript": transcript], sessionId: sessionId)
+ }
+}
diff --git a/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift
new file mode 100644
index 0000000..12f68e4
--- /dev/null
+++ b/apps/meeting-notes/MeetingNotesCore/Sources/MeetingNotesCore/MeetingNotesOutput.swift
@@ -0,0 +1,202 @@
+import Foundation
+
+public struct ActionItem: Equatable, Sendable, Codable {
+ public let task: String
+ public let owner: String?
+ public let due: String?
+
+ public init(task: String, owner: String? = nil, due: String? = nil) {
+ self.task = task
+ self.owner = owner
+ self.due = due
+ }
+}
+
+public struct Decision: Equatable, Sendable, Codable {
+ public let text: String
+ public let madeBy: String?
+
+ public init(text: String, madeBy: String? = nil) {
+ self.text = text
+ self.madeBy = madeBy
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case text
+ case madeBy = "made_by"
+ }
+}
+
+/// Runtime-owned meeting-notes.process output.
+public struct MeetingNotesOutput: Equatable, Sendable, Codable {
+ public let actionItems: [ActionItem]
+ public let decisions: [Decision]
+ public let followUps: [String]
+ public let summary: String
+
+ public init(
+ actionItems: [ActionItem],
+ decisions: [Decision],
+ followUps: [String],
+ summary: String
+ ) {
+ self.actionItems = actionItems
+ self.decisions = decisions
+ self.followUps = followUps
+ self.summary = summary
+ }
+
+ public static let empty = MeetingNotesOutput(
+ actionItems: [],
+ decisions: [],
+ followUps: [],
+ summary: ""
+ )
+
+ enum CodingKeys: String, CodingKey {
+ case actionItems = "action_items"
+ case decisions
+ case followUps = "follow_ups"
+ case summary
+ }
+}
+
+public struct TraceEvent: Equatable, Sendable, Codable {
+ public let event_type: String
+ public let timestamp: String
+ public let data: JSONValue?
+
+ public init(event_type: String, timestamp: String, data: JSONValue? = nil) {
+ self.event_type = event_type
+ self.timestamp = timestamp
+ self.data = data
+ }
+}
+
+public struct AppStateEventPayload: Equatable, Sendable {
+ public let state: String?
+ public let sessionId: String?
+ public let executionId: String?
+ public let output: MeetingNotesOutput?
+ public let errorMessage: String?
+
+ public init(
+ state: String? = nil,
+ sessionId: String? = nil,
+ executionId: String? = nil,
+ output: MeetingNotesOutput? = nil,
+ errorMessage: String? = nil
+ ) {
+ self.state = state
+ self.sessionId = sessionId
+ self.executionId = executionId
+ self.output = output
+ self.errorMessage = errorMessage
+ }
+}
+
+public enum MeetingNotesClientError: Error, Equatable, Sendable {
+ case http(status: Int)
+ case decode
+ case invalidURL
+}
+
+public enum JSONValue: Equatable, Sendable, Codable {
+ case string(String)
+ case number(Double)
+ case bool(Bool)
+ case object([String: JSONValue])
+ case array([JSONValue])
+ case null
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() {
+ self = .null
+ } else if let value = try? container.decode(Bool.self) {
+ self = .bool(value)
+ } else if let value = try? container.decode(Double.self) {
+ self = .number(value)
+ } else if let value = try? container.decode(String.self) {
+ self = .string(value)
+ } else if let value = try? container.decode([String: JSONValue].self) {
+ self = .object(value)
+ } else if let value = try? container.decode([JSONValue].self) {
+ self = .array(value)
+ } else {
+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON")
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .string(let value): try container.encode(value)
+ case .number(let value): try container.encode(value)
+ case .bool(let value): try container.encode(value)
+ case .object(let value): try container.encode(value)
+ case .array(let value): try container.encode(value)
+ case .null: try container.encodeNil()
+ }
+ }
+}
+
+public enum MeetingNotesOutputParser {
+ public static func parse(_ raw: Any?) -> MeetingNotesOutput? {
+ guard let dict = raw as? [String: Any],
+ let summary = dict["summary"] as? String else {
+ return nil
+ }
+ let actionItems = parseActionItems(dict["action_items"])
+ let decisions = parseDecisions(dict["decisions"])
+ let followUps = dict["follow_ups"] as? [String] ?? []
+ return MeetingNotesOutput(
+ actionItems: actionItems,
+ decisions: decisions,
+ followUps: followUps,
+ summary: summary
+ )
+ }
+
+ public static func parseEventPayload(_ raw: Any?) -> AppStateEventPayload? {
+ guard let dict = raw as? [String: Any] else { return nil }
+ let state = dict["state"] as? String
+ let sessionId = dict["session_id"] as? String
+ let executionId = dict["execution_id"] as? String
+ let output = parse(dict["output"])
+ var errorMessage: String?
+ if let error = dict["error"] as? String {
+ errorMessage = error
+ } else if let errorObj = dict["error"] as? [String: Any],
+ let message = errorObj["message"] as? String {
+ errorMessage = message
+ }
+ return AppStateEventPayload(
+ state: state,
+ sessionId: sessionId,
+ executionId: executionId,
+ output: output,
+ errorMessage: errorMessage
+ )
+ }
+
+ private static func parseActionItems(_ raw: Any?) -> [ActionItem] {
+ guard let items = raw as? [[String: Any]] else { return [] }
+ return items.compactMap { item in
+ guard let task = item["task"] as? String else { return nil }
+ return ActionItem(
+ task: task,
+ owner: item["owner"] as? String,
+ due: item["due"] as? String
+ )
+ }
+ }
+
+ private static func parseDecisions(_ raw: Any?) -> [Decision] {
+ guard let items = raw as? [[String: Any]] else { return [] }
+ return items.compactMap { item in
+ guard let text = item["text"] as? String else { return nil }
+ return Decision(text: text, madeBy: item["made_by"] as? String)
+ }
+ }
+}
diff --git a/apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/MeetingNotesCoreTests.swift b/apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/MeetingNotesCoreTests.swift
new file mode 100644
index 0000000..bc2e855
--- /dev/null
+++ b/apps/meeting-notes/MeetingNotesCore/Tests/MeetingNotesCoreTests/MeetingNotesCoreTests.swift
@@ -0,0 +1,95 @@
+import Foundation
+import XCTest
+@testable import MeetingNotesCore
+
+final class EmbeddedHostTests: XCTestCase {
+ private var sampleOutput: MeetingNotesOutput {
+ MeetingNotesOutput(
+ actionItems: [ActionItem(task: "Ship clients", owner: "Alex", due: "Friday")],
+ decisions: [Decision(text: "Use embedded runtime", madeBy: "Team")],
+ followUps: ["Schedule review"],
+ summary: "Discussed Wave 2 ports"
+ )
+ }
+
+ func testTestHostReturnsScriptedCapabilityResult() throws {
+ let host = try EmbeddedHost.createTestHost(output: sampleOutput)
+ let result = try host.submitTranscript("any transcript")
+ XCTAssertNil(result.error)
+ XCTAssertEqual(result.output?.summary, "Discussed Wave 2 ports")
+ XCTAssertTrue(result.events.contains { $0.event_type == "capability_result" })
+ }
+
+ func testPinnedDigestConstant() {
+ XCTAssertTrue(EmbeddedHost.pinnedRuntimeWasmDigest.hasPrefix("sha256:"))
+ XCTAssertEqual(EmbeddedHost.pinnedRuntimeWasmDigest.count, 71)
+ }
+}
+
+@MainActor
+final class AppStateViewModelTests: XCTestCase {
+ private var sampleOutput: MeetingNotesOutput {
+ MeetingNotesOutput(
+ actionItems: [ActionItem(task: "Ship clients")],
+ decisions: [],
+ followUps: [],
+ summary: "Discussed Wave 2 ports"
+ )
+ }
+
+ func testCanSubmitWhenReadyWithTranscript() throws {
+ let host = try EmbeddedHost.createTestHost(output: sampleOutput)
+ let vm = AppStateViewModel(host: host, workspaceId: "local-default")
+ vm.transcript = "hello"
+ XCTAssertEqual(vm.runtimeStatus, .ready)
+ XCTAssertEqual(vm.runtimeMode, EmbeddedHost.runtimeModeEmbedded)
+ XCTAssertTrue(vm.canSubmit)
+ }
+
+ func testUnavailableHostDisablesSubmit() {
+ let vm = AppStateViewModel(host: nil, workspaceId: "local-default")
+ vm.transcript = "hello"
+ XCTAssertEqual(vm.runtimeStatus, .unavailable)
+ XCTAssertFalse(vm.canSubmit)
+ }
+
+ func testSubmitTransitionsToCompleted() async throws {
+ let host = try EmbeddedHost.createTestHost(output: sampleOutput)
+ let vm = AppStateViewModel(host: host, workspaceId: "local-default")
+ vm.transcript = "meeting transcript"
+ vm.submit()
+ try await Task.sleep(nanoseconds: 200_000_000)
+ XCTAssertEqual(vm.currentState, "completed")
+ XCTAssertEqual(vm.output?.summary, "Discussed Wave 2 ports")
+ XCTAssertNotNil(vm.sessionId)
+ }
+
+ func testResetReturnsToIdle() throws {
+ let host = try EmbeddedHost.createTestHost(output: sampleOutput)
+ let vm = AppStateViewModel(host: host, workspaceId: "local-default")
+ vm.errorMessage = "boom"
+ vm.reset()
+ XCTAssertEqual(vm.currentState, "idle")
+ XCTAssertNil(vm.errorMessage)
+ }
+}
+
+final class MeetingNotesOutputParserTests: XCTestCase {
+ func testParseProcessOutput() {
+ let raw: [String: Any] = [
+ "action_items": [
+ ["task": "Ship clients", "owner": "Alex", "due": "Friday"],
+ ],
+ "decisions": [
+ ["text": "Use embedded runtime", "made_by": "Team"],
+ ],
+ "follow_ups": ["Schedule review"],
+ "summary": "Discussed Wave 2 ports",
+ ]
+ let output = MeetingNotesOutputParser.parse(raw)
+ XCTAssertEqual(output?.summary, "Discussed Wave 2 ports")
+ XCTAssertEqual(output?.actionItems.first?.task, "Ship clients")
+ XCTAssertEqual(output?.decisions.first?.madeBy, "Team")
+ XCTAssertEqual(output?.followUps, ["Schedule review"])
+ }
+}
diff --git a/apps/meeting-notes/android-compose/.gitignore b/apps/meeting-notes/android-compose/.gitignore
new file mode 100644
index 0000000..e4f1161
--- /dev/null
+++ b/apps/meeting-notes/android-compose/.gitignore
@@ -0,0 +1,9 @@
+.gradle/
+build/
+local.properties
+.idea/
+*.iml
+.DS_Store
+captures/
+.externalNativeBuild/
+.cxx/
diff --git a/apps/meeting-notes/android-compose/README.md b/apps/meeting-notes/android-compose/README.md
new file mode 100644
index 0000000..3fe47e6
--- /dev/null
+++ b/apps/meeting-notes/android-compose/README.md
@@ -0,0 +1,48 @@
+# meeting-notes (Android Compose)
+
+**Runtime mode: Embedded** - public Kotlin `TraverseEmbedder` (`dev.traverse.embedder`) with digest-pinned `runtime/runtime.wasm`. No `traverse-cli serve` sidecar is required.
+
+Native Android client for the `meeting-notes` reference app.
+
+## Prerequisites
+
+- Android Studio Ladybug+ (or compatible AGP 8.7 / Kotlin 2.0)
+- Android emulator API 28+ (or physical device)
+- Traverse checkout with the Kotlin embedder package and certified runtime artifact:
+
+```bash
+export TRAVERSE_REPO=/path/to/Traverse
+bash scripts/ci/sync_android_meeting_notes_bundle.sh
+```
+
+`settings.gradle.kts` composites `$TRAVERSE_REPO/packages/kotlin/TraverseEmbedder` as `:traverse-embedder`.
+
+## Bundle configuration
+
+Assets live under `app/src/main/assets/bundles/meeting-notes/` (including `runtime/runtime.wasm` + `runtime-release.json` after sync). The app copies them into `filesDir` on launch.
+
+Settings -> Workspace only (no sidecar URL). Default workspace is `local-default`.
+
+## Build and test
+
+```bash
+export TRAVERSE_REPO=/path/to/Traverse
+cd apps/meeting-notes/android-compose
+./gradlew test
+./gradlew :app:assembleDebug
+```
+
+Unit tests inject `InMemoryTraverseEmbedder` via `InMemoryMeetingNotesHost`; fixtures use only the runtime-owned `MeetingNotesOutput` fields.
+
+## Architecture
+
+| File | Role |
+|---|---|
+| `EmbeddedHost.kt` | Production + in-memory embedded hosts |
+| `ExecutionViewModel.kt` | Submit transcript -> render runtime-owned output |
+| `BundleAssets.kt` | Materialize asset bundle into filesDir |
+| `ui/MainScreen.kt` | Embedded runtime status, transcript input, output, trace |
+
+## Design language
+
+Follow [docs/design-language.md](../../../docs/design-language.md). Zone 1 shows **Embedded** runtime mode.
diff --git a/apps/meeting-notes/android-compose/app/build.gradle.kts b/apps/meeting-notes/android-compose/app/build.gradle.kts
new file mode 100644
index 0000000..4a04f42
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/build.gradle.kts
@@ -0,0 +1,75 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.serialization")
+ id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+ namespace = "com.traverseframework.meetingnotes"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.traverseframework.meetingnotes"
+ minSdk = 28
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.activity:activity-compose:1.9.3")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+ implementation("androidx.navigation:navigation-compose:2.8.4")
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+
+ // Public Traverse Kotlin embedder (composite via settings.gradle.kts + TRAVERSE_REPO)
+ implementation(project(":traverse-embedder"))
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
+}
diff --git a/apps/meeting-notes/android-compose/app/proguard-rules.pro b/apps/meeting-notes/android-compose/app/proguard-rules.pro
new file mode 100644
index 0000000..60ba34d
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/proguard-rules.pro
@@ -0,0 +1 @@
+# traverse-starter Android — default keep rules
diff --git a/apps/meeting-notes/android-compose/app/src/main/AndroidManifest.xml b/apps/meeting-notes/android-compose/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8e50f4a
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/app.manifest.json b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/app.manifest.json
new file mode 100644
index 0000000..06486a2
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/app.manifest.json
@@ -0,0 +1,117 @@
+{
+ "app_id": "meeting-notes",
+ "version": "1.0.0",
+ "schema_version": "1.0.0",
+ "workspace_defaults": {
+ "workspace_id": "local-default",
+ "registry_scope": "private"
+ },
+ "components": [
+ {
+ "component_id": "meeting-notes.process-component",
+ "version": "1.0.0",
+ "digest": "sha256:5647c39a1d25d8728350f9619025292a62e78a602068a2ad9b6f075751c93d99",
+ "manifest_path": "components/process/component.manifest.json"
+ }
+ ],
+ "workflows": [
+ {
+ "workflow_id": "meeting-notes.process",
+ "workflow_version": "1.0.0",
+ "path": "_traverse/workflows/examples/meeting-notes/process/workflow.json"
+ }
+ ],
+ "model_dependencies": [],
+ "config_schema": {
+ "type": "object",
+ "required": [
+ "workspace_id"
+ ],
+ "properties": {
+ "workspace_id": {
+ "type": "string"
+ },
+ "processing_mode": {
+ "type": "string",
+ "enum": [
+ "deterministic"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "default_config": {
+ "workspace_id": "local-default",
+ "processing_mode": "deterministic"
+ },
+ "placement_policy": {
+ "preferred_targets": [
+ "local"
+ ],
+ "allow_fallback": false
+ },
+ "public_surfaces": [
+ "cli",
+ "http_json"
+ ],
+ "state_machine": {
+ "initial_state": "idle",
+ "list_context_fields": [
+ "output.action_items",
+ "output.decisions",
+ "output.follow_ups",
+ "output.summary"
+ ],
+ "states": [
+ {
+ "id": "idle",
+ "transitions": [
+ {
+ "on": "submit",
+ "to": "processing"
+ }
+ ]
+ },
+ {
+ "id": "processing",
+ "invoke": {
+ "capability_id": "meeting-notes.process",
+ "input_from": "command.payload"
+ },
+ "transitions": [
+ {
+ "on": "capability_succeeded",
+ "to": "results"
+ },
+ {
+ "on": "capability_failed",
+ "to": "error"
+ }
+ ]
+ },
+ {
+ "id": "results",
+ "transitions": [
+ {
+ "on": "reset",
+ "to": "idle"
+ }
+ ]
+ },
+ {
+ "id": "error",
+ "transitions": [
+ {
+ "on": "retry",
+ "to": "processing",
+ "with_last_payload": true
+ },
+ {
+ "on": "reset",
+ "to": "idle"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/components/process/component.manifest.json b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/components/process/component.manifest.json
new file mode 100644
index 0000000..dec21bd
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/manifests/components/process/component.manifest.json
@@ -0,0 +1,30 @@
+{
+ "component_id": "meeting-notes.process-component",
+ "version": "1.0.0",
+ "schema_version": "1.0.0",
+ "capability_id": "meeting-notes.process",
+ "capability_version": "1.0.0",
+ "registry_ref": {
+ "namespace": "meeting-notes",
+ "id": "meeting-notes.process",
+ "version_range": "^1.0.0"
+ },
+ "runtime_constraints": {
+ "host_api_access": "none",
+ "network_access": "forbidden",
+ "filesystem_access": "none"
+ },
+ "permitted_targets": [
+ "local",
+ "device"
+ ],
+ "dependencies": [],
+ "connector_requirements": [],
+ "validation_evidence": [
+ {
+ "evidence_type": "checked_in_fixture",
+ "status": "passed",
+ "produced_by": "meeting_notes_example_smoke"
+ }
+ ]
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime-release.json b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime-release.json
new file mode 100644
index 0000000..b3354d7
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime-release.json
@@ -0,0 +1 @@
+{"runtime_version":"0.8.1","bridge_version":"1.1.0","bridge_abi_version":10100,"sha256":"aa801023ba4eb20b8c1b4004bdd964a78fed9540478b252b77eac04c80811852"}
diff --git a/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime.wasm b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime.wasm
new file mode 100644
index 0000000..9587c31
Binary files /dev/null and b/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime/runtime.wasm differ
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/BundleAssets.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/BundleAssets.kt
new file mode 100644
index 0000000..05de56f
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/BundleAssets.kt
@@ -0,0 +1,38 @@
+package com.traverseframework.meetingnotes
+
+import android.content.Context
+import java.io.File
+
+/** Copies bundled Traverse runtime assets from APK assets into app filesDir. */
+object BundleAssets {
+ fun materialize(context: Context, assetDir: String = AppConstants.BUNDLE_ASSET_DIR): File {
+ val dest = File(context.filesDir, assetDir)
+ copyAssetTree(context, assetDir, dest)
+ return dest
+ }
+
+ private fun copyAssetTree(context: Context, assetPath: String, destDir: File) {
+ val children = context.assets.list(assetPath) ?: return
+ if (children.isEmpty()) {
+ // leaf file
+ destDir.parentFile?.mkdirs()
+ context.assets.open(assetPath).use { input ->
+ destDir.outputStream().use { output -> input.copyTo(output) }
+ }
+ return
+ }
+ destDir.mkdirs()
+ for (child in children) {
+ val childAsset = "$assetPath/$child"
+ val childDest = File(destDir, child)
+ val grand = context.assets.list(childAsset)
+ if (grand.isNullOrEmpty()) {
+ context.assets.open(childAsset).use { input ->
+ childDest.outputStream().use { output -> input.copyTo(output) }
+ }
+ } else {
+ copyAssetTree(context, childAsset, childDest)
+ }
+ }
+ }
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt
new file mode 100644
index 0000000..b388067
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/EmbeddedHost.kt
@@ -0,0 +1,146 @@
+package com.traverseframework.meetingnotes
+
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import java.io.File
+
+/**
+ * Embedded Traverse host for meeting-notes.
+ *
+ * Production uses the public Kotlin `dev.traverse.embedder` package.
+ * Unit tests inject [InMemoryMeetingNotesHost] with scripted runtime-owned output.
+ */
+interface MeetingNotesHost {
+ val runtimeMode: String
+ val isReady: Boolean
+ fun submitTranscript(transcript: String): HostRunResult
+}
+
+data class HostRunResult(
+ val sessionId: String,
+ val output: MeetingNotesOutput?,
+ val events: List,
+ val error: String?,
+)
+
+/** Deterministic test double wrapping [dev.traverse.embedder.InMemoryTraverseEmbedder]. */
+class InMemoryMeetingNotesHost(
+ private val scriptedOutputJson: String,
+) : MeetingNotesHost {
+ override val runtimeMode: String = AppConstants.RUNTIME_MODE_EMBEDDED
+ override val isReady: Boolean = true
+
+ private val embedder = dev.traverse.embedder.InMemoryTraverseEmbedder()
+ .withTargetOutput(scriptedOutputJson)
+ .also {
+ it.initialize(
+ dev.traverse.embedder.TraverseBundle(
+ rootPath = "test-bundle",
+ runtimeWasmDigest = "sha256:test",
+ ),
+ )
+ }
+
+ override fun submitTranscript(transcript: String): HostRunResult {
+ val inputJson = buildJsonObject { put("transcript", transcript) }.toString()
+ val result = embedder.submit(
+ dev.traverse.embedder.TraverseSubmission(AppConstants.CAPABILITY_ID, inputJson),
+ )
+ val events = embedder.subscribe()
+ val outputJson = events.firstOrNull { it.eventType == "capability_result" }?.output
+ val output = outputJson?.let { parseOutput(it) }
+ return HostRunResult(
+ sessionId = result.sessionId,
+ output = output,
+ events = events.map {
+ TraceEvent(
+ event_type = it.eventType ?: it.status,
+ timestamp = it.sequence.toString(),
+ data = null,
+ )
+ },
+ error = if (output == null) "embedder emitted no capability_result output" else null,
+ )
+ }
+
+ companion object {
+ private val json = Json { ignoreUnknownKeys = true }
+
+ fun parseOutput(raw: String): MeetingNotesOutput? = try {
+ json.decodeFromString(MeetingNotesOutput.serializer(), raw)
+ } catch (_: Exception) {
+ null
+ }
+
+ fun withScriptedOutput(output: MeetingNotesOutput): InMemoryMeetingNotesHost =
+ InMemoryMeetingNotesHost(json.encodeToString(MeetingNotesOutput.serializer(), output))
+ }
+}
+
+/**
+ * Production host: digest-pinned `runtime/runtime.wasm` via public [RuntimeTraverseEmbedder]
+ * constructed from [TraverseBundle] (public constructor).
+ */
+class ProductionMeetingNotesHost private constructor(
+ private val embedder: dev.traverse.embedder.RuntimeTraverseEmbedder,
+) : MeetingNotesHost {
+ override val runtimeMode: String = AppConstants.RUNTIME_MODE_EMBEDDED
+ override val isReady: Boolean = true
+
+ override fun submitTranscript(transcript: String): HostRunResult = try {
+ val inputJson = buildJsonObject { put("transcript", transcript) }.toString()
+ val result = embedder.submit(
+ dev.traverse.embedder.TraverseSubmission(AppConstants.CAPABILITY_ID, inputJson),
+ )
+ val runtimeEvents = embedder.subscribe()
+ val events = runtimeEvents.map {
+ TraceEvent(
+ event_type = it.eventType ?: it.status,
+ timestamp = it.sequence.toString(),
+ data = null,
+ )
+ }
+ val output = runtimeEvents
+ .firstOrNull { it.eventType == "capability_result" || it.output != null }
+ ?.output
+ ?.let { InMemoryMeetingNotesHost.parseOutput(it) }
+ HostRunResult(
+ sessionId = result.sessionId,
+ output = output,
+ events = events,
+ error = if (output == null) {
+ "runtime returned no meeting-notes output"
+ } else {
+ null
+ },
+ )
+ } catch (e: Exception) {
+ HostRunResult("", null, emptyList(), e.message ?: "submit failed")
+ }
+
+ companion object {
+ fun createOrNull(bundleRoot: File): ProductionMeetingNotesHost? {
+ val wasm = File(bundleRoot, "runtime/runtime.wasm")
+ val release = File(bundleRoot, "runtime/runtime-release.json")
+ if (!wasm.isFile || !release.isFile) return null
+ val digestHex = Regex("\"sha256\"\\s*:\\s*\"([^\"]+)\"")
+ .find(release.readText())
+ ?.groupValues
+ ?.get(1)
+ ?: return null
+ return try {
+ val bundle = dev.traverse.embedder.TraverseBundle(
+ rootPath = bundleRoot.absolutePath,
+ runtimeWasmDigest = "sha256:$digestHex",
+ )
+ val embedder = dev.traverse.embedder.RuntimeTraverseEmbedder(bundle)
+ embedder.initialize("{}")
+ ProductionMeetingNotesHost(embedder)
+ } catch (_: Exception) {
+ null
+ }
+ }
+ }
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionUiState.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionUiState.kt
new file mode 100644
index 0000000..c380be7
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionUiState.kt
@@ -0,0 +1,31 @@
+package com.traverseframework.meetingnotes
+
+sealed interface ExecutionPhase {
+ data object Idle : ExecutionPhase
+ data object Loading : ExecutionPhase
+ data class Succeeded(val output: MeetingNotesOutput, val trace: List) : ExecutionPhase
+ data class Failed(val error: String) : ExecutionPhase
+}
+
+enum class RuntimeStatus {
+ Starting,
+ Ready,
+ Unavailable,
+}
+
+data class ExecutionUiState(
+ val phase: ExecutionPhase = ExecutionPhase.Idle,
+ val transcript: String = "",
+ val runtimeStatus: RuntimeStatus = RuntimeStatus.Starting,
+ val runtimeMode: String = AppConstants.RUNTIME_MODE_EMBEDDED,
+ val workspace: String = AppConstants.DEFAULT_WORKSPACE,
+ val showTrace: Boolean = false,
+) {
+ val isRunning: Boolean
+ get() = phase is ExecutionPhase.Loading
+
+ val canSubmit: Boolean
+ get() = runtimeStatus == RuntimeStatus.Ready &&
+ transcript.trim().isNotEmpty() &&
+ !isRunning
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModel.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModel.kt
new file mode 100644
index 0000000..ebf074a
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModel.kt
@@ -0,0 +1,75 @@
+package com.traverseframework.meetingnotes
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class ExecutionViewModel(
+ private val host: MeetingNotesHost,
+ private val settings: RuntimeSettings,
+ private val computeDispatcher: CoroutineDispatcher = Dispatchers.Default,
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(
+ ExecutionUiState(
+ runtimeStatus = if (host.isReady) RuntimeStatus.Ready else RuntimeStatus.Unavailable,
+ runtimeMode = host.runtimeMode,
+ workspace = AppConstants.DEFAULT_WORKSPACE,
+ ),
+ )
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var submitJob: Job? = null
+
+ init {
+ viewModelScope.launch {
+ settings.workspace.collect { workspace ->
+ _uiState.update { it.copy(workspace = workspace) }
+ }
+ }
+ }
+
+ fun updateTranscript(transcript: String) {
+ _uiState.update { it.copy(transcript = transcript.take(AppConstants.TRANSCRIPT_MAX_LENGTH)) }
+ }
+
+ fun toggleTrace(show: Boolean) {
+ _uiState.update { it.copy(showTrace = show) }
+ }
+
+ fun submit() {
+ val state = _uiState.value
+ if (!state.canSubmit) return
+ submitJob?.cancel()
+ _uiState.update { it.copy(phase = ExecutionPhase.Loading) }
+ val transcript = state.transcript.trim()
+ submitJob = viewModelScope.launch {
+ val result = withContext(computeDispatcher) { host.submitTranscript(transcript) }
+ if (result.error != null && result.output == null) {
+ _uiState.update { it.copy(phase = ExecutionPhase.Failed(result.error)) }
+ } else {
+ _uiState.update {
+ it.copy(
+ phase = ExecutionPhase.Succeeded(
+ result.output ?: MeetingNotesOutput.EMPTY,
+ result.events,
+ ),
+ )
+ }
+ }
+ }
+ }
+
+ fun reset() {
+ submitJob?.cancel()
+ submitJob = null
+ _uiState.update { it.copy(phase = ExecutionPhase.Idle, showTrace = false) }
+ }
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModelFactory.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModelFactory.kt
new file mode 100644
index 0000000..e9e7a1d
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ExecutionViewModelFactory.kt
@@ -0,0 +1,17 @@
+package com.traverseframework.meetingnotes
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+
+class ExecutionViewModelFactory(
+ private val host: MeetingNotesHost,
+ private val settings: RuntimeSettings,
+) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(ExecutionViewModel::class.java)) {
+ return ExecutionViewModel(host, settings) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/MainActivity.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/MainActivity.kt
new file mode 100644
index 0000000..0bf2a4d
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/MainActivity.kt
@@ -0,0 +1,63 @@
+package com.traverseframework.meetingnotes
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.traverseframework.meetingnotes.ui.MainScreen
+import com.traverseframework.meetingnotes.ui.MeetingNotesTheme
+import com.traverseframework.meetingnotes.ui.SettingsScreen
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ val settings = SettingsRepository(applicationContext)
+ val bundleRoot = BundleAssets.materialize(applicationContext)
+ val host: MeetingNotesHost = ProductionMeetingNotesHost.createOrNull(bundleRoot)
+ ?: UnavailableMeetingNotesHost
+ setContent {
+ MeetingNotesTheme {
+ val navController = rememberNavController()
+ val viewModel: ExecutionViewModel = viewModel(
+ factory = ExecutionViewModelFactory(host, settings),
+ )
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ NavHost(navController = navController, startDestination = "main") {
+ composable("main") {
+ MainScreen(
+ uiState = uiState,
+ onTranscriptChange = viewModel::updateTranscript,
+ onSubmit = viewModel::submit,
+ onReset = viewModel::reset,
+ onOpenSettings = { navController.navigate("settings") },
+ onTraceToggle = viewModel::toggleTrace,
+ )
+ }
+ composable("settings") {
+ SettingsScreen(
+ settings = settings,
+ currentWorkspace = uiState.workspace,
+ onBack = { navController.popBackStack() },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Fallback when the digest-pinned runtime bundle is missing from assets. */
+object UnavailableMeetingNotesHost : MeetingNotesHost {
+ override val runtimeMode: String = AppConstants.RUNTIME_MODE_EMBEDDED
+ override val isReady: Boolean = false
+ override fun submitTranscript(transcript: String): HostRunResult =
+ HostRunResult("", null, emptyList(), "embedded runtime unavailable - sync the app bundle")
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/Models.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/Models.kt
new file mode 100644
index 0000000..91f8168
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/Models.kt
@@ -0,0 +1,49 @@
+package com.traverseframework.meetingnotes
+
+import kotlinx.serialization.json.JsonElement
+
+@kotlinx.serialization.Serializable
+data class ActionItem(
+ val task: String,
+ val owner: String? = null,
+ val due: String? = null,
+)
+
+@kotlinx.serialization.Serializable
+data class Decision(
+ val text: String,
+ val made_by: String? = null,
+)
+
+@kotlinx.serialization.Serializable
+data class MeetingNotesOutput(
+ val action_items: List,
+ val decisions: List,
+ val follow_ups: List,
+ val summary: String,
+) {
+ companion object {
+ val EMPTY = MeetingNotesOutput(
+ action_items = emptyList(),
+ decisions = emptyList(),
+ follow_ups = emptyList(),
+ summary = "",
+ )
+ }
+}
+
+@kotlinx.serialization.Serializable
+data class TraceEvent(
+ val event_type: String,
+ val timestamp: String,
+ val data: JsonElement? = null,
+)
+
+object AppConstants {
+ const val CAPABILITY_ID = "meeting-notes.process"
+ const val RUNTIME_MODE_EMBEDDED = "Embedded"
+ const val DEFAULT_WORKSPACE = "local-default"
+ const val TRANSCRIPT_MAX_LENGTH = 5000
+ /** Asset-relative bundle root (must include runtime/runtime.wasm after sync). */
+ const val BUNDLE_ASSET_DIR = "bundles/meeting-notes"
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/SettingsRepository.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/SettingsRepository.kt
new file mode 100644
index 0000000..9d770c7
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/SettingsRepository.kt
@@ -0,0 +1,29 @@
+package com.traverseframework.meetingnotes
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+interface RuntimeSettings {
+ val workspace: Flow
+ suspend fun setWorkspace(workspace: String)
+}
+
+private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "meeting_notes_settings")
+
+class SettingsRepository(private val context: Context) : RuntimeSettings {
+ private val workspaceKey = stringPreferencesKey("workspace")
+
+ override val workspace: Flow = context.settingsDataStore.data.map { prefs ->
+ prefs[workspaceKey] ?: AppConstants.DEFAULT_WORKSPACE
+ }
+
+ override suspend fun setWorkspace(workspace: String) {
+ context.settingsDataStore.edit { it[workspaceKey] = workspace }
+ }
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/MainScreen.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/MainScreen.kt
new file mode 100644
index 0000000..89c6984
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/MainScreen.kt
@@ -0,0 +1,243 @@
+package com.traverseframework.meetingnotes.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.traverseframework.meetingnotes.ActionItem
+import com.traverseframework.meetingnotes.AppConstants
+import com.traverseframework.meetingnotes.Decision
+import com.traverseframework.meetingnotes.ExecutionPhase
+import com.traverseframework.meetingnotes.ExecutionUiState
+import com.traverseframework.meetingnotes.MeetingNotesOutput
+import com.traverseframework.meetingnotes.RuntimeStatus
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MainScreen(
+ uiState: ExecutionUiState,
+ onTranscriptChange: (String) -> Unit,
+ onSubmit: () -> Unit,
+ onReset: () -> Unit,
+ onOpenSettings: () -> Unit,
+ onTraceToggle: (Boolean) -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("meeting-notes") },
+ actions = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(end = 12.dp),
+ ) {
+ StatusDot(uiState.runtimeStatus)
+ Text(
+ text = statusLabel(uiState.runtimeStatus),
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ Button(onClick = onOpenSettings, modifier = Modifier.padding(start = 8.dp)) {
+ Text("Settings")
+ }
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ RuntimeCard(uiState)
+ InputCard(uiState, onTranscriptChange, onSubmit)
+ OutputCard(uiState, onReset, onTraceToggle)
+ }
+ }
+}
+
+@Composable
+private fun RuntimeCard(uiState: ExecutionUiState) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Runtime Environment", style = MaterialTheme.typography.titleMedium)
+ Text("mode: ${uiState.runtimeMode}", style = MaterialTheme.typography.bodySmall)
+ Text("workspace: ${uiState.workspace}", style = MaterialTheme.typography.bodySmall)
+ Text("capability: ${AppConstants.CAPABILITY_ID}", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+}
+
+@Composable
+private fun InputCard(
+ uiState: ExecutionUiState,
+ onTranscriptChange: (String) -> Unit,
+ onSubmit: () -> Unit,
+) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Process Transcript", style = MaterialTheme.typography.titleMedium)
+ OutlinedTextField(
+ value = uiState.transcript,
+ onValueChange = onTranscriptChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(140.dp),
+ placeholder = { Text("Paste meeting transcript...") },
+ )
+ Text("${uiState.transcript.length}/${AppConstants.TRANSCRIPT_MAX_LENGTH}")
+ Button(onClick = onSubmit, enabled = uiState.canSubmit, modifier = Modifier.fillMaxWidth()) {
+ Text(if (uiState.isRunning) "Processing..." else "Submit Transcript")
+ }
+ if (uiState.runtimeStatus == RuntimeStatus.Unavailable) {
+ Text(
+ "Embedded runtime unavailable - sync the bundle with scripts/ci/sync_android_meeting_notes_bundle.sh (requires TRAVERSE_REPO).",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun OutputCard(
+ uiState: ExecutionUiState,
+ onReset: () -> Unit,
+ onTraceToggle: (Boolean) -> Unit,
+) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Output", style = MaterialTheme.typography.titleMedium)
+ when (val phase = uiState.phase) {
+ ExecutionPhase.Idle -> {
+ Text(
+ if (uiState.runtimeStatus == RuntimeStatus.Unavailable) {
+ "Initialize the embedded runtime to see meeting notes output here."
+ } else {
+ "Submit a transcript above to run meeting-notes.process."
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ ExecutionPhase.Loading -> Text("Running embedded workflow...")
+ is ExecutionPhase.Failed -> Text("Error: ${phase.error}", color = MaterialTheme.colorScheme.error)
+ is ExecutionPhase.Succeeded -> {
+ OutputFields(phase.output)
+ if (phase.trace.isNotEmpty()) {
+ Button(onClick = { onTraceToggle(!uiState.showTrace) }) {
+ Text("Trace (${phase.trace.size} events)")
+ }
+ if (uiState.showTrace) {
+ phase.trace.forEach { event ->
+ Text("${event.timestamp} - ${event.event_type}", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+ }
+ Button(onClick = onReset) { Text("Reset") }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun OutputFields(output: MeetingNotesOutput) {
+ ActionItemsSection(output.action_items)
+ Spacer(modifier = Modifier.height(8.dp))
+ DecisionsSection(output.decisions)
+ Spacer(modifier = Modifier.height(8.dp))
+ StringListSection("Follow-ups", output.follow_ups)
+ Spacer(modifier = Modifier.height(8.dp))
+ Text("Summary", style = MaterialTheme.typography.titleSmall)
+ Text(output.summary)
+}
+
+@Composable
+private fun ActionItemsSection(items: List) {
+ Text("Action Items", style = MaterialTheme.typography.titleSmall)
+ if (items.isEmpty()) {
+ Text("None recorded", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ return
+ }
+ items.forEach { item ->
+ Text("- ${item.task}")
+ val details = listOfNotNull(item.owner, item.due?.let { "due $it" })
+ if (details.isNotEmpty()) {
+ Text(details.joinToString(" | "), style = MaterialTheme.typography.bodySmall)
+ }
+ }
+}
+
+@Composable
+private fun DecisionsSection(items: List) {
+ Text("Decisions", style = MaterialTheme.typography.titleSmall)
+ if (items.isEmpty()) {
+ Text("None recorded", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ return
+ }
+ items.forEach { item ->
+ Text("- ${item.text}")
+ item.made_by?.let { madeBy ->
+ Text("decided by $madeBy", style = MaterialTheme.typography.bodySmall)
+ }
+ }
+}
+
+@Composable
+private fun StringListSection(label: String, items: List) {
+ Text(label, style = MaterialTheme.typography.titleSmall)
+ if (items.isEmpty()) {
+ Text("None recorded", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ return
+ }
+ items.forEach { item -> Text("- $item") }
+}
+
+@Composable
+private fun StatusDot(status: RuntimeStatus) {
+ val color = when (status) {
+ RuntimeStatus.Ready -> Color(0xFF06B6D4)
+ RuntimeStatus.Unavailable -> Color(0xFFEF4444)
+ RuntimeStatus.Starting -> Color.Gray
+ }
+ Box(
+ modifier = Modifier
+ .size(10.dp)
+ .clip(CircleShape)
+ .background(color),
+ )
+}
+
+private fun statusLabel(status: RuntimeStatus): String = when (status) {
+ RuntimeStatus.Ready -> "Ready"
+ RuntimeStatus.Unavailable -> "Unavailable"
+ RuntimeStatus.Starting -> "Starting..."
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/SettingsScreen.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/SettingsScreen.kt
new file mode 100644
index 0000000..928d6ac
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/SettingsScreen.kt
@@ -0,0 +1,56 @@
+package com.traverseframework.meetingnotes.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.traverseframework.meetingnotes.RuntimeSettings
+import kotlinx.coroutines.launch
+
+@Composable
+fun SettingsScreen(
+ settings: RuntimeSettings,
+ currentWorkspace: String,
+ onBack: () -> Unit,
+) {
+ var workspace by remember(currentWorkspace) { mutableStateOf(currentWorkspace) }
+ val scope = rememberCoroutineScope()
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("Embedded runtime settings")
+ Text("Runtime mode is Embedded - no HTTP sidecar URL.")
+ OutlinedTextField(
+ value = workspace,
+ onValueChange = { workspace = it },
+ label = { Text("Workspace") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(
+ onClick = {
+ scope.launch {
+ settings.setWorkspace(workspace.trim())
+ onBack()
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Save")
+ }
+ }
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/Theme.kt b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/Theme.kt
new file mode 100644
index 0000000..312b369
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/java/com/traverseframework/meetingnotes/ui/Theme.kt
@@ -0,0 +1,13 @@
+package com.traverseframework.meetingnotes.ui
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+
+@Composable
+fun MeetingNotesTheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = lightColorScheme(),
+ content = content,
+ )
+}
diff --git a/apps/meeting-notes/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/meeting-notes/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..99310e9
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/meeting-notes/android-compose/app/src/main/res/values/colors.xml b/apps/meeting-notes/android-compose/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..27c0ce3
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #1E1B4B
+ #8B5CF6
+
diff --git a/apps/meeting-notes/android-compose/app/src/main/res/values/strings.xml b/apps/meeting-notes/android-compose/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..1009c08
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ meeting-notes
+
diff --git a/apps/meeting-notes/android-compose/app/src/main/res/values/themes.xml b/apps/meeting-notes/android-compose/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..bf431c0
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/res/values/themes.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/meeting-notes/android-compose/app/src/main/res/xml/network_security_config.xml b/apps/meeting-notes/android-compose/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..3f1b761
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,8 @@
+
+
+
+ 10.0.2.2
+ 127.0.0.1
+ localhost
+
+
diff --git a/apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/ExecutionUiStateTest.kt b/apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/ExecutionUiStateTest.kt
new file mode 100644
index 0000000..7314397
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/ExecutionUiStateTest.kt
@@ -0,0 +1,19 @@
+package com.traverseframework.meetingnotes
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ExecutionUiStateTest {
+ @Test
+ fun canSubmitRequiresReadyAndTranscript() {
+ val idle = ExecutionUiState(runtimeStatus = RuntimeStatus.Ready, transcript = "meeting transcript")
+ assertTrue(idle.canSubmit)
+
+ val unavailable = ExecutionUiState(runtimeStatus = RuntimeStatus.Unavailable, transcript = "meeting transcript")
+ assertEquals(false, unavailable.canSubmit)
+
+ val empty = ExecutionUiState(runtimeStatus = RuntimeStatus.Ready, transcript = " ")
+ assertEquals(false, empty.canSubmit)
+ }
+}
diff --git a/apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/ExecutionViewModelTest.kt b/apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/ExecutionViewModelTest.kt
new file mode 100644
index 0000000..22ba96b
--- /dev/null
+++ b/apps/meeting-notes/android-compose/app/src/test/java/com/traverseframework/meetingnotes/ExecutionViewModelTest.kt
@@ -0,0 +1,87 @@
+package com.traverseframework.meetingnotes
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ExecutionViewModelTest {
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun canSubmitWhenReadyWithTranscript() = runTest(testDispatcher) {
+ val host = InMemoryMeetingNotesHost.withScriptedOutput(sampleOutput())
+ val vm = ExecutionViewModel(host, FakeRuntimeSettings(), testDispatcher)
+ advanceUntilIdle()
+ vm.updateTranscript("Team agreed on launch tasks")
+ assertTrue(vm.uiState.value.canSubmit)
+ assertEquals(RuntimeStatus.Ready, vm.uiState.value.runtimeStatus)
+ assertEquals(AppConstants.RUNTIME_MODE_EMBEDDED, vm.uiState.value.runtimeMode)
+ }
+
+ @Test
+ fun submitRendersRuntimeOwnedFields() = runTest(testDispatcher) {
+ val host = InMemoryMeetingNotesHost.withScriptedOutput(sampleOutput())
+ val vm = ExecutionViewModel(host, FakeRuntimeSettings(), testDispatcher)
+ advanceUntilIdle()
+ vm.updateTranscript("Launch review transcript")
+ vm.submit()
+ advanceUntilIdle()
+ val phase = vm.uiState.value.phase
+ assertTrue(phase is ExecutionPhase.Succeeded)
+ val output = (phase as ExecutionPhase.Succeeded).output
+ assertEquals("Prepare launch checklist", output.action_items.first().task)
+ assertEquals("Ship the beta on Friday", output.decisions.first().text)
+ assertEquals("Team aligned on beta launch readiness.", output.summary)
+ }
+
+ @Test
+ fun resetReturnsToIdle() = runTest(testDispatcher) {
+ val host = InMemoryMeetingNotesHost.withScriptedOutput(sampleOutput())
+ val vm = ExecutionViewModel(host, FakeRuntimeSettings(), testDispatcher)
+ vm.reset()
+ assertEquals(ExecutionPhase.Idle, vm.uiState.value.phase)
+ }
+}
+
+private fun sampleOutput() = MeetingNotesOutput(
+ action_items = listOf(
+ ActionItem(task = "Prepare launch checklist", owner = "Avery", due = "Friday"),
+ ),
+ decisions = listOf(
+ Decision(text = "Ship the beta on Friday", made_by = "Morgan"),
+ ),
+ follow_ups = listOf("Confirm support rotation"),
+ summary = "Team aligned on beta launch readiness.",
+)
+
+private class FakeRuntimeSettings(
+ workspace: String = AppConstants.DEFAULT_WORKSPACE,
+) : RuntimeSettings {
+ private val _workspace = MutableStateFlow(workspace)
+ override val workspace = _workspace
+
+ override suspend fun setWorkspace(workspace: String) {
+ _workspace.value = workspace
+ }
+}
diff --git a/apps/meeting-notes/android-compose/build.gradle.kts b/apps/meeting-notes/android-compose/build.gradle.kts
new file mode 100644
index 0000000..da0c673
--- /dev/null
+++ b/apps/meeting-notes/android-compose/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+ id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+}
diff --git a/apps/meeting-notes/android-compose/gradle.properties b/apps/meeting-notes/android-compose/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/apps/meeting-notes/android-compose/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/apps/meeting-notes/android-compose/gradle/wrapper/gradle-wrapper.jar b/apps/meeting-notes/android-compose/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/apps/meeting-notes/android-compose/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/apps/meeting-notes/android-compose/gradle/wrapper/gradle-wrapper.properties b/apps/meeting-notes/android-compose/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..df97d72
--- /dev/null
+++ b/apps/meeting-notes/android-compose/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/apps/meeting-notes/android-compose/gradlew b/apps/meeting-notes/android-compose/gradlew
new file mode 100755
index 0000000..5216765
--- /dev/null
+++ b/apps/meeting-notes/android-compose/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a known source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/apps/meeting-notes/android-compose/settings.gradle.kts b/apps/meeting-notes/android-compose/settings.gradle.kts
new file mode 100644
index 0000000..de2aae5
--- /dev/null
+++ b/apps/meeting-notes/android-compose/settings.gradle.kts
@@ -0,0 +1,29 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "meeting-notes-android"
+
+val traverseRepo = System.getenv("TRAVERSE_REPO")
+ ?: rootDir.resolve("../../../../Traverse").takeIf { it.resolve("packages/kotlin/TraverseEmbedder").exists() }?.absolutePath
+ ?: rootDir.resolve("../../../Traverse").takeIf { it.resolve("packages/kotlin/TraverseEmbedder").exists() }?.absolutePath
+
+include(":app")
+
+if (traverseRepo != null) {
+ include(":traverse-embedder")
+ project(":traverse-embedder").projectDir =
+ file("$traverseRepo/packages/kotlin/TraverseEmbedder/traverse-embedder")
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes.xcodeproj/project.pbxproj b/apps/meeting-notes/ios-swift/MeetingNotes.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..0f8225f
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes.xcodeproj/project.pbxproj
@@ -0,0 +1,396 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ A1000000000000000000000B /* MeetingNotesCore in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000000C /* MeetingNotesCore */; };
+ A1000000000000000000000E /* AppSettingsSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000E /* AppSettingsSmokeTests.swift */; };
+ A10000000000000000000001 /* MeetingNotesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* MeetingNotesApp.swift */; };
+ A10000000000000000000002 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* AppSettings.swift */; };
+ A10000000000000000000005 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000005 /* ContentView.swift */; };
+ A10000000000000000000006 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* SettingsView.swift */; };
+ A10000000000000000000007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* Assets.xcassets */; };
+ B10000000000000000000020 /* Resources in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000020 /* Resources */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ A2000000000000000000000E /* AppSettingsSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsSmokeTests.swift; sourceTree = ""; };
+ A20000000000000000000001 /* MeetingNotesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesApp.swift; sourceTree = ""; };
+ A20000000000000000000002 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; };
+ A20000000000000000000005 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ A20000000000000000000006 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
+ A20000000000000000000007 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ B20000000000000000000020 /* Resources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Resources; sourceTree = ""; };
+ A20000000000000000000010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A30000000000000000000001 /* MeetingNotes.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MeetingNotes.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ A30000000000000000000002 /* MeetingNotesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeetingNotesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ A40000000000000000000001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A1000000000000000000000B /* MeetingNotesCore in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A40000000000000000000002 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ A50000000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ A50000000000000000000002 /* MeetingNotes */,
+ A50000000000000000000003 /* MeetingNotesTests */,
+ A50000000000000000000004 /* Products */,
+ );
+ sourceTree = "";
+ };
+ A50000000000000000000002 /* MeetingNotes */ = {
+ isa = PBXGroup;
+ children = (
+ A20000000000000000000001 /* MeetingNotesApp.swift */,
+ A20000000000000000000002 /* AppSettings.swift */,
+ A20000000000000000000005 /* ContentView.swift */,
+ A20000000000000000000006 /* SettingsView.swift */,
+ A20000000000000000000007 /* Assets.xcassets */,
+ B20000000000000000000020 /* Resources */,
+ A20000000000000000000010 /* Info.plist */,
+ );
+ path = MeetingNotes;
+ sourceTree = "";
+ };
+ A50000000000000000000003 /* MeetingNotesTests */ = {
+ isa = PBXGroup;
+ children = (
+ A2000000000000000000000E /* AppSettingsSmokeTests.swift */,
+ );
+ path = MeetingNotesTests;
+ sourceTree = "";
+ };
+ A50000000000000000000004 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ A30000000000000000000001 /* MeetingNotes.app */,
+ A30000000000000000000002 /* MeetingNotesTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ A60000000000000000000001 /* MeetingNotes */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A80000000000000000000001 /* Build configuration list for PBXNativeTarget "MeetingNotes" */;
+ buildPhases = (
+ A70000000000000000000001 /* Sources */,
+ A40000000000000000000001 /* Frameworks */,
+ A70000000000000000000002 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = MeetingNotes;
+ packageProductDependencies = (
+ A1000000000000000000000C /* MeetingNotesCore */,
+ );
+ productName = MeetingNotes;
+ productReference = A30000000000000000000001 /* MeetingNotes.app */;
+ productType = "com.apple.product-type.application";
+ };
+ A60000000000000000000002 /* MeetingNotesTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A80000000000000000000002 /* Build configuration list for PBXNativeTarget "MeetingNotesTests" */;
+ buildPhases = (
+ A70000000000000000000003 /* Sources */,
+ A40000000000000000000002 /* Frameworks */,
+ A70000000000000000000004 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ A90000000000000000000001 /* PBXTargetDependency */,
+ );
+ name = MeetingNotesTests;
+ productName = MeetingNotesTests;
+ productReference = A30000000000000000000002 /* MeetingNotesTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ AA0000000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1600;
+ LastUpgradeCheck = 1600;
+ TargetAttributes = {
+ A60000000000000000000001 = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ A60000000000000000000002 = {
+ CreatedOnToolsVersion = 16.0;
+ TestTargetID = A60000000000000000000001;
+ };
+ };
+ };
+ buildConfigurationList = A80000000000000000000003 /* Build configuration list for PBXProject "MeetingNotes" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = A50000000000000000000001;
+ packageReferences = (
+ A1000000000000000000000D /* XCLocalSwiftPackageReference "../MeetingNotesCore" */,
+ );
+ productRefGroup = A50000000000000000000004 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ A60000000000000000000001 /* MeetingNotes */,
+ A60000000000000000000002 /* MeetingNotesTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ A70000000000000000000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000007 /* Assets.xcassets in Resources */,
+ B10000000000000000000020 /* Resources in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A70000000000000000000004 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ A70000000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000001 /* MeetingNotesApp.swift in Sources */,
+ A10000000000000000000002 /* AppSettings.swift in Sources */,
+ A10000000000000000000005 /* ContentView.swift in Sources */,
+ A10000000000000000000006 /* SettingsView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A70000000000000000000003 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A1000000000000000000000E /* AppSettingsSmokeTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ A90000000000000000000001 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = A60000000000000000000001 /* MeetingNotes */;
+ targetProxy = A90000000000000000000002 /* PBXContainerItemProxy */;
+ };
+ A90000000000000000000002 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = AA0000000000000000000001 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = A60000000000000000000001;
+ remoteInfo = MeetingNotes;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ AB0000000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_TESTABILITY = YES;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ AB0000000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_VERSION = 5.0;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ AB0000000000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeetingNotes/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.docapproval;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ AB0000000000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeetingNotes/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.docapproval;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+ SUPPORTS_MACCATALYST = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ AB0000000000000000000005 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.docapproval.tests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MeetingNotes.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MeetingNotes";
+ };
+ name = Debug;
+ };
+ AB0000000000000000000006 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.docapproval.tests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MeetingNotes.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MeetingNotes";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ A80000000000000000000001 /* Build configuration list for PBXNativeTarget "MeetingNotes" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AB0000000000000000000003 /* Debug */,
+ AB0000000000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A80000000000000000000002 /* Build configuration list for PBXNativeTarget "MeetingNotesTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AB0000000000000000000005 /* Debug */,
+ AB0000000000000000000006 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ A80000000000000000000003 /* Build configuration list for PBXProject "MeetingNotes" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ AB0000000000000000000001 /* Debug */,
+ AB0000000000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ A1000000000000000000000D /* XCLocalSwiftPackageReference "../MeetingNotesCore" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../MeetingNotesCore;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ A1000000000000000000000C /* MeetingNotesCore */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = A1000000000000000000000D /* XCLocalSwiftPackageReference "../MeetingNotesCore" */;
+ productName = MeetingNotesCore;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = AA0000000000000000000001 /* Project object */;
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes.xcodeproj/xcshareddata/xcschemes/MeetingNotes.xcscheme b/apps/meeting-notes/ios-swift/MeetingNotes.xcodeproj/xcshareddata/xcschemes/MeetingNotes.xcscheme
new file mode 100644
index 0000000..34a010f
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes.xcodeproj/xcshareddata/xcschemes/MeetingNotes.xcscheme
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/AppSettings.swift b/apps/meeting-notes/ios-swift/MeetingNotes/AppSettings.swift
new file mode 100644
index 0000000..6eb5866
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/AppSettings.swift
@@ -0,0 +1,33 @@
+import Foundation
+import MeetingNotesCore
+
+@MainActor
+final class AppSettings: ObservableObject {
+ static let appId = "meeting-notes"
+ static let transcriptMaxLength = 5_000
+ static let defaultWorkspace = "local-default"
+
+ private enum Keys {
+ static let workspace = "meetingNotesWorkspace"
+ static let bundlePath = "meetingNotesBundlePath"
+ }
+
+ @Published var workspace: String {
+ didSet { UserDefaults.standard.set(workspace, forKey: Keys.workspace) }
+ }
+
+ @Published var bundlePath: String {
+ didSet { UserDefaults.standard.set(bundlePath, forKey: Keys.bundlePath) }
+ }
+
+ init(userDefaults: UserDefaults = .standard) {
+ workspace = userDefaults.string(forKey: Keys.workspace) ?? Self.defaultWorkspace
+ bundlePath = userDefaults.string(forKey: Keys.bundlePath) ?? ""
+ }
+
+ var bundleURL: URL? {
+ let trimmed = bundlePath.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ return URL(fileURLWithPath: trimmed)
+ }
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/meeting-notes/ios-swift/MeetingNotes/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..13613e3
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/Assets.xcassets/Contents.json b/apps/meeting-notes/ios-swift/MeetingNotes/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/ContentView.swift b/apps/meeting-notes/ios-swift/MeetingNotes/ContentView.swift
new file mode 100644
index 0000000..3eeb8f8
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/ContentView.swift
@@ -0,0 +1,210 @@
+import SwiftUI
+import MeetingNotesCore
+
+struct ContentView: View {
+ @EnvironmentObject private var settings: AppSettings
+ @EnvironmentObject private var viewModel: AppStateViewModel
+ @State private var showSettings = false
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ runtimeSection
+ inputSection
+ outputSection
+ }
+ .padding()
+ }
+ .navigationTitle("meeting-notes")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("Settings") { showSettings = true }
+ }
+ }
+ .sheet(isPresented: $showSettings) {
+ SettingsView()
+ .environmentObject(settings)
+ }
+ }
+ }
+
+ private var runtimeSection: some View {
+ GroupBox("Runtime Environment") {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(viewModel.runtimeMode)
+ .font(.headline)
+ HStack {
+ Circle()
+ .fill(statusColor)
+ .frame(width: 10, height: 10)
+ Text(statusLabel)
+ Spacer()
+ }
+ Text("workspace: \(settings.workspace)")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Text("workflow: \(viewModel.workflowId)")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+
+ private var inputSection: some View {
+ GroupBox("Submit Transcript") {
+ VStack(alignment: .leading, spacing: 12) {
+ TextEditor(text: $viewModel.transcript)
+ .frame(minHeight: 120)
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(.quaternary))
+ Text("\(viewModel.transcript.count)/\(AppSettings.transcriptMaxLength)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Button(action: { viewModel.submit() }) {
+ Text(viewModel.isRunning ? "Processing…" : "Process Transcript")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(!viewModel.canSubmit)
+ if viewModel.runtimeStatus == .unavailable {
+ Text("Embedded runtime unavailable — run scripts/ci/sync_swift_meeting_notes_bundle.sh")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var outputSection: some View {
+ GroupBox("Meeting Notes Output") {
+ VStack(alignment: .leading, spacing: 12) {
+ if let error = viewModel.errorMessage, viewModel.currentState == "error" {
+ Text("Error: \(error)")
+ .foregroundStyle(.red)
+ }
+
+ switch viewModel.currentState {
+ case "idle":
+ if viewModel.runtimeStatus == .unavailable {
+ Text("Embedded runtime unavailable — sync the Swift bundle to see output here.")
+ .foregroundStyle(.secondary)
+ } else if viewModel.errorMessage == nil {
+ Text("Submit a transcript above to run meeting-notes.process.")
+ .foregroundStyle(.secondary)
+ }
+ case "processing":
+ Text("Processing…")
+ case "error":
+ Text("Error: \(viewModel.errorMessage ?? "execution failed")")
+ .foregroundStyle(.red)
+ Button("Reset") { viewModel.resetLocal() }
+ .buttonStyle(.bordered)
+ case "completed", "results":
+ if let output = viewModel.output {
+ outputFields(output)
+ }
+ if !viewModel.trace.isEmpty {
+ DisclosureGroup("Trace (\(viewModel.trace.count) events)", isExpanded: $viewModel.showTrace) {
+ ForEach(Array(viewModel.trace.enumerated()), id: \.offset) { _, event in
+ VStack(alignment: .leading, spacing: 4) {
+ Text("\(event.timestamp) · \(event.event_type)")
+ .font(.caption.monospaced())
+ if let data = event.data {
+ Text(String(describing: data))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ Button("Reset") { viewModel.resetLocal() }
+ .buttonStyle(.bordered)
+ default:
+ Text("State: \(viewModel.currentState)")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+
+ private func outputFields(_ output: MeetingNotesOutput) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ labeledField("Summary", output.summary.isEmpty ? "None recorded" : output.summary)
+ sectionList("Action Items") {
+ if output.actionItems.isEmpty {
+ Text("None recorded").foregroundStyle(.secondary)
+ } else {
+ ForEach(Array(output.actionItems.enumerated()), id: \.offset) { _, item in
+ VStack(alignment: .leading, spacing: 2) {
+ Text("• \(item.task)")
+ HStack(spacing: 8) {
+ if let owner = item.owner { Text(owner).font(.caption).foregroundStyle(.secondary) }
+ if let due = item.due { Text("due \(due)").font(.caption).foregroundStyle(.secondary) }
+ }
+ }
+ }
+ }
+ }
+ sectionList("Decisions") {
+ if output.decisions.isEmpty {
+ Text("None recorded").foregroundStyle(.secondary)
+ } else {
+ ForEach(Array(output.decisions.enumerated()), id: \.offset) { _, item in
+ Text("• \(item.text)\(item.madeBy.map { " — decided by \($0)" } ?? "")")
+ }
+ }
+ }
+ sectionList("Follow-ups") {
+ if output.followUps.isEmpty {
+ Text("None recorded").foregroundStyle(.secondary)
+ } else {
+ ForEach(Array(output.followUps.enumerated()), id: \.offset) { _, item in
+ Text("• \(item)")
+ }
+ }
+ }
+ }
+ }
+
+ private func labeledField(_ label: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(label).font(.caption).foregroundStyle(.secondary)
+ Text(value)
+ }
+ }
+
+ private func sectionList(_ label: String, @ViewBuilder content: () -> Content) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(label).font(.caption).foregroundStyle(.secondary)
+ content()
+ }
+ }
+
+ private var statusColor: Color {
+ switch viewModel.runtimeStatus {
+ case .ready: return .cyan
+ case .unavailable: return .red
+ case .starting: return .gray
+ }
+ }
+
+ private var statusLabel: String {
+ switch viewModel.runtimeStatus {
+ case .ready: return "Ready"
+ case .unavailable: return "Unavailable"
+ case .starting: return "Starting…"
+ }
+ }
+}
+
+#Preview {
+ let settings = AppSettings()
+ ContentView()
+ .environmentObject(settings)
+ .environmentObject(AppStateViewModel(host: nil, workspaceId: settings.workspace))
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/Info.plist b/apps/meeting-notes/ios-swift/MeetingNotes/Info.plist
new file mode 100644
index 0000000..53255b2
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/Info.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ UILaunchScreen
+
+
+
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/MeetingNotesApp.swift b/apps/meeting-notes/ios-swift/MeetingNotes/MeetingNotesApp.swift
new file mode 100644
index 0000000..c0f0359
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/MeetingNotesApp.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+import MeetingNotesCore
+
+@main
+struct MeetingNotesApp: App {
+ @StateObject private var settings = AppSettings()
+ @StateObject private var viewModel: AppStateViewModel
+
+ init() {
+ let settings = AppSettings()
+ _settings = StateObject(wrappedValue: settings)
+ let host = EmbeddedHost.tryCreateProduction(
+ bundleRoot: settings.bundleURL,
+ workspaceId: settings.workspace
+ )
+ _viewModel = StateObject(wrappedValue: AppStateViewModel(
+ host: host,
+ workspaceId: settings.workspace,
+ appId: AppSettings.appId,
+ transcriptMaxLength: AppSettings.transcriptMaxLength
+ ))
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environmentObject(settings)
+ .environmentObject(viewModel)
+ .onChange(of: settings.workspace) { _, workspace in
+ viewModel.updateWorkspace(workspace)
+ }
+ }
+ }
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/app.manifest.json b/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/app.manifest.json
new file mode 100644
index 0000000..06486a2
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/app.manifest.json
@@ -0,0 +1,117 @@
+{
+ "app_id": "meeting-notes",
+ "version": "1.0.0",
+ "schema_version": "1.0.0",
+ "workspace_defaults": {
+ "workspace_id": "local-default",
+ "registry_scope": "private"
+ },
+ "components": [
+ {
+ "component_id": "meeting-notes.process-component",
+ "version": "1.0.0",
+ "digest": "sha256:5647c39a1d25d8728350f9619025292a62e78a602068a2ad9b6f075751c93d99",
+ "manifest_path": "components/process/component.manifest.json"
+ }
+ ],
+ "workflows": [
+ {
+ "workflow_id": "meeting-notes.process",
+ "workflow_version": "1.0.0",
+ "path": "_traverse/workflows/examples/meeting-notes/process/workflow.json"
+ }
+ ],
+ "model_dependencies": [],
+ "config_schema": {
+ "type": "object",
+ "required": [
+ "workspace_id"
+ ],
+ "properties": {
+ "workspace_id": {
+ "type": "string"
+ },
+ "processing_mode": {
+ "type": "string",
+ "enum": [
+ "deterministic"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "default_config": {
+ "workspace_id": "local-default",
+ "processing_mode": "deterministic"
+ },
+ "placement_policy": {
+ "preferred_targets": [
+ "local"
+ ],
+ "allow_fallback": false
+ },
+ "public_surfaces": [
+ "cli",
+ "http_json"
+ ],
+ "state_machine": {
+ "initial_state": "idle",
+ "list_context_fields": [
+ "output.action_items",
+ "output.decisions",
+ "output.follow_ups",
+ "output.summary"
+ ],
+ "states": [
+ {
+ "id": "idle",
+ "transitions": [
+ {
+ "on": "submit",
+ "to": "processing"
+ }
+ ]
+ },
+ {
+ "id": "processing",
+ "invoke": {
+ "capability_id": "meeting-notes.process",
+ "input_from": "command.payload"
+ },
+ "transitions": [
+ {
+ "on": "capability_succeeded",
+ "to": "results"
+ },
+ {
+ "on": "capability_failed",
+ "to": "error"
+ }
+ ]
+ },
+ {
+ "id": "results",
+ "transitions": [
+ {
+ "on": "reset",
+ "to": "idle"
+ }
+ ]
+ },
+ {
+ "id": "error",
+ "transitions": [
+ {
+ "on": "retry",
+ "to": "processing",
+ "with_last_payload": true
+ },
+ {
+ "on": "reset",
+ "to": "idle"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/components/process/component.manifest.json b/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/components/process/component.manifest.json
new file mode 100644
index 0000000..dec21bd
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/components/process/component.manifest.json
@@ -0,0 +1,30 @@
+{
+ "component_id": "meeting-notes.process-component",
+ "version": "1.0.0",
+ "schema_version": "1.0.0",
+ "capability_id": "meeting-notes.process",
+ "capability_version": "1.0.0",
+ "registry_ref": {
+ "namespace": "meeting-notes",
+ "id": "meeting-notes.process",
+ "version_range": "^1.0.0"
+ },
+ "runtime_constraints": {
+ "host_api_access": "none",
+ "network_access": "forbidden",
+ "filesystem_access": "none"
+ },
+ "permitted_targets": [
+ "local",
+ "device"
+ ],
+ "dependencies": [],
+ "connector_requirements": [],
+ "validation_evidence": [
+ {
+ "evidence_type": "checked_in_fixture",
+ "status": "passed",
+ "produced_by": "meeting_notes_example_smoke"
+ }
+ ]
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/runtime/runtime-release.json b/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/runtime/runtime-release.json
new file mode 100644
index 0000000..b3354d7
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/runtime/runtime-release.json
@@ -0,0 +1 @@
+{"runtime_version":"0.8.1","bridge_version":"1.1.0","bridge_abi_version":10100,"sha256":"aa801023ba4eb20b8c1b4004bdd964a78fed9540478b252b77eac04c80811852"}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/runtime/runtime.wasm b/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/runtime/runtime.wasm
new file mode 100644
index 0000000..9587c31
Binary files /dev/null and b/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/runtime/runtime.wasm differ
diff --git a/apps/meeting-notes/ios-swift/MeetingNotes/SettingsView.swift b/apps/meeting-notes/ios-swift/MeetingNotes/SettingsView.swift
new file mode 100644
index 0000000..df9927d
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotes/SettingsView.swift
@@ -0,0 +1,37 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @EnvironmentObject private var settings: AppSettings
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Embedded runtime") {
+ TextField("Workspace", text: $settings.workspace)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Bundle path (optional)", text: $settings.bundlePath)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+ Section {
+ Text("Embedded mode uses the bundled runtime/runtime.wasm. Restart after changing the bundle path.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle("Settings")
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") { dismiss() }
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ SettingsView()
+ .environmentObject(AppSettings())
+}
diff --git a/apps/meeting-notes/ios-swift/MeetingNotesTests/AppSettingsSmokeTests.swift b/apps/meeting-notes/ios-swift/MeetingNotesTests/AppSettingsSmokeTests.swift
new file mode 100644
index 0000000..65bd646
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/MeetingNotesTests/AppSettingsSmokeTests.swift
@@ -0,0 +1,13 @@
+import XCTest
+@testable import MeetingNotes
+
+final class AppSettingsSmokeTests: XCTestCase {
+ @MainActor
+ func testDefaults() {
+ let defaults = UserDefaults(suiteName: "meeting-notes-smoke-\(UUID().uuidString)")!
+ let settings = AppSettings(userDefaults: defaults)
+ XCTAssertEqual(settings.workspace, AppSettings.defaultWorkspace)
+ XCTAssertEqual(AppSettings.appId, "meeting-notes")
+ XCTAssertTrue(settings.bundlePath.isEmpty)
+ }
+}
diff --git a/apps/meeting-notes/ios-swift/README.md b/apps/meeting-notes/ios-swift/README.md
new file mode 100644
index 0000000..0049bad
--- /dev/null
+++ b/apps/meeting-notes/ios-swift/README.md
@@ -0,0 +1,22 @@
+# meeting-notes (iOS SwiftUI)
+
+**Runtime mode: Embedded** — in-process `TraverseEmbedder` (Swift) loads digest-pinned `runtime/runtime.wasm`. No `traverse-cli serve` required.
+
+## Sync bundle
+
+```bash
+export TRAVERSE_REPO=/path/to/Traverse
+bash scripts/ci/sync_swift_meeting_notes_bundle.sh
+```
+
+Destination: `MeetingNotes/Resources/bundles/meeting-notes/`
+
+## Build / run
+
+Open `MeetingNotes.xcodeproj` in Xcode → Run (Simulator).
+
+Shared host + tests: [`../MeetingNotesCore/`](../MeetingNotesCore/)
+
+```bash
+cd apps/meeting-notes/MeetingNotesCore && swift test
+```
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac.xcodeproj/project.pbxproj b/apps/meeting-notes/macos-swift/MeetingNotesMac.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..fd824e2
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac.xcodeproj/project.pbxproj
@@ -0,0 +1,395 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ B1000000000000000000000B /* MeetingNotesCore in Frameworks */ = {isa = PBXBuildFile; productRef = B1000000000000000000000C /* MeetingNotesCore */; };
+ B1000000000000000000000E /* AppSettingsSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2000000000000000000000E /* AppSettingsSmokeTests.swift */; };
+ B10000000000000000000001 /* MeetingNotesMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000001 /* MeetingNotesMacApp.swift */; };
+ B10000000000000000000002 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000002 /* AppSettings.swift */; };
+ B10000000000000000000005 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000005 /* ContentView.swift */; };
+ B10000000000000000000006 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000006 /* PreferencesView.swift */; };
+ B10000000000000000000007 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000007 /* AppDelegate.swift */; };
+ B10000000000000000000008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000008 /* Assets.xcassets */; };
+ B10000000000000000000020 /* Resources in Resources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000020 /* Resources */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ B2000000000000000000000E /* AppSettingsSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsSmokeTests.swift; sourceTree = ""; };
+ B20000000000000000000001 /* MeetingNotesMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeetingNotesMacApp.swift; sourceTree = ""; };
+ B20000000000000000000002 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; };
+ B20000000000000000000005 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ B20000000000000000000006 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; };
+ B20000000000000000000007 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ B20000000000000000000008 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ B20000000000000000000020 /* Resources */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Resources; sourceTree = ""; };
+ B20000000000000000000011 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ B30000000000000000000001 /* MeetingNotesMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MeetingNotesMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ B30000000000000000000002 /* MeetingNotesMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MeetingNotesMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ B40000000000000000000001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B1000000000000000000000B /* MeetingNotesCore in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ B40000000000000000000002 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ B50000000000000000000001 = {
+ isa = PBXGroup;
+ children = (
+ B50000000000000000000002 /* MeetingNotesMac */,
+ B50000000000000000000003 /* MeetingNotesMacTests */,
+ B50000000000000000000004 /* Products */,
+ );
+ sourceTree = "";
+ };
+ B50000000000000000000002 /* MeetingNotesMac */ = {
+ isa = PBXGroup;
+ children = (
+ B20000000000000000000001 /* MeetingNotesMacApp.swift */,
+ B20000000000000000000007 /* AppDelegate.swift */,
+ B20000000000000000000002 /* AppSettings.swift */,
+ B20000000000000000000005 /* ContentView.swift */,
+ B20000000000000000000006 /* PreferencesView.swift */,
+ B20000000000000000000008 /* Assets.xcassets */,
+ B20000000000000000000020 /* Resources */,
+ B20000000000000000000011 /* Info.plist */,
+ );
+ path = MeetingNotesMac;
+ sourceTree = "";
+ };
+ B50000000000000000000003 /* MeetingNotesMacTests */ = {
+ isa = PBXGroup;
+ children = (
+ B2000000000000000000000E /* AppSettingsSmokeTests.swift */,
+ );
+ path = MeetingNotesMacTests;
+ sourceTree = "";
+ };
+ B50000000000000000000004 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ B30000000000000000000001 /* MeetingNotesMac.app */,
+ B30000000000000000000002 /* MeetingNotesMacTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ B60000000000000000000001 /* MeetingNotesMac */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = B80000000000000000000001 /* Build configuration list for PBXNativeTarget "MeetingNotesMac" */;
+ buildPhases = (
+ B70000000000000000000001 /* Sources */,
+ B40000000000000000000001 /* Frameworks */,
+ B70000000000000000000002 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = MeetingNotesMac;
+ packageProductDependencies = (
+ B1000000000000000000000C /* MeetingNotesCore */,
+ );
+ productName = MeetingNotesMac;
+ productReference = B30000000000000000000001 /* MeetingNotesMac.app */;
+ productType = "com.apple.product-type.application";
+ };
+ B60000000000000000000002 /* MeetingNotesMacTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = B80000000000000000000002 /* Build configuration list for PBXNativeTarget "MeetingNotesMacTests" */;
+ buildPhases = (
+ B70000000000000000000003 /* Sources */,
+ B40000000000000000000002 /* Frameworks */,
+ B70000000000000000000004 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ B90000000000000000000001 /* PBXTargetDependency */,
+ );
+ name = MeetingNotesMacTests;
+ productName = MeetingNotesMacTests;
+ productReference = B30000000000000000000002 /* MeetingNotesMacTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ BA0000000000000000000001 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1600;
+ LastUpgradeCheck = 1600;
+ TargetAttributes = {
+ B60000000000000000000001 = {
+ CreatedOnToolsVersion = 16.0;
+ };
+ B60000000000000000000002 = {
+ CreatedOnToolsVersion = 16.0;
+ TestTargetID = B60000000000000000000001;
+ };
+ };
+ };
+ buildConfigurationList = B80000000000000000000003 /* Build configuration list for PBXProject "MeetingNotesMac" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = B50000000000000000000001;
+ packageReferences = (
+ B1000000000000000000000D /* XCLocalSwiftPackageReference "../MeetingNotesCore" */,
+ );
+ productRefGroup = B50000000000000000000004 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ B60000000000000000000001 /* MeetingNotesMac */,
+ B60000000000000000000002 /* MeetingNotesMacTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ B70000000000000000000002 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B10000000000000000000008 /* Assets.xcassets in Resources */,
+ B10000000000000000000020 /* Resources in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ B70000000000000000000004 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ B70000000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B10000000000000000000001 /* MeetingNotesMacApp.swift in Sources */,
+ B10000000000000000000007 /* AppDelegate.swift in Sources */,
+ B10000000000000000000002 /* AppSettings.swift in Sources */,
+ B10000000000000000000005 /* ContentView.swift in Sources */,
+ B10000000000000000000006 /* PreferencesView.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ B70000000000000000000003 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ B1000000000000000000000E /* AppSettingsSmokeTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ B90000000000000000000001 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = B60000000000000000000001 /* MeetingNotesMac */;
+ targetProxy = B90000000000000000000002 /* PBXContainerItemProxy */;
+ };
+ B90000000000000000000002 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = BA0000000000000000000001 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = B60000000000000000000001;
+ remoteInfo = MeetingNotesMac;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ BB0000000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_TESTABILITY = YES;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ BB0000000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_VERSION = 5.0;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ BB0000000000000000000003 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_ENTITLEMENTS = "";
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeetingNotesMac/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.docapproval.mac;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ };
+ name = Debug;
+ };
+ BB0000000000000000000004 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeetingNotesMac/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.docapproval.mac;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ };
+ name = Release;
+ };
+ BB0000000000000000000005 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.docapproval.mac.tests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MeetingNotesMac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MeetingNotesMac";
+ };
+ name = Debug;
+ };
+ BB0000000000000000000006 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = framework.traverse.reference.docapproval.mac.tests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MeetingNotesMac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MeetingNotesMac";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ B80000000000000000000001 /* Build configuration list for PBXNativeTarget "MeetingNotesMac" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ BB0000000000000000000003 /* Debug */,
+ BB0000000000000000000004 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ B80000000000000000000002 /* Build configuration list for PBXNativeTarget "MeetingNotesMacTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ BB0000000000000000000005 /* Debug */,
+ BB0000000000000000000006 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ B80000000000000000000003 /* Build configuration list for PBXProject "MeetingNotesMac" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ BB0000000000000000000001 /* Debug */,
+ BB0000000000000000000002 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ B1000000000000000000000D /* XCLocalSwiftPackageReference "../MeetingNotesCore" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = ../MeetingNotesCore;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ B1000000000000000000000C /* MeetingNotesCore */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = B1000000000000000000000D /* XCLocalSwiftPackageReference "../MeetingNotesCore" */;
+ productName = MeetingNotesCore;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = BA0000000000000000000001 /* Project object */;
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac.xcodeproj/xcshareddata/xcschemes/MeetingNotesMac.xcscheme b/apps/meeting-notes/macos-swift/MeetingNotesMac.xcodeproj/xcshareddata/xcschemes/MeetingNotesMac.xcscheme
new file mode 100644
index 0000000..4bc9f41
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac.xcodeproj/xcshareddata/xcschemes/MeetingNotesMac.xcscheme
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/AppDelegate.swift b/apps/meeting-notes/macos-swift/MeetingNotesMac/AppDelegate.swift
new file mode 100644
index 0000000..f07acae
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/AppDelegate.swift
@@ -0,0 +1,11 @@
+import AppKit
+
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ NSApp.setActivationPolicy(.regular)
+ }
+
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ true
+ }
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/AppSettings.swift b/apps/meeting-notes/macos-swift/MeetingNotesMac/AppSettings.swift
new file mode 100644
index 0000000..6eb5866
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/AppSettings.swift
@@ -0,0 +1,33 @@
+import Foundation
+import MeetingNotesCore
+
+@MainActor
+final class AppSettings: ObservableObject {
+ static let appId = "meeting-notes"
+ static let transcriptMaxLength = 5_000
+ static let defaultWorkspace = "local-default"
+
+ private enum Keys {
+ static let workspace = "meetingNotesWorkspace"
+ static let bundlePath = "meetingNotesBundlePath"
+ }
+
+ @Published var workspace: String {
+ didSet { UserDefaults.standard.set(workspace, forKey: Keys.workspace) }
+ }
+
+ @Published var bundlePath: String {
+ didSet { UserDefaults.standard.set(bundlePath, forKey: Keys.bundlePath) }
+ }
+
+ init(userDefaults: UserDefaults = .standard) {
+ workspace = userDefaults.string(forKey: Keys.workspace) ?? Self.defaultWorkspace
+ bundlePath = userDefaults.string(forKey: Keys.bundlePath) ?? ""
+ }
+
+ var bundleURL: URL? {
+ let trimmed = bundlePath.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ return URL(fileURLWithPath: trimmed)
+ }
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/meeting-notes/macos-swift/MeetingNotesMac/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..13613e3
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/Assets.xcassets/Contents.json b/apps/meeting-notes/macos-swift/MeetingNotesMac/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/ContentView.swift b/apps/meeting-notes/macos-swift/MeetingNotesMac/ContentView.swift
new file mode 100644
index 0000000..46fb333
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/ContentView.swift
@@ -0,0 +1,186 @@
+import SwiftUI
+import MeetingNotesCore
+
+struct ContentView: View {
+ @EnvironmentObject private var settings: AppSettings
+ @EnvironmentObject private var viewModel: AppStateViewModel
+
+ var body: some View {
+ NavigationSplitView {
+ sidebar
+ } detail: {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ inputSection
+ outputSection
+ }
+ .padding(24)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .frame(minWidth: 720, minHeight: 560)
+ .toolbar {
+ ToolbarItem(placement: .automatic) {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(statusColor)
+ .frame(width: 10, height: 10)
+ Text(statusLabel)
+ Text(viewModel.runtimeMode)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ private var sidebar: some View {
+ List {
+ Section("Runtime Environment") {
+ LabeledContent("Mode", value: viewModel.runtimeMode)
+ LabeledContent("Status", value: statusLabel)
+ LabeledContent("Workspace", value: settings.workspace)
+ LabeledContent("Workflow", value: viewModel.workflowId)
+ }
+ }
+ .navigationSplitViewColumnWidth(min: 200, ideal: 220)
+ }
+
+ private var inputSection: some View {
+ GroupBox("Submit Transcript") {
+ VStack(alignment: .leading, spacing: 12) {
+ TextEditor(text: $viewModel.transcript)
+ .font(.body)
+ .frame(minHeight: 140)
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))
+ Text("\(viewModel.transcript.count)/\(AppSettings.transcriptMaxLength)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ HStack {
+ Button(viewModel.isRunning ? "Processing…" : "Process Transcript") { viewModel.submit() }
+ .keyboardShortcut(.return, modifiers: .command)
+ .disabled(!viewModel.canSubmit)
+ Button("Reset") { viewModel.resetLocal() }
+ .keyboardShortcut("r", modifiers: .command)
+ }
+ if viewModel.runtimeStatus == .unavailable {
+ Text("Embedded runtime unavailable — run scripts/ci/sync_swift_meeting_notes_bundle.sh")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var outputSection: some View {
+ GroupBox("Meeting Notes Output") {
+ VStack(alignment: .leading, spacing: 12) {
+ if let error = viewModel.errorMessage, viewModel.currentState == "error" {
+ Text("Error: \(error)")
+ .foregroundStyle(.red)
+ }
+
+ switch viewModel.currentState {
+ case "idle":
+ if viewModel.runtimeStatus == .unavailable {
+ Text("Embedded runtime unavailable — sync the Swift bundle to see output here.")
+ .foregroundStyle(.secondary)
+ } else if viewModel.errorMessage == nil {
+ Text("Submit a transcript above to run meeting-notes.process (⌘↩).")
+ .foregroundStyle(.secondary)
+ }
+ case "processing":
+ Text("Processing…")
+ case "error":
+ Text("Error: \(viewModel.errorMessage ?? "execution failed")")
+ .foregroundStyle(.red)
+ case "completed", "results":
+ if let output = viewModel.output {
+ outputFields(output)
+ }
+ if !viewModel.trace.isEmpty {
+ DisclosureGroup("Trace (\(viewModel.trace.count) events)", isExpanded: $viewModel.showTrace) {
+ ForEach(Array(viewModel.trace.enumerated()), id: \.offset) { _, event in
+ VStack(alignment: .leading, spacing: 4) {
+ Text("\(event.timestamp) · \(event.event_type)")
+ .font(.caption.monospaced())
+ if let data = event.data {
+ Text(String(describing: data))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+ }
+ }
+ default:
+ Text("State: \(viewModel.currentState)")
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+
+ private func outputFields(_ output: MeetingNotesOutput) -> some View {
+ VStack(alignment: .leading, spacing: 12) {
+ labeled("Summary", output.summary.isEmpty ? "None recorded" : output.summary)
+ Text("Action Items").font(.headline)
+ if output.actionItems.isEmpty {
+ Text("None recorded").foregroundStyle(.secondary)
+ } else {
+ ForEach(Array(output.actionItems.enumerated()), id: \.offset) { _, item in
+ Text("• \(item.task)\(item.owner.map { " (\($0))" } ?? "")\(item.due.map { " — due \($0)" } ?? "")")
+ }
+ }
+ Text("Decisions").font(.headline)
+ if output.decisions.isEmpty {
+ Text("None recorded").foregroundStyle(.secondary)
+ } else {
+ ForEach(Array(output.decisions.enumerated()), id: \.offset) { _, item in
+ Text("• \(item.text)\(item.madeBy.map { " — decided by \($0)" } ?? "")")
+ }
+ }
+ Text("Follow-ups").font(.headline)
+ if output.followUps.isEmpty {
+ Text("None recorded").foregroundStyle(.secondary)
+ } else {
+ ForEach(Array(output.followUps.enumerated()), id: \.offset) { _, item in
+ Text("• \(item)")
+ }
+ }
+ }
+ }
+
+ private func labeled(_ label: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(label).font(.caption).foregroundStyle(.secondary)
+ Text(value)
+ }
+ }
+
+ private var statusColor: Color {
+ switch viewModel.runtimeStatus {
+ case .ready: return .cyan
+ case .unavailable: return .red
+ case .starting: return .gray
+ }
+ }
+
+ private var statusLabel: String {
+ switch viewModel.runtimeStatus {
+ case .ready: return "Ready"
+ case .unavailable: return "Unavailable"
+ case .starting: return "Starting…"
+ }
+ }
+}
+
+#Preview {
+ let settings = AppSettings()
+ ContentView()
+ .environmentObject(settings)
+ .environmentObject(AppStateViewModel(host: nil, workspaceId: settings.workspace))
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/Info.plist b/apps/meeting-notes/macos-swift/MeetingNotesMac/Info.plist
new file mode 100644
index 0000000..0c8dde8
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/Info.plist
@@ -0,0 +1,33 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ NSHumanReadableCopyright
+ Copyright © 2026 traverse-framework. All rights reserved.
+ NSPrincipalClass
+ NSApplication
+
+
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/MeetingNotesMacApp.swift b/apps/meeting-notes/macos-swift/MeetingNotesMac/MeetingNotesMacApp.swift
new file mode 100644
index 0000000..668e037
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/MeetingNotesMacApp.swift
@@ -0,0 +1,68 @@
+import SwiftUI
+import MeetingNotesCore
+
+private struct AppStateViewModelKey: FocusedValueKey {
+ typealias Value = AppStateViewModel
+}
+
+extension FocusedValues {
+ var appStateViewModel: AppStateViewModel? {
+ get { self[AppStateViewModelKey.self] }
+ set { self[AppStateViewModelKey.self] = newValue }
+ }
+}
+
+struct WorkflowCommands: Commands {
+ @FocusedValue(\.appStateViewModel) private var viewModel
+
+ var body: some Commands {
+ CommandMenu("Transcript") {
+ Button("Process Transcript") { viewModel?.submit() }
+ .keyboardShortcut(.return, modifiers: .command)
+ .disabled(viewModel?.canSubmit != true)
+ Button("Reset") { viewModel?.resetLocal() }
+ .keyboardShortcut("r", modifiers: .command)
+ }
+ }
+}
+
+@main
+struct MeetingNotesMacApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+ @StateObject private var settings = AppSettings()
+ @StateObject private var viewModel: AppStateViewModel
+
+ init() {
+ let settings = AppSettings()
+ _settings = StateObject(wrappedValue: settings)
+ let host = EmbeddedHost.tryCreateProduction(
+ bundleRoot: settings.bundleURL,
+ workspaceId: settings.workspace
+ )
+ _viewModel = StateObject(wrappedValue: AppStateViewModel(
+ host: host,
+ workspaceId: settings.workspace,
+ appId: AppSettings.appId,
+ transcriptMaxLength: AppSettings.transcriptMaxLength
+ ))
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environmentObject(settings)
+ .environmentObject(viewModel)
+ .focusedValue(\.appStateViewModel, viewModel)
+ .onChange(of: settings.workspace) { _, workspace in
+ viewModel.updateWorkspace(workspace)
+ }
+ }
+ .commands {
+ WorkflowCommands()
+ }
+ Settings {
+ PreferencesView()
+ .environmentObject(settings)
+ }
+ }
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/PreferencesView.swift b/apps/meeting-notes/macos-swift/MeetingNotesMac/PreferencesView.swift
new file mode 100644
index 0000000..d4c97b6
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/PreferencesView.swift
@@ -0,0 +1,29 @@
+import SwiftUI
+
+struct PreferencesView: View {
+ @EnvironmentObject private var settings: AppSettings
+
+ var body: some View {
+ Form {
+ Section("Embedded runtime") {
+ TextField("Workspace", text: $settings.workspace)
+ .textFieldStyle(.roundedBorder)
+ TextField("Bundle path (optional)", text: $settings.bundlePath)
+ .textFieldStyle(.roundedBorder)
+ }
+ Section {
+ Text("Embedded mode uses the bundled runtime/runtime.wasm. Restart after changing the bundle path.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 420, height: 180)
+ .padding()
+ }
+}
+
+#Preview {
+ PreferencesView()
+ .environmentObject(AppSettings())
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/app.manifest.json b/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/app.manifest.json
new file mode 100644
index 0000000..06486a2
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/app.manifest.json
@@ -0,0 +1,117 @@
+{
+ "app_id": "meeting-notes",
+ "version": "1.0.0",
+ "schema_version": "1.0.0",
+ "workspace_defaults": {
+ "workspace_id": "local-default",
+ "registry_scope": "private"
+ },
+ "components": [
+ {
+ "component_id": "meeting-notes.process-component",
+ "version": "1.0.0",
+ "digest": "sha256:5647c39a1d25d8728350f9619025292a62e78a602068a2ad9b6f075751c93d99",
+ "manifest_path": "components/process/component.manifest.json"
+ }
+ ],
+ "workflows": [
+ {
+ "workflow_id": "meeting-notes.process",
+ "workflow_version": "1.0.0",
+ "path": "_traverse/workflows/examples/meeting-notes/process/workflow.json"
+ }
+ ],
+ "model_dependencies": [],
+ "config_schema": {
+ "type": "object",
+ "required": [
+ "workspace_id"
+ ],
+ "properties": {
+ "workspace_id": {
+ "type": "string"
+ },
+ "processing_mode": {
+ "type": "string",
+ "enum": [
+ "deterministic"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "default_config": {
+ "workspace_id": "local-default",
+ "processing_mode": "deterministic"
+ },
+ "placement_policy": {
+ "preferred_targets": [
+ "local"
+ ],
+ "allow_fallback": false
+ },
+ "public_surfaces": [
+ "cli",
+ "http_json"
+ ],
+ "state_machine": {
+ "initial_state": "idle",
+ "list_context_fields": [
+ "output.action_items",
+ "output.decisions",
+ "output.follow_ups",
+ "output.summary"
+ ],
+ "states": [
+ {
+ "id": "idle",
+ "transitions": [
+ {
+ "on": "submit",
+ "to": "processing"
+ }
+ ]
+ },
+ {
+ "id": "processing",
+ "invoke": {
+ "capability_id": "meeting-notes.process",
+ "input_from": "command.payload"
+ },
+ "transitions": [
+ {
+ "on": "capability_succeeded",
+ "to": "results"
+ },
+ {
+ "on": "capability_failed",
+ "to": "error"
+ }
+ ]
+ },
+ {
+ "id": "results",
+ "transitions": [
+ {
+ "on": "reset",
+ "to": "idle"
+ }
+ ]
+ },
+ {
+ "id": "error",
+ "transitions": [
+ {
+ "on": "retry",
+ "to": "processing",
+ "with_last_payload": true
+ },
+ {
+ "on": "reset",
+ "to": "idle"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/components/process/component.manifest.json b/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/components/process/component.manifest.json
new file mode 100644
index 0000000..dec21bd
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/components/process/component.manifest.json
@@ -0,0 +1,30 @@
+{
+ "component_id": "meeting-notes.process-component",
+ "version": "1.0.0",
+ "schema_version": "1.0.0",
+ "capability_id": "meeting-notes.process",
+ "capability_version": "1.0.0",
+ "registry_ref": {
+ "namespace": "meeting-notes",
+ "id": "meeting-notes.process",
+ "version_range": "^1.0.0"
+ },
+ "runtime_constraints": {
+ "host_api_access": "none",
+ "network_access": "forbidden",
+ "filesystem_access": "none"
+ },
+ "permitted_targets": [
+ "local",
+ "device"
+ ],
+ "dependencies": [],
+ "connector_requirements": [],
+ "validation_evidence": [
+ {
+ "evidence_type": "checked_in_fixture",
+ "status": "passed",
+ "produced_by": "meeting_notes_example_smoke"
+ }
+ ]
+}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/runtime/runtime-release.json b/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/runtime/runtime-release.json
new file mode 100644
index 0000000..b3354d7
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/runtime/runtime-release.json
@@ -0,0 +1 @@
+{"runtime_version":"0.8.1","bridge_version":"1.1.0","bridge_abi_version":10100,"sha256":"aa801023ba4eb20b8c1b4004bdd964a78fed9540478b252b77eac04c80811852"}
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/runtime/runtime.wasm b/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/runtime/runtime.wasm
new file mode 100644
index 0000000..9587c31
Binary files /dev/null and b/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/runtime/runtime.wasm differ
diff --git a/apps/meeting-notes/macos-swift/MeetingNotesMacTests/AppSettingsSmokeTests.swift b/apps/meeting-notes/macos-swift/MeetingNotesMacTests/AppSettingsSmokeTests.swift
new file mode 100644
index 0000000..0bc50a2
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/MeetingNotesMacTests/AppSettingsSmokeTests.swift
@@ -0,0 +1,13 @@
+import XCTest
+@testable import MeetingNotesMac
+
+final class AppSettingsSmokeTests: XCTestCase {
+ @MainActor
+ func testDefaults() {
+ let defaults = UserDefaults(suiteName: "meeting-notes-mac-smoke-\(UUID().uuidString)")!
+ let settings = AppSettings(userDefaults: defaults)
+ XCTAssertEqual(settings.workspace, AppSettings.defaultWorkspace)
+ XCTAssertEqual(AppSettings.appId, "meeting-notes")
+ XCTAssertTrue(settings.bundlePath.isEmpty)
+ }
+}
diff --git a/apps/meeting-notes/macos-swift/README.md b/apps/meeting-notes/macos-swift/README.md
new file mode 100644
index 0000000..429b96f
--- /dev/null
+++ b/apps/meeting-notes/macos-swift/README.md
@@ -0,0 +1,22 @@
+# meeting-notes (macOS SwiftUI)
+
+**Runtime mode: Embedded** — in-process `TraverseEmbedder` (Swift) loads digest-pinned `runtime/runtime.wasm`. No `traverse-cli serve` required.
+
+## Sync bundle
+
+```bash
+export TRAVERSE_REPO=/path/to/Traverse
+bash scripts/ci/sync_swift_meeting_notes_bundle.sh
+```
+
+Destination: `MeetingNotesMac/Resources/bundles/meeting-notes/`
+
+## Build / run
+
+Open `MeetingNotesMac.xcodeproj` in Xcode → Run.
+
+Shared host + tests: [`../MeetingNotesCore/`](../MeetingNotesCore/)
+
+```bash
+cd apps/meeting-notes/MeetingNotesCore && swift test
+```
diff --git a/apps/meeting-notes/windows-winui/.gitignore b/apps/meeting-notes/windows-winui/.gitignore
new file mode 100644
index 0000000..4050a41
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/.gitignore
@@ -0,0 +1,7 @@
+bin/
+obj/
+.vs/
+*.user
+*.suo
+*.cache
+TestResults/
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes.Tests/ExecutionViewModelTests.cs b/apps/meeting-notes/windows-winui/MeetingNotes.Tests/ExecutionViewModelTests.cs
new file mode 100644
index 0000000..54ee9a8
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes.Tests/ExecutionViewModelTests.cs
@@ -0,0 +1,97 @@
+namespace MeetingNotes.Tests;
+
+using Xunit;
+
+internal sealed class InMemorySettingsRepository : ISettingsRepository
+{
+ public string Workspace { get; set; } = AppConstants.DefaultWorkspace;
+ public string BundlePath { get; set; } = string.Empty;
+}
+
+public class ExecutionViewModelTests
+{
+ private static MeetingNotesOutput SampleOutput { get; } = new(
+ [new ActionItem("Prepare launch checklist", "Avery", "Friday")],
+ [new Decision("Ship the beta on Friday", "Morgan")],
+ ["Confirm support rotation"],
+ "Team aligned on beta launch readiness.");
+
+ [Fact]
+ public void CanSubmitWhenReadyWithTranscript()
+ {
+ using var host = EmbeddedHost.CreateTestHost(SampleOutput);
+ var vm = new ExecutionViewModel(host, new InMemorySettingsRepository())
+ {
+ Transcript = "meeting transcript",
+ };
+
+ Assert.Equal(RuntimeStatus.Ready, vm.RuntimeStatus);
+ Assert.Equal(EmbeddedHost.RuntimeModeEmbedded, vm.RuntimeMode);
+ Assert.True(vm.CanSubmit);
+ }
+
+ [Fact]
+ public async Task SubmitTransitionsToSucceededWithScriptedOutput()
+ {
+ using var host = EmbeddedHost.CreateTestHost(SampleOutput);
+ var vm = new ExecutionViewModel(host, new InMemorySettingsRepository())
+ {
+ Transcript = "meeting transcript",
+ };
+
+ await vm.SubmitCommand.ExecuteAsync(null);
+
+ Assert.Equal(ExecutionPhase.Succeeded, vm.Phase);
+ Assert.Equal("Prepare launch checklist", vm.Output?.ActionItems[0].Task);
+ Assert.Equal("Ship the beta on Friday", vm.Output?.Decisions[0].Text);
+ Assert.Equal("Team aligned on beta launch readiness.", vm.Output?.Summary);
+ Assert.NotNull(vm.SessionId);
+ }
+
+ [Fact]
+ public void ResetReturnsToIdle()
+ {
+ using var host = EmbeddedHost.CreateTestHost(SampleOutput);
+ var vm = new ExecutionViewModel(host, new InMemorySettingsRepository())
+ {
+ Phase = ExecutionPhase.Failed,
+ Error = "boom",
+ };
+
+ vm.ResetCommand.Execute(null);
+ Assert.Equal(ExecutionPhase.Idle, vm.Phase);
+ Assert.Null(vm.Error);
+ }
+
+ [Fact]
+ public void UnavailableHostDisablesSubmit()
+ {
+ var vm = new ExecutionViewModel(null, new InMemorySettingsRepository())
+ {
+ Transcript = "hello",
+ };
+
+ Assert.Equal(RuntimeStatus.Unavailable, vm.RuntimeStatus);
+ Assert.False(vm.CanSubmit);
+ }
+}
+
+public class EmbeddedHostTests
+{
+ [Fact]
+ public void TestHostReturnsScriptedCapabilityResult()
+ {
+ var output = new MeetingNotesOutput(
+ [new ActionItem("Share notes", "Avery", null)],
+ [new Decision("Use the lightweight rollout", "Morgan")],
+ ["Schedule review"],
+ "Rollout plan selected.");
+
+ using var host = EmbeddedHost.CreateTestHost(output);
+ var result = host.SubmitTranscript("any transcript");
+
+ Assert.Null(result.Error);
+ Assert.Equal("Share notes", result.Output?.ActionItems[0].Task);
+ Assert.Contains(result.Events, e => e.EventType == "capability_result");
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes.Tests/MeetingNotes.Tests.csproj b/apps/meeting-notes/windows-winui/MeetingNotes.Tests/MeetingNotes.Tests.csproj
new file mode 100644
index 0000000..66a85b5
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes.Tests/MeetingNotes.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+ net8.0
+ MeetingNotes.Tests
+ false
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes.sln b/apps/meeting-notes/windows-winui/MeetingNotes.sln
new file mode 100644
index 0000000..4e5da2e
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes.sln
@@ -0,0 +1,55 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeetingNotes", "MeetingNotes\MeetingNotes.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MeetingNotes.Tests", "MeetingNotes.Tests\MeetingNotes.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Debug|ARM64 = Debug|ARM64
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ Release|ARM64 = Release|ARM64
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|x64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|x64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|x64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|x86
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|x86
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|ARM64.ActiveCfg = Debug|ARM64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|ARM64.Build.0 = Debug|ARM64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|x64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|x64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|x64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|x64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|x86
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|x86
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|ARM64.ActiveCfg = Release|ARM64
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|ARM64.Build.0 = Release|ARM64
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|ARM64.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/App.xaml b/apps/meeting-notes/windows-winui/MeetingNotes/App.xaml
new file mode 100644
index 0000000..fd936cc
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/App.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/App.xaml.cs b/apps/meeting-notes/windows-winui/MeetingNotes/App.xaml.cs
new file mode 100644
index 0000000..8b2dd75
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/App.xaml.cs
@@ -0,0 +1,25 @@
+using Microsoft.UI.Xaml;
+
+namespace MeetingNotes;
+
+public partial class App : Application
+{
+ public static SettingsRepository Settings { get; } = new();
+ public static ExecutionViewModel ViewModel { get; private set; } = null!;
+
+ public App()
+ {
+ InitializeComponent();
+ var bundleOverride = string.IsNullOrWhiteSpace(Settings.BundlePath)
+ ? null
+ : Settings.BundlePath;
+ var host = EmbeddedHost.TryCreateProduction(bundleOverride, Settings.Workspace);
+ ViewModel = new ExecutionViewModel(host, Settings);
+ }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ var window = new MainWindow();
+ window.Activate();
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/AppConstants.cs b/apps/meeting-notes/windows-winui/MeetingNotes/AppConstants.cs
new file mode 100644
index 0000000..6deb1a3
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/AppConstants.cs
@@ -0,0 +1,9 @@
+namespace MeetingNotes;
+
+public static class AppConstants
+{
+ public const string AppId = "meeting-notes";
+ public const string CapabilityId = "meeting-notes.process";
+ public const string DefaultWorkspace = "local-default";
+ public const int TranscriptMaxLength = 5000;
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/app.manifest.json b/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/app.manifest.json
new file mode 100644
index 0000000..06486a2
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/app.manifest.json
@@ -0,0 +1,117 @@
+{
+ "app_id": "meeting-notes",
+ "version": "1.0.0",
+ "schema_version": "1.0.0",
+ "workspace_defaults": {
+ "workspace_id": "local-default",
+ "registry_scope": "private"
+ },
+ "components": [
+ {
+ "component_id": "meeting-notes.process-component",
+ "version": "1.0.0",
+ "digest": "sha256:5647c39a1d25d8728350f9619025292a62e78a602068a2ad9b6f075751c93d99",
+ "manifest_path": "components/process/component.manifest.json"
+ }
+ ],
+ "workflows": [
+ {
+ "workflow_id": "meeting-notes.process",
+ "workflow_version": "1.0.0",
+ "path": "_traverse/workflows/examples/meeting-notes/process/workflow.json"
+ }
+ ],
+ "model_dependencies": [],
+ "config_schema": {
+ "type": "object",
+ "required": [
+ "workspace_id"
+ ],
+ "properties": {
+ "workspace_id": {
+ "type": "string"
+ },
+ "processing_mode": {
+ "type": "string",
+ "enum": [
+ "deterministic"
+ ]
+ }
+ },
+ "additionalProperties": false
+ },
+ "default_config": {
+ "workspace_id": "local-default",
+ "processing_mode": "deterministic"
+ },
+ "placement_policy": {
+ "preferred_targets": [
+ "local"
+ ],
+ "allow_fallback": false
+ },
+ "public_surfaces": [
+ "cli",
+ "http_json"
+ ],
+ "state_machine": {
+ "initial_state": "idle",
+ "list_context_fields": [
+ "output.action_items",
+ "output.decisions",
+ "output.follow_ups",
+ "output.summary"
+ ],
+ "states": [
+ {
+ "id": "idle",
+ "transitions": [
+ {
+ "on": "submit",
+ "to": "processing"
+ }
+ ]
+ },
+ {
+ "id": "processing",
+ "invoke": {
+ "capability_id": "meeting-notes.process",
+ "input_from": "command.payload"
+ },
+ "transitions": [
+ {
+ "on": "capability_succeeded",
+ "to": "results"
+ },
+ {
+ "on": "capability_failed",
+ "to": "error"
+ }
+ ]
+ },
+ {
+ "id": "results",
+ "transitions": [
+ {
+ "on": "reset",
+ "to": "idle"
+ }
+ ]
+ },
+ {
+ "id": "error",
+ "transitions": [
+ {
+ "on": "retry",
+ "to": "processing",
+ "with_last_payload": true
+ },
+ {
+ "on": "reset",
+ "to": "idle"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/components/process/component.manifest.json b/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/components/process/component.manifest.json
new file mode 100644
index 0000000..dec21bd
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/components/process/component.manifest.json
@@ -0,0 +1,30 @@
+{
+ "component_id": "meeting-notes.process-component",
+ "version": "1.0.0",
+ "schema_version": "1.0.0",
+ "capability_id": "meeting-notes.process",
+ "capability_version": "1.0.0",
+ "registry_ref": {
+ "namespace": "meeting-notes",
+ "id": "meeting-notes.process",
+ "version_range": "^1.0.0"
+ },
+ "runtime_constraints": {
+ "host_api_access": "none",
+ "network_access": "forbidden",
+ "filesystem_access": "none"
+ },
+ "permitted_targets": [
+ "local",
+ "device"
+ ],
+ "dependencies": [],
+ "connector_requirements": [],
+ "validation_evidence": [
+ {
+ "evidence_type": "checked_in_fixture",
+ "status": "passed",
+ "produced_by": "meeting_notes_example_smoke"
+ }
+ ]
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/runtime/runtime-release.json b/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/runtime/runtime-release.json
new file mode 100644
index 0000000..b3354d7
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/runtime/runtime-release.json
@@ -0,0 +1 @@
+{"runtime_version":"0.8.1","bridge_version":"1.1.0","bridge_abi_version":10100,"sha256":"aa801023ba4eb20b8c1b4004bdd964a78fed9540478b252b77eac04c80811852"}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/runtime/runtime.wasm b/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/runtime/runtime.wasm
new file mode 100644
index 0000000..9587c31
Binary files /dev/null and b/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/runtime/runtime.wasm differ
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/EmbeddedHost.cs b/apps/meeting-notes/windows-winui/MeetingNotes/EmbeddedHost.cs
new file mode 100644
index 0000000..d8fae98
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/EmbeddedHost.cs
@@ -0,0 +1,387 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Traverse.Embedder;
+
+namespace MeetingNotes;
+
+/// Successful or failed embedded workflow run.
+public sealed record HostRunResult(
+ string SessionId,
+ MeetingNotesOutput? Output,
+ IReadOnlyList Events,
+ string? Error);
+
+///
+/// Embedded Traverse host boundary for WinUI shells.
+/// Production uses ; tests use
+/// with scripted runtime-owned output.
+///
+public interface IEmbeddedHost : IDisposable
+{
+ string WorkspaceId { get; }
+ string WorkflowId { get; }
+ bool IsReady { get; }
+ HostRunResult SubmitTranscript(string transcript);
+}
+
+/// Factory helpers for production and test hosts.
+public static class EmbeddedHost
+{
+ public const string RuntimeModeEmbedded = "Embedded";
+ public const string DefaultWorkflowId = AppConstants.CapabilityId;
+ public const string DefaultWorkspace = AppConstants.DefaultWorkspace;
+ public const string DefaultAppId = AppConstants.AppId;
+
+ public const string PinnedRuntimeWasmDigest =
+ "sha256:aa801023ba4eb20b8c1b4004bdd964a78fed9540478b252b77eac04c80811852";
+
+ public const string DefaultRelativeBundlePath = "Assets/bundles/meeting-notes";
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ public static IEmbeddedHost? TryCreateProduction(
+ string? bundleRoot = null,
+ string? workspaceId = null,
+ string? digest = null)
+ {
+ try
+ {
+ var root = ResolveBundleRoot(bundleRoot);
+ if (root is null)
+ {
+ return null;
+ }
+
+ var pinned = digest ?? ReadPinnedDigest(root) ?? PinnedRuntimeWasmDigest;
+ var workspace = string.IsNullOrWhiteSpace(workspaceId)
+ ? DefaultWorkspace
+ : workspaceId.Trim();
+ return new ProductionEmbeddedHost(root, pinned, workspace);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public static IEmbeddedHost CreateTestHost(
+ MeetingNotesOutput output,
+ string workspaceId = DefaultWorkspace)
+ {
+ var harness = new InMemoryTraverseEmbedder()
+ .WithTargetOutput(JsonSerializer.Serialize(output, JsonOptions));
+ harness.Initialize(new TraverseBundle("test-root", "sha256:test"));
+ return new TestEmbeddedHost(harness, workspaceId, DefaultWorkflowId);
+ }
+
+ public static string? ResolveBundleRoot(string? overridePath = null)
+ {
+ if (!string.IsNullOrWhiteSpace(overridePath))
+ {
+ var candidate = Path.GetFullPath(overridePath);
+ if (File.Exists(Path.Combine(candidate, "runtime", "runtime.wasm")))
+ {
+ return candidate;
+ }
+ }
+
+ var baseDir = AppContext.BaseDirectory;
+ var fromBase = Path.GetFullPath(Path.Combine(baseDir, DefaultRelativeBundlePath));
+ if (File.Exists(Path.Combine(fromBase, "runtime", "runtime.wasm")))
+ {
+ return fromBase;
+ }
+
+ var dir = new DirectoryInfo(baseDir);
+ while (dir is not null)
+ {
+ var nested = Path.Combine(dir.FullName, DefaultRelativeBundlePath);
+ if (File.Exists(Path.Combine(nested, "runtime", "runtime.wasm")))
+ {
+ return Path.GetFullPath(nested);
+ }
+
+ dir = dir.Parent;
+ }
+
+ return null;
+ }
+
+ private static string? ReadPinnedDigest(string bundleRoot)
+ {
+ var releasePath = Path.Combine(bundleRoot, "runtime", "runtime-release.json");
+ if (!File.Exists(releasePath))
+ {
+ return null;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllText(releasePath));
+ if (doc.RootElement.TryGetProperty("sha256", out var sha) &&
+ sha.ValueKind == JsonValueKind.String &&
+ sha.GetString() is { Length: > 0 } hex)
+ {
+ return hex.StartsWith("sha256:", StringComparison.Ordinal)
+ ? hex
+ : "sha256:" + hex;
+ }
+ }
+ catch
+ {
+ // fall through
+ }
+
+ return null;
+ }
+
+ private sealed class ProductionEmbeddedHost : IEmbeddedHost
+ {
+ private readonly WasmtimeRuntimeBridge _bridge;
+ private readonly WasmtimeBridgeClient _client;
+ private readonly RuntimeTraverseEmbedder _runtime;
+ private bool _disposed;
+
+ public ProductionEmbeddedHost(string bundleRoot, string digest, string workspaceId)
+ {
+ WorkspaceId = workspaceId;
+ WorkflowId = DefaultWorkflowId;
+ var bundle = new TraverseBundle(bundleRoot, digest);
+ _bridge = new WasmtimeRuntimeBridge(bundle);
+ _client = new WasmtimeBridgeClient(_bridge);
+ _runtime = new RuntimeTraverseEmbedder(_client);
+ var config = new JsonObject { ["workspace_id"] = workspaceId }.ToJsonString();
+ _runtime.Initialize(config);
+ IsReady = true;
+ }
+
+ public string WorkspaceId { get; }
+ public string WorkflowId { get; }
+ public bool IsReady { get; }
+
+ public HostRunResult SubmitTranscript(string transcript)
+ {
+ var input = new JsonObject { ["transcript"] = transcript }.ToJsonString();
+ var accepted = _runtime.Submit(new TraverseSubmission(WorkflowId, input));
+ if (!string.Equals(accepted.Status, "accepted", StringComparison.OrdinalIgnoreCase))
+ {
+ return new HostRunResult(
+ accepted.SessionId,
+ null,
+ Array.Empty(),
+ $"submit {accepted.Status}");
+ }
+
+ return DrainEvents(accepted.SessionId);
+ }
+
+ private HostRunResult DrainEvents(string sessionId)
+ {
+ var events = new List();
+ MeetingNotesOutput? output = null;
+ string? error = null;
+
+ while (_client.NextEvent() is { } bytes)
+ {
+ using var doc = JsonDocument.Parse(bytes);
+ var root = doc.RootElement;
+ var eventType = root.TryGetProperty("type", out var typeEl) &&
+ typeEl.ValueKind == JsonValueKind.String
+ ? typeEl.GetString() ?? "event"
+ : root.TryGetProperty("event_type", out var et) &&
+ et.ValueKind == JsonValueKind.String
+ ? et.GetString() ?? "event"
+ : "event";
+ var eventSession = root.TryGetProperty("session_id", out var sid) &&
+ sid.ValueKind == JsonValueKind.String
+ ? sid.GetString()
+ : null;
+ if (eventSession is not null &&
+ !string.Equals(eventSession, sessionId, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ JsonElement? data = root.TryGetProperty("data", out var dataEl)
+ ? dataEl.Clone()
+ : null;
+ events.Add(new TraceEvent(eventType, events.Count.ToString(), data));
+
+ if (eventType == "error")
+ {
+ error = ExtractError(data) ?? "execution failed";
+ break;
+ }
+
+ if (eventType == "capability_result")
+ {
+ output = ParseOutput(data);
+ break;
+ }
+ }
+
+ if (error is not null)
+ {
+ return new HostRunResult(sessionId, null, events, error);
+ }
+
+ if (output is null && events.Count == 0)
+ {
+ return new HostRunResult(
+ sessionId,
+ null,
+ events,
+ "embedder emitted no capability_result");
+ }
+
+ return new HostRunResult(sessionId, output ?? MeetingNotesOutput.Empty, events, null);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ try
+ {
+ _runtime.Shutdown();
+ }
+ catch
+ {
+ // best-effort
+ }
+
+ _bridge.Dispose();
+ }
+ }
+
+ private sealed class TestEmbeddedHost : IEmbeddedHost
+ {
+ private readonly InMemoryTraverseEmbedder _harness;
+
+ public TestEmbeddedHost(
+ InMemoryTraverseEmbedder harness,
+ string workspaceId,
+ string workflowId)
+ {
+ _harness = harness;
+ WorkspaceId = workspaceId;
+ WorkflowId = workflowId;
+ IsReady = true;
+ }
+
+ public string WorkspaceId { get; }
+ public string WorkflowId { get; }
+ public bool IsReady { get; }
+
+ public HostRunResult SubmitTranscript(string transcript)
+ {
+ var input = new JsonObject { ["transcript"] = transcript }.ToJsonString();
+ var accepted = _harness.Submit(new TraverseSubmission(WorkflowId, input));
+ var runtimeEvents = _harness.Subscribe();
+ var events = new List();
+ MeetingNotesOutput? output = null;
+ string? error = null;
+
+ foreach (var evt in runtimeEvents)
+ {
+ if (evt.SessionId is not null &&
+ !string.Equals(evt.SessionId, accepted.SessionId, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ var eventType = evt.EventType ?? evt.Status;
+ JsonElement? data = null;
+ if (evt.Output is not null)
+ {
+ using var parsed = JsonDocument.Parse(evt.Output);
+ data = parsed.RootElement.Clone();
+ }
+
+ events.Add(new TraceEvent(eventType, evt.Sequence.ToString(), data));
+
+ if (string.Equals(eventType, "error", StringComparison.Ordinal))
+ {
+ error = evt.ErrorData ?? "execution failed";
+ break;
+ }
+
+ if (string.Equals(eventType, "capability_result", StringComparison.Ordinal) &&
+ evt.Output is not null)
+ {
+ output = JsonSerializer.Deserialize(evt.Output, JsonOptions)
+ ?? MeetingNotesOutput.Empty;
+ break;
+ }
+ }
+
+ if (error is not null)
+ {
+ return new HostRunResult(accepted.SessionId, null, events, error);
+ }
+
+ return new HostRunResult(
+ accepted.SessionId,
+ output ?? MeetingNotesOutput.Empty,
+ events,
+ output is null ? "embedder emitted no capability_result" : null);
+ }
+
+ public void Dispose() => _harness.Shutdown();
+ }
+
+ private static MeetingNotesOutput ParseOutput(JsonElement? data)
+ {
+ if (data is null)
+ {
+ return MeetingNotesOutput.Empty;
+ }
+
+ var element = data.Value;
+ if (element.ValueKind == JsonValueKind.Object &&
+ element.TryGetProperty("output", out var nested))
+ {
+ element = nested;
+ }
+
+ if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
+ {
+ return MeetingNotesOutput.Empty;
+ }
+
+ return JsonSerializer.Deserialize(element.GetRawText(), JsonOptions)
+ ?? MeetingNotesOutput.Empty;
+ }
+
+ private static string? ExtractError(JsonElement? data)
+ {
+ if (data is null || data.Value.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ if (data.Value.TryGetProperty("error", out var err))
+ {
+ if (err.ValueKind == JsonValueKind.String)
+ {
+ return err.GetString();
+ }
+
+ if (err.ValueKind == JsonValueKind.Object &&
+ err.TryGetProperty("message", out var message) &&
+ message.ValueKind == JsonValueKind.String)
+ {
+ return message.GetString();
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/ExecutionViewModel.cs b/apps/meeting-notes/windows-winui/MeetingNotes/ExecutionViewModel.cs
new file mode 100644
index 0000000..222ddb7
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/ExecutionViewModel.cs
@@ -0,0 +1,143 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+
+namespace MeetingNotes;
+
+public partial class ExecutionViewModel : ObservableObject, IDisposable
+{
+ private readonly IEmbeddedHost? _host;
+ private readonly ISettingsRepository _settings;
+ private CancellationTokenSource? _submitCts;
+
+ [ObservableProperty]
+ private ExecutionPhase _phase = ExecutionPhase.Idle;
+
+ [ObservableProperty]
+ private string _transcript = string.Empty;
+
+ [ObservableProperty]
+ private RuntimeStatus _runtimeStatus = RuntimeStatus.Starting;
+
+ [ObservableProperty]
+ private bool _showTrace;
+
+ [ObservableProperty]
+ private string? _sessionId;
+
+ [ObservableProperty]
+ private MeetingNotesOutput? _output;
+
+ [ObservableProperty]
+ private IReadOnlyList _trace = Array.Empty();
+
+ [ObservableProperty]
+ private string? _error;
+
+ public ExecutionViewModel(IEmbeddedHost? host, ISettingsRepository settings)
+ {
+ _host = host;
+ _settings = settings;
+ RuntimeMode = EmbeddedHost.RuntimeModeEmbedded;
+ WorkflowId = host?.WorkflowId ?? AppConstants.CapabilityId;
+ RuntimeStatus = host?.IsReady == true ? RuntimeStatus.Ready : RuntimeStatus.Unavailable;
+ }
+
+ public string RuntimeMode { get; }
+
+ public string WorkflowId { get; }
+
+ public string Workspace => _settings.Workspace;
+
+ public bool CanSubmit =>
+ RuntimeStatus == RuntimeStatus.Ready &&
+ !string.IsNullOrWhiteSpace(Transcript) &&
+ Phase is not ExecutionPhase.Loading;
+
+ partial void OnTranscriptChanged(string value)
+ {
+ if (value.Length > AppConstants.TranscriptMaxLength)
+ {
+ Transcript = value[..AppConstants.TranscriptMaxLength];
+ return;
+ }
+
+ SubmitCommand.NotifyCanExecuteChanged();
+ }
+
+ partial void OnPhaseChanged(ExecutionPhase value) => SubmitCommand.NotifyCanExecuteChanged();
+
+ partial void OnRuntimeStatusChanged(RuntimeStatus value) => SubmitCommand.NotifyCanExecuteChanged();
+
+ [RelayCommand(CanExecute = nameof(CanSubmit))]
+ private async Task SubmitAsync()
+ {
+ if (!CanSubmit || _host is null)
+ {
+ return;
+ }
+
+ _submitCts?.Cancel();
+ _submitCts = new CancellationTokenSource();
+
+ Phase = ExecutionPhase.Loading;
+ Error = null;
+ Output = null;
+ Trace = Array.Empty();
+ ShowTrace = false;
+ SessionId = null;
+
+ var trimmedTranscript = Transcript.Trim();
+
+ try
+ {
+ var result = await Task.Run(
+ () => _host.SubmitTranscript(trimmedTranscript),
+ _submitCts.Token);
+
+ SessionId = result.SessionId;
+ Trace = result.Events;
+ ShowTrace = result.Events.Count > 0;
+
+ if (result.Error is not null)
+ {
+ Phase = ExecutionPhase.Failed;
+ Error = result.Error;
+ return;
+ }
+
+ Output = result.Output ?? MeetingNotesOutput.Empty;
+ Phase = ExecutionPhase.Succeeded;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ Phase = ExecutionPhase.Failed;
+ Error = ex.Message;
+ }
+ }
+
+ [RelayCommand]
+ private void Reset()
+ {
+ _submitCts?.Cancel();
+ _submitCts = null;
+ Phase = ExecutionPhase.Idle;
+ SessionId = null;
+ Output = null;
+ Trace = Array.Empty();
+ Error = null;
+ ShowTrace = false;
+ }
+
+ public void RefreshRuntimeStatus()
+ {
+ RuntimeStatus = _host?.IsReady == true ? RuntimeStatus.Ready : RuntimeStatus.Unavailable;
+ OnPropertyChanged(nameof(Workspace));
+ }
+
+ public void Dispose()
+ {
+ _submitCts?.Cancel();
+ _submitCts?.Dispose();
+ _host?.Dispose();
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/HomePage.xaml b/apps/meeting-notes/windows-winui/MeetingNotes/HomePage.xaml
new file mode 100644
index 0000000..29ef94a
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/HomePage.xaml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/HomePage.xaml.cs b/apps/meeting-notes/windows-winui/MeetingNotes/HomePage.xaml.cs
new file mode 100644
index 0000000..a8afbf8
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/HomePage.xaml.cs
@@ -0,0 +1,156 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Navigation;
+
+namespace MeetingNotes;
+
+public sealed partial class HomePage : Page
+{
+ private ExecutionViewModel? _viewModel;
+
+ public HomePage()
+ {
+ InitializeComponent();
+ TranscriptBox.TextChanged += (_, _) =>
+ {
+ if (_viewModel is null)
+ {
+ return;
+ }
+
+ _viewModel.Transcript = TranscriptBox.Text;
+ if (TranscriptBox.Text != _viewModel.Transcript)
+ {
+ TranscriptBox.Text = _viewModel.Transcript;
+ TranscriptBox.SelectionStart = TranscriptBox.Text.Length;
+ }
+ SubmitButton.IsEnabled = _viewModel.CanSubmit;
+ };
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ base.OnNavigatedTo(e);
+ if (e.Parameter is not ExecutionViewModel viewModel)
+ {
+ return;
+ }
+
+ _viewModel = viewModel;
+ _viewModel.PropertyChanged += (_, _) => DispatcherQueue.TryEnqueue(UpdateUi);
+ TranscriptBox.Text = _viewModel.Transcript;
+ UpdateUi();
+ }
+
+ private async void SubmitButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel is null)
+ {
+ return;
+ }
+
+ await _viewModel.SubmitCommand.ExecuteAsync(null);
+ }
+
+ private void ResetButton_Click(object sender, RoutedEventArgs e)
+ {
+ _viewModel?.ResetCommand.Execute(null);
+ if (_viewModel is not null)
+ {
+ TranscriptBox.Text = _viewModel.Transcript;
+ }
+ }
+
+ private void UpdateUi()
+ {
+ if (_viewModel is null)
+ {
+ return;
+ }
+
+ SubmitButton.IsEnabled = _viewModel.CanSubmit;
+ OfflineHint.Visibility = _viewModel.RuntimeStatus == RuntimeStatus.Unavailable
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+
+ IdleText.Visibility = Visibility.Collapsed;
+ LoadingText.Visibility = Visibility.Collapsed;
+ ErrorText.Visibility = Visibility.Collapsed;
+ OutputGrid.Visibility = Visibility.Collapsed;
+ TraceExpander.Visibility = Visibility.Collapsed;
+
+ switch (_viewModel.Phase)
+ {
+ case ExecutionPhase.Idle:
+ IdleText.Visibility = Visibility.Visible;
+ IdleText.Text = _viewModel.RuntimeStatus == RuntimeStatus.Unavailable
+ ? "Embedded runtime unavailable - sync the WinUI bundle (scripts/ci/sync_winui_meeting_notes_bundle.sh)."
+ : "Submit a transcript above to run meeting-notes.process.";
+ break;
+ case ExecutionPhase.Loading:
+ LoadingText.Visibility = Visibility.Visible;
+ break;
+ case ExecutionPhase.Failed:
+ ErrorText.Visibility = Visibility.Visible;
+ ErrorText.Text = $"Error: {_viewModel.Error}";
+ break;
+ case ExecutionPhase.Succeeded:
+ OutputGrid.Visibility = Visibility.Visible;
+ SummaryValue.Text = _viewModel.Output?.Summary ?? string.Empty;
+ ActionItemsList.ItemsSource = FormatActionItems(_viewModel.Output?.ActionItems);
+ DecisionsList.ItemsSource = FormatDecisions(_viewModel.Output?.Decisions);
+ FollowUpsList.ItemsSource = FormatStringList(_viewModel.Output?.FollowUps);
+
+ if (_viewModel.Trace.Count > 0)
+ {
+ TraceExpander.Visibility = Visibility.Visible;
+ TraceExpander.Header = $"Trace ({_viewModel.Trace.Count} events)";
+ TraceList.ItemsSource = _viewModel.Trace.Select(evt =>
+ $"{evt.Timestamp} - {evt.EventType}");
+ }
+
+ break;
+ }
+ }
+
+ private static IReadOnlyList FormatActionItems(IReadOnlyList? items)
+ {
+ if (items is null || items.Count == 0)
+ {
+ return ["None recorded"];
+ }
+
+ return items.Select(item =>
+ {
+ var details = new[] { item.Owner, item.Due is null ? null : $"due {item.Due}" }
+ .Where(value => !string.IsNullOrWhiteSpace(value));
+ var suffix = string.Join(" | ", details);
+ return string.IsNullOrWhiteSpace(suffix)
+ ? item.Task
+ : $"{item.Task} ({suffix})";
+ }).ToArray();
+ }
+
+ private static IReadOnlyList FormatDecisions(IReadOnlyList? items)
+ {
+ if (items is null || items.Count == 0)
+ {
+ return ["None recorded"];
+ }
+
+ return items.Select(item =>
+ string.IsNullOrWhiteSpace(item.MadeBy)
+ ? item.Text
+ : $"{item.Text} - decided by {item.MadeBy}").ToArray();
+ }
+
+ private static IReadOnlyList FormatStringList(IReadOnlyList? items)
+ {
+ if (items is null || items.Count == 0)
+ {
+ return ["None recorded"];
+ }
+
+ return items;
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/ISettingsRepository.cs b/apps/meeting-notes/windows-winui/MeetingNotes/ISettingsRepository.cs
new file mode 100644
index 0000000..ead66cd
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/ISettingsRepository.cs
@@ -0,0 +1,7 @@
+namespace MeetingNotes;
+
+public interface ISettingsRepository
+{
+ string Workspace { get; set; }
+ string BundlePath { get; set; }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/MainWindow.xaml b/apps/meeting-notes/windows-winui/MeetingNotes/MainWindow.xaml
new file mode 100644
index 0000000..b4af8cc
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/MainWindow.xaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/MainWindow.xaml.cs b/apps/meeting-notes/windows-winui/MeetingNotes/MainWindow.xaml.cs
new file mode 100644
index 0000000..83c0716
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/MainWindow.xaml.cs
@@ -0,0 +1,62 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI;
+
+namespace MeetingNotes;
+
+public sealed partial class MainWindow : Window
+{
+ private readonly ExecutionViewModel _viewModel = App.ViewModel;
+ private readonly SettingsRepository _settings = App.Settings;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ ContentFrame.Navigate(typeof(HomePage), _viewModel);
+ _viewModel.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName is nameof(ExecutionViewModel.RuntimeStatus)
+ or nameof(ExecutionViewModel.Workspace)
+ or null)
+ {
+ UpdateStatusHeader();
+ }
+ };
+ UpdateStatusHeader();
+ }
+
+ private void NavView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
+ {
+ if (args.IsSettingsSelected)
+ {
+ ContentFrame.Navigate(typeof(SettingsPage), _settings);
+ return;
+ }
+
+ ContentFrame.Navigate(typeof(HomePage), _viewModel);
+ }
+
+ private void UpdateStatusHeader()
+ {
+ ModeText.Text = _viewModel.RuntimeMode;
+ WorkspaceText.Text = _settings.Workspace;
+ WorkflowText.Text = _viewModel.WorkflowId;
+
+ switch (_viewModel.RuntimeStatus)
+ {
+ case RuntimeStatus.Ready:
+ StatusDot.Fill = new SolidColorBrush(Color.FromArgb(255, 0, 188, 212));
+ StatusText.Text = "Ready";
+ break;
+ case RuntimeStatus.Unavailable:
+ StatusDot.Fill = new SolidColorBrush(Color.FromArgb(255, 229, 57, 53));
+ StatusText.Text = "Unavailable";
+ break;
+ default:
+ StatusDot.Fill = new SolidColorBrush(Color.FromArgb(255, 158, 158, 158));
+ StatusText.Text = "Starting…";
+ break;
+ }
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/MeetingNotes.csproj b/apps/meeting-notes/windows-winui/MeetingNotes/MeetingNotes.csproj
new file mode 100644
index 0000000..a379c69
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/MeetingNotes.csproj
@@ -0,0 +1,38 @@
+
+
+ WinExe
+ net8.0-windows10.0.17763.0
+ 10.0.17763.0
+ MeetingNotes
+ app.manifest
+ x86;x64;ARM64
+ win-x86;win-x64;win-arm64
+ true
+ true
+ enable
+ enable
+ None
+ true
+ $(DefineConstants);DISABLE_XAML_GENERATED_MAIN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/Models.cs b/apps/meeting-notes/windows-winui/MeetingNotes/Models.cs
new file mode 100644
index 0000000..06b876a
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/Models.cs
@@ -0,0 +1,68 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MeetingNotes;
+
+public sealed record ActionItem(
+ [property: JsonPropertyName("task")] string Task,
+ [property: JsonPropertyName("owner")] string? Owner,
+ [property: JsonPropertyName("due")] string? Due);
+
+public sealed record Decision(
+ [property: JsonPropertyName("text")] string Text,
+ [property: JsonPropertyName("made_by")] string? MadeBy);
+
+public sealed record MeetingNotesOutput(
+ [property: JsonPropertyName("action_items")] IReadOnlyList ActionItems,
+ [property: JsonPropertyName("decisions")] IReadOnlyList Decisions,
+ [property: JsonPropertyName("follow_ups")] IReadOnlyList FollowUps,
+ [property: JsonPropertyName("summary")] string Summary)
+{
+ public static MeetingNotesOutput Empty { get; } = new(
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty(),
+ string.Empty);
+}
+
+public sealed record TraceEvent(
+ [property: JsonPropertyName("event_type")] string EventType,
+ string Timestamp,
+ JsonElement? Data);
+
+public enum ExecutionPhase
+{
+ Idle,
+ Loading,
+ Succeeded,
+ Failed,
+}
+
+public enum RuntimeStatus
+{
+ Starting,
+ Ready,
+ Unavailable,
+}
+
+public sealed class ExecutionUiState
+{
+ public ExecutionPhase Phase { get; init; } = ExecutionPhase.Idle;
+ public string Transcript { get; init; } = string.Empty;
+ public RuntimeStatus RuntimeStatus { get; init; } = RuntimeStatus.Starting;
+ public string Workspace { get; init; } = AppConstants.DefaultWorkspace;
+ public string WorkflowId { get; init; } = AppConstants.CapabilityId;
+ public string RuntimeMode { get; init; } = EmbeddedHost.RuntimeModeEmbedded;
+ public bool ShowTrace { get; init; }
+ public string? SessionId { get; init; }
+ public MeetingNotesOutput? Output { get; init; }
+ public IReadOnlyList Trace { get; init; } = Array.Empty();
+ public string? Error { get; init; }
+
+ public bool IsRunning => Phase is ExecutionPhase.Loading;
+
+ public bool CanSubmit =>
+ RuntimeStatus == RuntimeStatus.Ready &&
+ !string.IsNullOrWhiteSpace(Transcript) &&
+ !IsRunning;
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/Package.appxmanifest b/apps/meeting-notes/windows-winui/MeetingNotes/Package.appxmanifest
new file mode 100644
index 0000000..99da6de
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/Package.appxmanifest
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ meeting-notes
+ Traverse Framework
+ Assets\StoreLogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/Program.cs b/apps/meeting-notes/windows-winui/MeetingNotes/Program.cs
new file mode 100644
index 0000000..66f2092
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/Program.cs
@@ -0,0 +1,13 @@
+using Microsoft.UI.Xaml;
+
+namespace MeetingNotes;
+
+public static class Program
+{
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ WinRT.ComWrappersSupport.InitializeComWrappers();
+ Application.Start(_ => new App());
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/SettingsPage.xaml b/apps/meeting-notes/windows-winui/MeetingNotes/SettingsPage.xaml
new file mode 100644
index 0000000..2dd52d3
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/SettingsPage.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/SettingsPage.xaml.cs b/apps/meeting-notes/windows-winui/MeetingNotes/SettingsPage.xaml.cs
new file mode 100644
index 0000000..5c15bc9
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/SettingsPage.xaml.cs
@@ -0,0 +1,39 @@
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Navigation;
+
+namespace MeetingNotes;
+
+public sealed partial class SettingsPage : Page
+{
+ private SettingsRepository? _settings;
+
+ public SettingsPage()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ base.OnNavigatedTo(e);
+ if (e.Parameter is not SettingsRepository settings)
+ {
+ return;
+ }
+
+ _settings = settings;
+ WorkspaceBox.Text = settings.Workspace;
+ BundlePathBox.Text = settings.BundlePath;
+ }
+
+ private void Setting_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ if (_settings is null)
+ {
+ return;
+ }
+
+ _settings.Workspace = WorkspaceBox.Text;
+ _settings.BundlePath = BundlePathBox.Text;
+ App.ViewModel.RefreshRuntimeStatus();
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/SettingsRepository.cs b/apps/meeting-notes/windows-winui/MeetingNotes/SettingsRepository.cs
new file mode 100644
index 0000000..79e2d81
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/SettingsRepository.cs
@@ -0,0 +1,40 @@
+using Windows.Storage;
+
+namespace MeetingNotes;
+
+public sealed class SettingsRepository : ISettingsRepository
+{
+ private const string WorkspaceKey = "workspace";
+ private const string BundlePathKey = "bundlePath";
+
+ private readonly ApplicationDataContainer _localSettings;
+
+ public SettingsRepository()
+ : this(ApplicationData.Current.LocalSettings)
+ {
+ }
+
+ internal SettingsRepository(ApplicationDataContainer localSettings)
+ {
+ _localSettings = localSettings;
+ Workspace = Read(WorkspaceKey, AppConstants.DefaultWorkspace);
+ BundlePath = Read(BundlePathKey, string.Empty);
+ }
+
+ public string Workspace
+ {
+ get => Read(WorkspaceKey, AppConstants.DefaultWorkspace);
+ set => _localSettings.Values[WorkspaceKey] = value;
+ }
+
+ public string BundlePath
+ {
+ get => Read(BundlePathKey, string.Empty);
+ set => _localSettings.Values[BundlePathKey] = value;
+ }
+
+ private string Read(string key, string fallback)
+ {
+ return _localSettings.Values[key] as string ?? fallback;
+ }
+}
diff --git a/apps/meeting-notes/windows-winui/MeetingNotes/app.manifest b/apps/meeting-notes/windows-winui/MeetingNotes/app.manifest
new file mode 100644
index 0000000..93eb4d3
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/MeetingNotes/app.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ PerMonitorV2, PerMonitor
+
+
+
diff --git a/apps/meeting-notes/windows-winui/README.md b/apps/meeting-notes/windows-winui/README.md
new file mode 100644
index 0000000..8dd4bd7
--- /dev/null
+++ b/apps/meeting-notes/windows-winui/README.md
@@ -0,0 +1,58 @@
+# meeting-notes (Windows WinUI 3)
+
+**Runtime mode: Embedded** - in-process `TraverseEmbedder` (.NET) loads digest-pinned `runtime/runtime.wasm`. No `traverse-cli serve` sidecar is required.
+
+Native Windows client for the `meeting-notes` reference app.
+
+## Prerequisites
+
+- Windows 10 1809+ (build 17763) or Windows 11
+- Visual Studio 2022 with the Windows App SDK and .NET desktop development workloads
+- Bundled runtime assets under `MeetingNotes/Assets/bundles/meeting-notes/` (synced below)
+
+## Sync the embedded bundle
+
+```powershell
+$env:TRAVERSE_REPO = "C:\temp\traverse" # clone of traverse-framework/Traverse
+bash scripts/ci/sync_winui_meeting_notes_bundle.sh
+```
+
+This copies `runtime/runtime.wasm` + `runtime-release.json` (digest pin) and app manifests into the WinUI Assets tree.
+
+## Settings
+
+Open Settings (gear icon) to set:
+
+- Workspace - default `local-default`
+- Bundle path (optional) - override the bundled Assets root
+
+No Runtime URL is required in embedded mode.
+
+## Build and run
+
+From Visual Studio 2022, open `MeetingNotes.sln` and run on x64.
+
+Or from a Developer PowerShell:
+
+```powershell
+cd apps\meeting-notes\windows-winui
+dotnet build MeetingNotes.sln -c Release
+dotnet test MeetingNotes.sln -c Release
+dotnet run --project MeetingNotes\MeetingNotes.csproj
+```
+
+## Architecture
+
+| File | Role |
+|---|---|
+| `EmbeddedHost.cs` | `RuntimeTraverseEmbedder` / `InMemoryTraverseEmbedder` boundary |
+| `ExecutionViewModel.cs` | MVVM submit + Embedded Ready/Unavailable status |
+| `HomePage.xaml` | Transcript input, output fields, trace |
+| `SettingsPage.xaml` | Workspace + optional bundle path |
+| `MainWindow.xaml` | Navigation shell - Embedded + status + workspace + workflow |
+
+Vendored SDK: [`vendor/traverse-embedder-dotnet/`](../../../../vendor/traverse-embedder-dotnet/) (Traverse Spec 068 / 071).
+
+## Design language
+
+Follow [docs/design-language.md](../../../docs/design-language.md). Zone 1 shows **Embedded** with Ready / Unavailable / Starting.
diff --git a/docs/design-language.md b/docs/design-language.md
index 2bfd840..2232595 100644
--- a/docs/design-language.md
+++ b/docs/design-language.md
@@ -88,6 +88,10 @@ The UI renders these fields exactly as the runtime provides them — never compu
| Platform | Path | Status |
|---|---|---|
| Web (React) | `apps/meeting-notes/web-react/` | Shipped (embedded) |
+| iOS (SwiftUI) | `apps/meeting-notes/ios-swift/` | Shipped (embedded) |
+| macOS (SwiftUI + AppKit) | `apps/meeting-notes/macos-swift/` | Shipped (embedded) |
+| Android (Jetpack Compose) | `apps/meeting-notes/android-compose/` | Shipped (embedded) |
+| Windows (WinUI 3) | `apps/meeting-notes/windows-winui/` | Shipped (embedded) |
| Linux (GTK4 + Rust) | `apps/meeting-notes/linux-gtk/` | Shipped (embedded) |
| CLI (Rust) | `apps/meeting-notes/cli-rust/` | Shipped (embedded) |
diff --git a/docs/production-reference-plan.md b/docs/production-reference-plan.md
index ec350ed..42a70c7 100644
--- a/docs/production-reference-plan.md
+++ b/docs/production-reference-plan.md
@@ -59,7 +59,8 @@ Live status is always on [Project 2](https://github.com/orgs/traverse-framework/
| Delete sidecar client code | `remove-sidecar-paths` | **Done** (#206) | Dead HTTP paths removed from starter/doc-approval | Shipped |
| Nightly Apple/Windows + Android/GTK | `native-ci-android-gtk-required` | **Done** (#209); nightly green via `fix-nightly-native-required` | Required nightly jobs | Shipped |
| Product WASM agents (Traverse) | `consume-product-wasm-agents` | **Done** (#227) | Traverse real-wasm-agent-execute Done (#795/#809) | Digest-pinned Traverse-published starter agents |
-| `registry_ref` adoption | `registry-ref-full-kit-cutover` | **In Progress** | All six primary components use `registry_ref`; sync materializes for embedders | Finish smoke evidence |
+| `registry_ref` adoption | `registry-ref-full-kit-cutover` | **Done** (#235) | All six primary components use `registry_ref`; sync materializes for embedders | Shipped |
+| meeting-notes Wave 2 OS ports | `meeting-notes-wave2-os-ports` | **In Progress** | Apple + Windows + Android meeting-notes embeds | Finish Wave 2 showcase |
| Phase 2 sidecar nightly | `phase2-sidecar-nightly` | **Future** (defer) | Legacy path; low demo value | Optional; low priority |
### Wave 1 — Done
@@ -70,13 +71,11 @@ Live status is always on [Project 2](https://github.com/orgs/traverse-framework/
### Wave 2 — Ready (upstream unblocked 2026-07-22)
-Claim when Agent is Unassigned:
-
-- `embed-trace-explorer` (Traverse #802)
-- `registry-ref-starter-process` (Traverse #811) — Done (#224)
-- `registry-ref-full-kit-cutover` — remaining five components → `registry_ref`
-- `consume-product-wasm-agents` (Traverse #795/#809) — Done
-
+- `embed-trace-explorer` — Done (#225)
+- `registry-ref-starter-process` — Done (#224)
+- `registry-ref-full-kit-cutover` — Done (#235)
+- `consume-product-wasm-agents` — Done (#227)
+- `meeting-notes-wave2-os-ports` — In Progress (Apple + Windows + Android meeting-notes embeds)
## Architecture boundary (unchanged)
- UI shells render runtime-owned fields only — no local business logic
diff --git a/scripts/ci/embedded_smoke.sh b/scripts/ci/embedded_smoke.sh
index 9c4a998..3657cfb 100755
--- a/scripts/ci/embedded_smoke.sh
+++ b/scripts/ci/embedded_smoke.sh
@@ -254,6 +254,8 @@ smoke_android() {
local assets="$REPO_ROOT/apps/traverse-starter/android-compose/app/src/main/assets/bundles/traverse-starter/runtime"
verify_runtime_digest "android" "$assets" || true
+ verify_runtime_digest "android-meeting-notes" \
+ "$REPO_ROOT/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes/runtime" || true
# Linux CI often has a partial Android SDK — do not run gradle unless required.
if ! slice_expected "$slice" && [ "$EXPECT" != "auto" ]; then
@@ -293,6 +295,10 @@ smoke_swift() {
"$REPO_ROOT/apps/traverse-starter/ios-swift/TraverseStarter/Resources/bundles/traverse-starter/runtime" || true
verify_runtime_digest "macos" \
"$REPO_ROOT/apps/traverse-starter/macos-swift/TraverseStarterMac/Resources/bundles/traverse-starter/runtime" || true
+ verify_runtime_digest "ios-meeting-notes" \
+ "$REPO_ROOT/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes/runtime" || true
+ verify_runtime_digest "macos-meeting-notes" \
+ "$REPO_ROOT/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes/runtime" || true
if ! slice_expected "$slice" && [ "$EXPECT" != "auto" ]; then
skip "swift SDK tests — not required for EXPECT=$EXPECT (digests checked)"
@@ -307,17 +313,30 @@ smoke_swift() {
if slice_expected "$slice"; then fail "swift expected but host is not Darwin"; else skip "swift — requires Darwin host"; fi
return
fi
- log "=== swift (swift test TraverseCore) ==="
- local pkg="$REPO_ROOT/apps/traverse-starter/ios-swift/TraverseCore"
+ log "=== swift (swift test TraverseCore + MeetingNotesCore) ==="
+ local pkg="$REPO_ROOT/apps/traverse-starter/TraverseCore"
+ if [ ! -f "$pkg/Package.swift" ]; then
+ pkg="$REPO_ROOT/apps/traverse-starter/ios-swift/TraverseCore"
+ fi
if [ -f "$pkg/Package.swift" ]; then
if (cd "$pkg" && swift test); then
ok "swift TraverseCore tests"
else
- sdk_fail_or_skip "$slice" "swift test failed"
+ sdk_fail_or_skip "$slice" "swift TraverseCore test failed"
fi
else
skip "swift — TraverseCore Package.swift not found for headless test"
fi
+ local mn_pkg="$REPO_ROOT/apps/meeting-notes/MeetingNotesCore"
+ if [ -f "$mn_pkg/Package.swift" ]; then
+ if (cd "$mn_pkg" && swift test); then
+ ok "swift MeetingNotesCore tests"
+ else
+ sdk_fail_or_skip "$slice" "swift MeetingNotesCore test failed"
+ fi
+ else
+ skip "swift — MeetingNotesCore Package.swift not found"
+ fi
}
smoke_windows() {
@@ -326,6 +345,8 @@ smoke_windows() {
verify_runtime_digest "windows" \
"$REPO_ROOT/apps/traverse-starter/windows-winui/TraverseStarter/Assets/bundles/traverse-starter/runtime" || true
+ verify_runtime_digest "windows-meeting-notes" \
+ "$REPO_ROOT/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes/runtime" || true
if ! slice_expected "$slice" && [ "$EXPECT" != "auto" ]; then
skip "windows SDK tests — not required for EXPECT=$EXPECT (digest checked)"
diff --git a/scripts/ci/repository_checks.sh b/scripts/ci/repository_checks.sh
index 96a70cc..f1469de 100644
--- a/scripts/ci/repository_checks.sh
+++ b/scripts/ci/repository_checks.sh
@@ -77,10 +77,19 @@ check "manifests/doc-approval/components/recommend/component.manifest.json" "doc
# meeting-notes clients
check "apps/meeting-notes/web-react/package.json" "meeting-notes web-react package"
check "apps/meeting-notes/web-react/README.md" "meeting-notes web-react README"
+check "apps/meeting-notes/ios-swift/MeetingNotes.xcodeproj" "meeting-notes ios-swift Xcode project"
+check "apps/meeting-notes/ios-swift/README.md" "meeting-notes ios-swift README"
+check "apps/meeting-notes/macos-swift/MeetingNotesMac.xcodeproj" "meeting-notes macos-swift Xcode project"
+check "apps/meeting-notes/macos-swift/README.md" "meeting-notes macos-swift README"
+check "apps/meeting-notes/android-compose/settings.gradle.kts" "meeting-notes android-compose Gradle project"
+check "apps/meeting-notes/android-compose/README.md" "meeting-notes android-compose README"
+check "apps/meeting-notes/windows-winui/MeetingNotes.sln" "meeting-notes windows-winui solution"
+check "apps/meeting-notes/windows-winui/README.md" "meeting-notes windows-winui README"
check "apps/meeting-notes/linux-gtk/Cargo.toml" "meeting-notes linux-gtk Cargo project"
check "apps/meeting-notes/linux-gtk/README.md" "meeting-notes linux-gtk README"
check "apps/meeting-notes/cli-rust/Cargo.toml" "meeting-notes cli-rust Cargo project"
check "apps/meeting-notes/cli-rust/README.md" "meeting-notes cli-rust README"
+check "apps/meeting-notes/MeetingNotesCore/Package.swift" "meeting-notes MeetingNotesCore package"
check "apps/meeting-notes/meeting-notes-core-rs/Cargo.toml" "meeting-notes-core-rs Cargo crate"
check "apps/meeting-notes/Cargo.toml" "meeting-notes Cargo workspace"
check "manifests/meeting-notes/app.manifest.json" "meeting-notes app manifest"
@@ -125,10 +134,13 @@ check "scripts/ci/sync_web_doc_approval_bundle.sh" "Web doc-approval bundle sync
check "scripts/ci/sync_web_meeting_notes_bundle.sh" "Web meeting-notes bundle sync"
check "scripts/ci/sync_winui_starter_bundle.sh" "WinUI starter bundle sync"
check "scripts/ci/sync_winui_doc_approval_bundle.sh" "WinUI doc-approval bundle sync"
+check "scripts/ci/sync_winui_meeting_notes_bundle.sh" "WinUI meeting-notes bundle sync"
check "scripts/ci/sync_swift_starter_bundle.sh" "Swift starter bundle sync"
check "scripts/ci/sync_swift_doc_approval_bundle.sh" "Swift doc-approval bundle sync"
+check "scripts/ci/sync_swift_meeting_notes_bundle.sh" "Swift meeting-notes bundle sync"
check "scripts/ci/sync_android_starter_bundle.sh" "Android starter bundle sync"
check "scripts/ci/sync_android_doc_approval_bundle.sh" "Android doc-approval bundle sync"
+check "scripts/ci/sync_android_meeting_notes_bundle.sh" "Android meeting-notes bundle sync"
check "scripts/ci/onboarding_check.sh" "Onboarding check"
# GitHub Actions
diff --git a/scripts/ci/sync_android_meeting_notes_bundle.sh b/scripts/ci/sync_android_meeting_notes_bundle.sh
new file mode 100755
index 0000000..1277611
--- /dev/null
+++ b/scripts/ci/sync_android_meeting_notes_bundle.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# Sync digest-pinned runtime.wasm into Android assets for meeting-notes.
+# Shared rules: scripts/ci/sync_bundle_core.sh + docs/runtime-bundle-sync.md
+set -euo pipefail
+# shellcheck source=scripts/ci/sync_bundle_core.sh
+source "$(cd "$(dirname "$0")" && pwd)/sync_bundle_core.sh"
+sync_bundle_init
+sync_bundle_destination \
+ --dest "$REPO_ROOT/apps/meeting-notes/android-compose/app/src/main/assets/bundles/meeting-notes" \
+ --app meeting-notes \
+ --manifest-layout subdir \
+ --runtime required \
+ --traverse-assets none \
+ --label "Android meeting-notes"
diff --git a/scripts/ci/sync_swift_meeting_notes_bundle.sh b/scripts/ci/sync_swift_meeting_notes_bundle.sh
new file mode 100755
index 0000000..af67c06
--- /dev/null
+++ b/scripts/ci/sync_swift_meeting_notes_bundle.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Sync meeting-notes bundle into Swift iOS + macOS Resources.
+# Shared rules: scripts/ci/sync_bundle_core.sh + docs/runtime-bundle-sync.md
+set -euo pipefail
+# shellcheck source=scripts/ci/sync_bundle_core.sh
+source "$(cd "$(dirname "$0")" && pwd)/sync_bundle_core.sh"
+sync_bundle_init
+for DEST in \
+ "$REPO_ROOT/apps/meeting-notes/ios-swift/MeetingNotes/Resources/bundles/meeting-notes" \
+ "$REPO_ROOT/apps/meeting-notes/macos-swift/MeetingNotesMac/Resources/bundles/meeting-notes"
+do
+ sync_bundle_destination \
+ --dest "$DEST" \
+ --app meeting-notes \
+ --components process \
+ --manifest-layout root \
+ --runtime required \
+ --traverse-assets none \
+ --label "Swift meeting-notes"
+done
diff --git a/scripts/ci/sync_winui_meeting_notes_bundle.sh b/scripts/ci/sync_winui_meeting_notes_bundle.sh
new file mode 100755
index 0000000..0e73c31
--- /dev/null
+++ b/scripts/ci/sync_winui_meeting_notes_bundle.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+# Sync meeting-notes application bundle into WinUI Assets.
+# Shared rules: scripts/ci/sync_bundle_core.sh + docs/runtime-bundle-sync.md
+set -euo pipefail
+# shellcheck source=scripts/ci/sync_bundle_core.sh
+source "$(cd "$(dirname "$0")" && pwd)/sync_bundle_core.sh"
+sync_bundle_init
+sync_bundle_destination \
+ --dest "$REPO_ROOT/apps/meeting-notes/windows-winui/MeetingNotes/Assets/bundles/meeting-notes" \
+ --app meeting-notes \
+ --components process \
+ --manifest-layout root \
+ --runtime required \
+ --traverse-assets optional \
+ --label "WinUI meeting-notes"