diff --git a/.claude.json b/.claude.json index db426975c..dbe5afd46 100644 --- a/.claude.json +++ b/.claude.json @@ -9,8 +9,8 @@ "provenance": { "generator": "scripts/build-cognitive-manifest.py", "repository": "Aegis-Omega/AEGIS-OMEGA", - "source_ref": "claude/blissful-rubin-mt9jS", - "parent_state_hash": "410bcd49c721e65050382ae759db7af2f41fa9f572e625174252c72a8748ea93", + "source_ref": "feat/app-intents-under-version-control", + "parent_state_hash": "e9f0ec153b0b320a1e791092f73209442ec43a982e503203d9c101ec40949cba", "signature_mode": "GITHUB_OIDC_ATTESTATION" }, "hashing": { @@ -488,5 +488,5 @@ "on_success": "broadcast-attested-verified-event-stream" } }, - "state_hash": "e9f0ec153b0b320a1e791092f73209442ec43a982e503203d9c101ec40949cba" + "state_hash": "77d2014edd826149d8fa6ced78db0ed81bd1811c1430f201ba3f64329f60ce15" } diff --git a/clients/aegis-omega-app-intents/Package.swift b/clients/aegis-omega-app-intents/Package.swift new file mode 100644 index 000000000..deb0d7e12 --- /dev/null +++ b/clients/aegis-omega-app-intents/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "AEGISOmegaAppIntents", + defaultLocalization: "en", + platforms: [ + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library( + name: "AEGISOmegaAppIntents", + targets: ["AEGISOmegaAppIntents"] + ), + ], + targets: [ + .target( + name: "AEGISOmegaAppIntents" + ), + .testTarget( + name: "AEGISOmegaAppIntentsTests", + dependencies: ["AEGISOmegaAppIntents"] + ), + ] +) diff --git a/clients/aegis-omega-app-intents/README.md b/clients/aegis-omega-app-intents/README.md new file mode 100644 index 000000000..ff9de9618 --- /dev/null +++ b/clients/aegis-omega-app-intents/README.md @@ -0,0 +1,51 @@ +# AEGIS Omega — App Intents client + +Siri / Spotlight / Shortcuts entry points that hand a single system-facing route into the app's +root scene. + +## Why this is in the repository + +It was previously stranded in a transient agent scratch directory +(`Documents\Codex\2026-07-28\build-ios-apps-ios-app-intents-2\outputs\`). That is the same class +of location where the "holon-gram compiler" (8 files, +4446/−889) was lost permanently when its +container was reclaimed — see `CLAUDE.md` § *Verified Failure Modes* V1. Work that only exists in +an agent's working directory is one cleanup away from gone. + +## Layout + +| File | Platform | Purpose | +|---|---|---| +| `AEGISIntentRoute.swift` | any | `AEGISIntentRoute` / `AEGISSessionMode` — pure `Foundation` route model | +| `AEGISIntentRouter.swift` | any | `@MainActor @Observable` singleton the root scene observes | +| `AEGISAppIntents.swift` | Apple only | `AppIntent` conformances + `AEGISSessionModeIntentValue` | +| `AEGISAppShortcuts.swift` | Apple only | `AppShortcutsProvider` phrases | + +## Cross-platform guard + +`AppIntents` ships only in Apple SDKs. The two Apple-only sources are wrapped in +`#if canImport(AppIntents)`, and the test that exercises `AEGISSessionModeIntentValue` is guarded +the same way. On Apple platforms everything compiles unchanged; on Linux/Windows the pure +`Foundation` core and its tests still build, so the routing logic stays continuously verifiable +without a Mac. + +## Build + +```bash +swift build +swift test +``` + +**Windows prerequisite:** the Swift toolchain requires MSVC's `link.exe`. Without the Visual +Studio C++ workload, `swift build` fails with `toolchain is invalid: could not find CLI tool +'link'`. Installing that workload requires an **elevated** process — a non-elevated +`setup.exe --quiet` exits `5007` and silently does nothing. + +**iOS builds require macOS + Xcode.** No Windows toolchain can produce an iOS app; on Windows only +the cross-platform core and its tests are exercised. + +## Tests + +`AEGISIntentRouteTests` covers route equality by payload, `AEGISSessionMode` raw-value round trip +(including the rejection of an unknown value), that `accept` replaces the pending handoff with a +fresh identity, and that `clear` on a superseded handoff does not drop the current one. Router +tests reset shared state via `defer`, since `AEGISIntentRouter.shared` is a singleton. diff --git a/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppIntents.swift b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppIntents.swift new file mode 100644 index 000000000..8b987ad9e --- /dev/null +++ b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppIntents.swift @@ -0,0 +1,105 @@ +// AppIntents ships only in Apple SDKs. Guarded so the pure-Swift core and its +// tests still build on Linux/Windows; on Apple platforms this compiles unchanged. +#if canImport(AppIntents) +import AppIntents + +public enum AEGISSessionModeIntentValue: String, AppEnum { + case focused + case governed + case recovery + + public static let typeDisplayRepresentation: TypeDisplayRepresentation = "Session mode" + + public static let caseDisplayRepresentations: [Self: DisplayRepresentation] = [ + .focused: "Focused", + .governed: "Governed", + .recovery: "Recovery", + ] + + var domainValue: AEGISSessionMode { + switch self { + case .focused: .focused + case .governed: .governed + case .recovery: .recovery + } + } +} + +public struct ContinueContextIntent: AppIntent { + public static let title: LocalizedStringResource = "Continue context" + public static let description = IntentDescription( + "Open AEGIS Omega with context captured from Siri, Spotlight, or another shortcut." + ) + public static let openAppWhenRun = true + + @Parameter( + title: "Context", + inputConnectionBehavior: .connectToPreviousIntentResult + ) + public var context: String? + + public init() {} + + public init(context: String?) { + self.context = context + } + + public func perform() async throws -> some IntentResult { + await AEGISIntentRouter.shared.accept(.continueContext(text: context)) + return .result() + } +} + +public struct StartGovernedSessionIntent: AppIntent { + public static let title: LocalizedStringResource = "Start governed session" + public static let description = IntentDescription( + "Open AEGIS Omega ready to begin a focused, governed, or recovery session." + ) + public static let openAppWhenRun = true + + @Parameter(title: "Objective") + public var objective: String? + + @Parameter(title: "Mode", default: .governed) + public var mode: AEGISSessionModeIntentValue + + public init() {} + + public init(objective: String?, mode: AEGISSessionModeIntentValue = .governed) { + self.objective = objective + self.mode = mode + } + + public func perform() async throws -> some IntentResult { + await AEGISIntentRouter.shared.accept( + .startSession(objective: objective, mode: mode.domainValue) + ) + return .result() + } +} + +public struct InspectEvidenceIntent: AppIntent { + public static let title: LocalizedStringResource = "Inspect evidence" + public static let description = IntentDescription( + "Open AEGIS Omega at the evidence surface, optionally focused on a reference." + ) + public static let openAppWhenRun = true + + @Parameter( + title: "Reference", + inputConnectionBehavior: .connectToPreviousIntentResult + ) + public var reference: String? + + public init() {} + + public init(reference: String?) { + self.reference = reference + } + + public func perform() async throws -> some IntentResult { + await AEGISIntentRouter.shared.accept(.inspectEvidence(reference: reference)) + return .result() + } +} +#endif diff --git a/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppIntentsPackage.swift b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppIntentsPackage.swift new file mode 100644 index 000000000..1fafcd252 --- /dev/null +++ b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppIntentsPackage.swift @@ -0,0 +1,18 @@ +// Intent definitions that live in a Swift package (rather than the app bundle) are not +// indexed by Siri / Spotlight / Shortcuts unless the app declares the package. Providing +// this type lets the app target opt in: +// +// struct MyApp: App, AppIntentsPackage { +// static var includedPackages: [any AppIntentsPackage.Type] { +// [AEGISOmegaAppIntentsPackage.self] +// } +// } +// +// AppIntents ships only in Apple SDKs, so this is guarded like the other intent sources. +#if canImport(AppIntents) +import AppIntents + +public struct AEGISOmegaAppIntentsPackage: AppIntentsPackage { + public init() {} +} +#endif diff --git a/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppShortcuts.swift b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppShortcuts.swift new file mode 100644 index 000000000..9834d2a9c --- /dev/null +++ b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISAppShortcuts.swift @@ -0,0 +1,39 @@ +// AppIntents ships only in Apple SDKs. Guarded so the pure-Swift core and its +// tests still build on Linux/Windows; on Apple platforms this compiles unchanged. +#if canImport(AppIntents) +import AppIntents + +public struct AEGISAppShortcuts: AppShortcutsProvider { + public static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ContinueContextIntent(), + phrases: [ + "Continue context in \(.applicationName)", + "Remember this with \(.applicationName)", + ], + shortTitle: "Continue context", + systemImageName: "brain.head.profile" + ) + + AppShortcut( + intent: StartGovernedSessionIntent(), + phrases: [ + "Start a governed session in \(.applicationName)", + "Begin a session with \(.applicationName)", + ], + shortTitle: "Start session", + systemImageName: "point.3.connected.trianglepath.dotted" + ) + + AppShortcut( + intent: InspectEvidenceIntent(), + phrases: [ + "Inspect evidence in \(.applicationName)", + "Open evidence with \(.applicationName)", + ], + shortTitle: "Inspect evidence", + systemImageName: "checkmark.seal" + ) + } +} +#endif diff --git a/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISIntentRoute.swift b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISIntentRoute.swift new file mode 100644 index 000000000..ab76a668e --- /dev/null +++ b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISIntentRoute.swift @@ -0,0 +1,14 @@ +import Foundation + +/// The single system-facing route passed from App Intents into the main scene. +public enum AEGISIntentRoute: Equatable, Sendable { + case continueContext(text: String?) + case startSession(objective: String?, mode: AEGISSessionMode) + case inspectEvidence(reference: String?) +} + +public enum AEGISSessionMode: String, CaseIterable, Codable, Sendable { + case focused + case governed + case recovery +} diff --git a/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISIntentRouter.swift b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISIntentRouter.swift new file mode 100644 index 000000000..1693eefad --- /dev/null +++ b/clients/aegis-omega-app-intents/Sources/AEGISOmegaAppIntents/AEGISIntentRouter.swift @@ -0,0 +1,34 @@ +import Foundation +import Observation + +/// Main-actor handoff point observed by the app's root scene. +@MainActor +@Observable +public final class AEGISIntentRouter { + public struct Handoff: Identifiable, Equatable, Sendable { + public let id: UUID + public let route: AEGISIntentRoute + + public init(id: UUID = UUID(), route: AEGISIntentRoute) { + self.id = id + self.route = route + } + } + + public static let shared = AEGISIntentRouter() + + /// Read-only to callers: mutation must go through `accept(_:)` / `clear(_:)` so the + /// id-matching guard in `clear(_:)` cannot be bypassed by an external write. + public private(set) var pendingHandoff: Handoff? + + private init() {} + + public func accept(_ route: AEGISIntentRoute) { + pendingHandoff = Handoff(route: route) + } + + public func clear(_ handoff: Handoff) { + guard pendingHandoff?.id == handoff.id else { return } + pendingHandoff = nil + } +} diff --git a/clients/aegis-omega-app-intents/Tests/AEGISOmegaAppIntentsTests/AEGISIntentRouteTests.swift b/clients/aegis-omega-app-intents/Tests/AEGISOmegaAppIntentsTests/AEGISIntentRouteTests.swift new file mode 100644 index 000000000..b07706a0b --- /dev/null +++ b/clients/aegis-omega-app-intents/Tests/AEGISOmegaAppIntentsTests/AEGISIntentRouteTests.swift @@ -0,0 +1,85 @@ +import XCTest +@testable import AEGISOmegaAppIntents + +// Every test method here is nonisolated and `async`, and the main-actor hop happens inside +// the body. SwiftPM builds its test-discovery list out of unapplied method references, so a +// method's isolation leaks into that list's element type: `@MainActor` methods produce +// `(Self) -> @MainActor () -> Void`, which off-Apple fails to compile when mixed with +// nonisolated methods and fails to *cast* at run time when it isn't. Keeping the whole class +// nonisolated keeps the element type uniform and castable, so the tests are actually invoked. +final class AEGISIntentRouteTests: XCTestCase { + + /// Returns the router to a known-empty state through its public API only, + /// since `pendingHandoff` is read-only to callers. + @MainActor + private func drain(_ router: AEGISIntentRouter) { + while let h = router.pendingHandoff { router.clear(h) } + } + + // Exercises the AppIntents-facing enum, so it can only run where AppIntents exists. + #if canImport(AppIntents) + func testSessionModesMapWithoutLosingMeaning() async { + XCTAssertEqual(AEGISSessionModeIntentValue.focused.domainValue, .focused) + XCTAssertEqual(AEGISSessionModeIntentValue.governed.domainValue, .governed) + XCTAssertEqual(AEGISSessionModeIntentValue.recovery.domainValue, .recovery) + } + #endif + + func testRouterReplacesThePendingHandoffAtomically() async { + await MainActor.run { + let router = AEGISIntentRouter.shared + defer { drain(router) } + + router.accept(.continueContext(text: "first")) + let first = router.pendingHandoff + + router.accept(.inspectEvidence(reference: "receipt-42")) + let second = router.pendingHandoff + + XCTAssertNotEqual(first?.id, second?.id) + XCTAssertEqual(second?.route, .inspectEvidence(reference: "receipt-42")) + } + } + + // The route model is pure Foundation, so these run on every platform. + func testRouteEqualityDistinguishesPayloads() async { + XCTAssertEqual( + AEGISIntentRoute.continueContext(text: "a"), + AEGISIntentRoute.continueContext(text: "a") + ) + XCTAssertNotEqual( + AEGISIntentRoute.continueContext(text: "a"), + AEGISIntentRoute.continueContext(text: "b") + ) + XCTAssertNotEqual( + AEGISIntentRoute.startSession(objective: nil, mode: .focused), + AEGISIntentRoute.startSession(objective: nil, mode: .governed) + ) + } + + func testSessionModeRoundTripsThroughItsRawValue() async { + for mode in AEGISSessionMode.allCases { + XCTAssertEqual(AEGISSessionMode(rawValue: mode.rawValue), mode) + } + XCTAssertNil(AEGISSessionMode(rawValue: "not-a-mode")) + } + + func testClearOnlyDropsTheMatchingHandoff() async { + await MainActor.run { + let router = AEGISIntentRouter.shared + defer { drain(router) } + + router.accept(.startSession(objective: "audit", mode: .recovery)) + guard let stale = router.pendingHandoff else { return XCTFail("expected a handoff") } + + router.accept(.inspectEvidence(reference: "receipt-7")) + router.clear(stale) + + XCTAssertEqual( + router.pendingHandoff?.route, + .inspectEvidence(reference: "receipt-7"), + "clearing a superseded handoff must not drop the current one" + ) + } + } +}