Commit Graph
95 Commits
Author SHA1 Message Date
james.maesandClaude Opus 4.7 5ebe510627 docs: scrub Phase-7 + JarvisBusWorld doc drift (#19, #45) (#195)
* docs(memory): scrub Phase-7 hedge language from MemoryStore (#19)

Audit 2026-05-12 flagged five doc-comment references that mis-described
the implementation state. The cited plans (07-02, 07-03, 07-06) shipped
months ago — the bounded extraction channel, the hybrid FTS5 + vec0 read
path, the ReplayEvent emission, the regression corpus test seams, and
`installMemory`'s replay-sink wiring are all live, not "wires" or
"will replace" promises.

Five touchpoints rewritten to describe the code as it is:

- `MemoryReplaySink` doc: drop the "Plan 07-06" plan reference; the seam
  is just used at startup.
- `MemoryStore` actor doc: replace the "Plan 07-02 wires / Plan 07-03
  wires" sentence with the actual contract (single writer; hybrid search
  through `MemoryReadStore`; `recordRetrieval` emits `memoryRetrieval`).
- Read-path MARK: drop the "Plan 07-03" plan label; keep the
  requirement-ID list (MEM-07 / TEXT-03 / D-02 / D-05) since those are
  still useful for traceability to the audit corpus.
- `recordRetrieval` doc: replace "Mirrors recordMemoryMutation from 07-02"
  with "Mirrors recordMemoryMutation on the write path."
- Test-seams MARK: drop "Plan 07-06" prefix; describe by the test target
  (JARVIS_REAL_MODELS=1 corpus suite) instead.

The 2026-05-11 D-5/D-6 markers and the "Boot policy (changed 2026-05-11)"
doc are preserved unchanged — they document current behaviour, not
historical hedges.

`swift build --package-path packages/Memory` clean.

Closes #19

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(bus): scrub JarvisBusWorld drift + add grep gate (#45)

`fb41c5f` (2026-05-02) unified `Injection.js` and the HUD bundle into the
default `WKContentWorld.page` and retired the prior isolated
`JarvisBusWorld` named world. The doc-comments and test fixtures across
20+ lines kept describing the bug-era topology — pre-existing-bus attach
"because cross-world isolation", message-handler-only-visible-in-named-
world, etc. — for months afterward. Result: a new reader following the
docs would conclude the bus uses two worlds when it actually uses one.

Five rationale rewrites:

- `WebviewBridge.swift` class doc — handler is on the page world, not an
  isolated named world. Page scripts in the loaded HUD bundle address it
  via `window.webkit.messageHandlers.jarvisBus`.
- `Injection.js` header — names the page world explicitly; one-line nod to
  `fb41c5f` for the historical context.
- `bridge.ts` JSDoc + `isJarvisBusShape` rationale — recast the H-02
  attach-to-existing path as a "defensive double-install guard" inside
  ONE page world. Injection.js fires first at document-start; the bundle
  installer fires later as a top-level `main.tsx` side-effect; the guard
  prevents the bundle from clobbering Injection.js's queued early
  messages and captured handshake state. The duck-type `isJarvisBusShape`
  CODE is unchanged — only the comment narrative was misleading.
- `webview/packages/hud/src/main.tsx` — drop the cross-world-isolation
  framing; describe the single-world reality.
- `App/AppDelegate.swift` install comment — page world, not isolated.

Test-side updates (same scrub, plus one latent test bug surfaced):

- `WebviewBridgeTests.test_scriptMessageHandlerIsRegisteredUnderConfigured-
  Name`: was removing from `WKContentWorld.world(name: "JarvisBusWorld")`
  while the bridge registered against `.page` — silent no-op since
  `removeScriptMessageHandler` doesn't throw on absent. Now removes from
  `.page` (the world the bridge actually uses), making the
  "registration-is-evidence-of-success" assertion meaningful again.
- `WebviewBridgeOutboundTests`: comment-only.
- `RealWKWebViewIntegrationTests`: comment-only (forensic narrative kept,
  literal world name replaced with "prior isolated named content world").
- `webview/packages/bus/tests/bridge.test.ts`: H-02 describe-block + one
  it-name rewritten to match the new defensive-double-install rationale.

Infrastructure:

- `scripts/check-no-jarvis-bus-world-references.sh`: new boundary gate.
  Greps for the literal `JarvisBusWorld` in tracked files; excludes
  `.planning/`, `docs/`, `webview/packages/*/dist/`, and this script
  itself. Wired into `CLAUDE.md`'s gate listing; the CI
  `boundary-gates` job auto-discovers all `scripts/check-*.sh`, so no
  workflow edit is needed.
- `webview/packages/bus/package.json`: 0.0.0 → 0.0.1 (per spec —
  comment-only changes still bump patch so consumers can pin).
- `webview/packages/bus/dist/bridge.{js,d.ts}`: rebuilt via `pnpm --filter
  @jarvis/bus build`; NOT committed because `dist/` is not tracked (the
  build phase regenerates on each xcodebuild invocation).

Verified locally:
- `swift build --package-path packages/Bus` clean
- `pnpm --filter @jarvis/bus test` — 37/37 pass
- `bash scripts/check-no-jarvis-bus-world-references.sh` — PASS
- `bash scripts/check-bus-protocol-version.sh` — PASS (v2.3.0)
- `bash scripts/check-bus-harness-parity.sh` — PASS

Closes #45

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:51:03 -05:00
james.maes a8c7f1a09b chore(deps): batch Dependabot bumps — fiber + plugin-react + jsdom + vite (#144, #145, #147, #148, #160)
Lands four SAFE-MERGE Dependabot bumps in one commit. Each was confirmed SAFE-MERGE by the H' triage on PR #155, then re-verified locally on the new lockfile baseline (vitest@3 + vite@8 + esbuild dropped) from PR #159.

- @react-three/fiber 9.6.0 → 9.6.1 (closes #148)
- @vitejs/plugin-react 5.x → 6.0.1 (closes #147)
- jsdom 25.x → 29.1.1 (closes #144)
- vite pinned to >=8.0.12 via pnpm.overrides (closes #145)

37/37 bus + 101/101 hud tests; both workspaces build; production webview bundle stamps cleanly. PR #146 (TypeScript 6) NOT included; deferred for focused evaluation.
2026-05-13 06:47:16 -05:00
james.maes 795c3bf5c9 feat(app): v0.1 UX surfaces — startup panels + InputMonitoring banner + PTT (#87, #88, #89, #156)
#89: new AppDelegate.startupSurfacesMode enum (.minimal/.diagnostic). Debug defaults .diagnostic; Release defaults .minimal. openStartupSurfacesIfDiagnostic() runs at tail of bootHealthTask (skipped under XCTest or when wizard is open). Status panel top-right + Dev Overlay bottom-right of NSScreen.main.visibleFrame. DevOverlay opens to Logs tab.

#88: BannerContent.inputMonitoringDenied is now nonDismissible: true. New HUDBannerCoordinator.dismiss(id:) clears a banner programmatically without writing dismissedThisLaunch. AppDelegate.reprobeInputMonitoring fires on NSApplication.didBecomeActiveNotification; rebinds the global hotkey on re-grant. Uses IOHIDCheckAccess (query-only, no prompt) per CLAUDE.md guidance. 3 new coordinator tests.

#87: WizardState.pttHotkey persisted under Jarvis.pttHotkey (independent of summon hotkey). Ships unbound per CLAUDE.md hotkey hygiene. AppDelegate.rebindPTTFromState() called from installVoice, foreground TCC re-grant, Settings Save/Clear. SettingsView extended with PTT hotkey section. Audio-graph isolation: NSEvent monitors only, no audio-graph surgery — safe alongside fix/voice-crit-v0.1. 3 new WizardState tests.

DevOverlay 14 + Voice 26 tests pass; all 17 grep boundary gates green (incl. single-writer-hudstate + no-modal-presentation).
2026-05-13 06:32:16 -05:00
james.maes bc74494ada fix(mcp): v0.1 CRIT cluster — turnID resolver wired + tool-result double-cap (#20, #21, #157)
#21 (CRIT): ToolResultPacker.pack(_:) is now a pass-through decoder; redundant inner 8 KB cap removed. MCP dispatcher's SanitizeForModel.prepareForBoundary(_, capBytes: 8192) is the sole cap site. Doc-comment rewritten honestly: replay row reflects boundary-prepared bytes (8 KB), not original helper output. Replay fidelity beyond 8 KB lifted to v0.5/M-3 once SanitizeForModel can move to a shared module without dep cycle.

#20 (CRIT): ReplayingToolResultObserver.turnIDResolver lock-protected via OSAllocatedUnfairLock<ResolverBox> (same Swift-6-async-safe pattern as InProcessConfirmationCache). New attach(turnIDResolver:) method. AgentOrchestrator.currentTurnID() actor-isolated accessor. installAgent attaches the resolver immediately after orchestrator assignment, with defensive systemLogger.warning if observer type mismatches.

Tests: ToolResultPackerTests TP2/TP5 updated to assert no [TRUNCATED] marker; OrchestratorSubmitTests OS6 reframed + new OS6b reads SQLite events table directly to assert 8 KB exact; MCPRuntimeWiringTests adds attach pre/post-attach + replay-log dual-write correlation.

AgentCore 243 + Memory 98 + Replay 34 + MCP all green; all 18 boundary gates green.
2026-05-13 06:31:46 -05:00
james.maes 4b1dd6404b fix(voice): v0.1 CRIT cluster — wake / STT / TTS / boot probes (#28, #29, #30, #31, #32, #33, #34, #36, #158)
#28 (CRIT): setCancelInFlight calls stopFeed() (preserves stream); rebuild consumer re-arms DAG against new ring. Wake-word no longer dies on audio-graph rebuild.

#29 (CRIT): sttFactory reads PerTurnSnapshot.stt.whisperKitFallback; get_self_state returns canonical snake_case backend constants. Factory signature: () -> any STTProvider → () async -> any STTProvider (existing test sites compile unchanged under Swift 6 implicit-async-closure rules).

#30 (CRIT): TTSEngineActor.synthesize emits .ttsStopped after .finished on natural completion (two-emit-site model: barge-in + natural-completion).

#31 (CRIT): VoiceTTSAdapter.tierResolver wired to read snap.tts.tier from configStore.

#32 (HIGH): Rolling-window trim in OpenWakeWordSession.runORT — mel ≤ 84, embeddings ≤ 16, nextEmbStart slides on drop.

#33 (HIGH): three new boot probes — WakeWordBootHealthProbe, STTBootHealthProbe, TTSBootHealthProbe. New WakeWordDAG.isFeedArmed + VoiceTTSAdapter.hasEngine accessors.

#34 (HIGH): vad.reset() at top of startSTTSession clears Silero VAD LSTM hidden state + wasSpeech between sessions.

#36 (HIGH): new Broadcaster.Priority.agentHud (protects stateChange/turnEnd/error); installAgent adds subscriber pumping AgentHudIntent into dormantAgentContinuation.

Voice 90 + AgentCore 242 tests pass; all 18 boundary gates green.
2026-05-13 06:31:17 -05:00
james.maes f59f5d12e3 fix(memory): v0.1 CRIT cluster — facts_vec / replay correlation / FTS5 sanitize / search_memory preamble (#12, #13, #14, #15, #16, #153)
#14 (CRIT): ContextBuilder.selfAwarePreamble names search_memory + forget_fact with explicit cue 'call search_memory FIRST. Never claim to remember without searching.'

#16 (HIGH): MemoryQueries.sanitizeFTS5Query strips non-alphanumeric, quotes tokens; HybridSearch.searchFacts calls it before binding to MATCH ?. Embedding still runs on raw query.

#12 (CRIT): MemoryStore.init accepts optional embedder; insertNewFact writes facts_vec after the facts commit. Vec failures log + leave FTS row (no rollback). installMemory shares one embedder between writer + reader.

#15 (HIGH): drop stableHash; pass sentinel 0 as sourceTurnId (option 1 per audit — smaller change surface than threading real rowid through 5 files). Schema comment updated.

#13 (CRIT): two-layer fix. Layer 1: AppDelegate owns a per-process sentinel TurnID; installAgent writes the matching turns row after beginSession; AppDelegateMemoryReplaySink attributes events to it. Layer 2: ReplayLog.flushPending retries failed batches event-by-event so one poisoned row only drops itself.

Memory 98 + Replay 34 + AgentCore 243 tests pass; all 18 boundary gates green.
2026-05-13 06:30:35 -05:00
james.maes 05411f82ff fix(vision): v0.1 cluster — T2 honesty + pendingFrame leak + TCC revoke observation (#1, #2, #3, #152)
#1 (HIGH): new hasRealT2Provider init param on AgentOrchestrator; t2AvailableForThisTurn now requires both decision.tier != .t3Cloud AND hasRealT2Provider. Production passes false (MissingT2Provider only). Pre-existing escalation test still passes true with mock T2.

#2 (MED): new FrameAttachController.releaseAfterRejectedSubmit() routes through the sole discardFrame() emission site (D-15 single-emission grep gate still green). Both handleChatSubmit and handleChatCancelAndSubmit call it on .rejected.

#3 (MED): AVCaptureSessionWasInterruptedNotification observer yields .midSessionRevoked. macOS limitation surfaced: AVCaptureSessionInterruptionReasonKey is API_UNAVAILABLE(macos), so we cannot discriminate by reason — any interruption surfaces the banner. NSApplication.didBecomeActive observer calls becameAuthorized() on non-authorized → .authorized flip.

Vision 75 + AgentCore 243 tests pass; all 18 boundary gates green.
2026-05-13 06:30:09 -05:00
james.maes a6f91e6b73 fix(bus,hud): v0.1 S1 cluster — webview didFail + menu-bar icon transition (#40, #41, #151)
#40: BridgeNavigationDelegate gains onDidFail closure (covers didFailProvisionalNavigation + didFail). WebviewBridge.failHandshake(reason:) transitions to new terminal .loadFailed state, cancels pending 2s timeout, fires existing alertPresenter, idempotent. AppDelegate.handleWebviewLoadFailure dual-surfaces: failHandshake + critical non-dismissible banner (defense-in-depth across test seams). WebviewBootHealthProbe gets .loadFailed case.

#41: HudStateCoordinator emit closure now ALSO calls menuBarController.transition(to:) in addition to bridge.send(.hudState(...)). Doc-comment correctly notes this is not a HUD-08 single-writer violation — transition writes currentState, not BusOutbound.hudState. scripts/check-single-writer-hudstate.sh still green.

Tests: 4 new Handshake cases (load-failure-from-sentHello with alert-not-duplicated, from-idle, idempotency, post-armed no-op), BridgeNavigationFailureTests end-to-end + per-arm, MenuBarIconStateTransitionTests drives dormantAgentContinuation and asserts .thinking → .speaking → .idle. 62/62 Bus pass; all 18 boundary gates green.
2026-05-13 06:29:34 -05:00
james.maes 5bb1e778b5 fix(hud): chat panel auto-scroll on streaming append — B-06 tactical (#51, #150)
Adds a useLayoutEffect on ChatPanel keyed to total content length (captures streaming token append where chatEvents.length is constant). 24px bottom-pin hysteresis; user scroll-up breaks the pin; auto-re-arms on scroll-back-to-bottom. scrollTo feature-detected with scrollTop fallback for JSDOM. 4 vitest cases assert decision logic.
2026-05-13 06:27:42 -05:00
james.maesandClaude Opus 4.7 281b9093e7 docs(appdelegate): apply commenting style guide (P0 of #64)
Rewrites App/AppDelegate.swift docstrings + inline comments to the
.planning/code-commenting-guide.md standard. Comments + docstrings
only — zero code behavior changes.

Closes the P0 entry in the migration table (#65 under epic #64).

Highlights:
- File header: 5-line opener + "See also" + audit-trail suffix.
- Class docstring: install-order ASCII DAG, threading model,
  "Before you edit" block listing the five sharp-edged invariants
  (single-writer HudState, locked install order, shared in-process
  tool registry, JIT entitlement, no-modal presentation).
- Property docstrings reorganized under role-named MARKs
  (Test-injectable seams / AppKit surfaces / HUD state coordinator
  + bus producer continuations / Orch→replay seam + MCP runtime /
  Boot health / Voice / Memory / Vision / Agent). Every property
  now leads with WHY; audit references (Plan IDs, B-/D-/CR- numbers,
  dated audits) are parenthetical suffixes.
- Public + key private method docstrings rewritten:
  applicationWillFinishLaunching gets the 16-step bootstrap + the
  hard-block branches called out; applicationWillTerminate explains
  why each cancellation is explicit; runBootHealth + register-
  BootHealthProbes list every probe + its role; installVoice /
  installMemory / installAgent / installVision /
  installSelfKnowledgeTools open with the subsystem's
  responsibility + fail-soft-vs-hard policy + step sequence.
- The motivating bad example from the guide
  (`inProcessToolRegistry` Plan-ID-lede comment) is rewritten
  exactly as the guide prescribes.
- Inline comments still carrying Plan-ID ledes ("Phase E 2026-05-03
  audit fix:", "Track B-4 (...):") rewritten to lead with WHY/WHAT
  with audit trail demoted to parentheticals at end.

Boundary gates:
- 18/18 green (full sweep: app-builds, bus-harness-parity,
  bus-protocol-version, corpus-secrets, embedding-dim-literal,
  install-order, no-evaluate-javascript, no-leftover-stubs,
  no-modal-presentation, no-null-voice-adapters,
  orchestrator-events-single-consumer,
  presence-bus-no-tts-orchestrator, presence-vision-isolation,
  single-memory-mutated-emit, single-memory-used-emit,
  single-writer-hudstate, vision-isolation,
  applescript-confirmation).
- check-install-order verified literal Task spawn lines + the
  `await self?.agentInstallTask?.value` invariant are intact.
- check-single-writer-hudstate verified AppDelegate still only
  references `.hudState(_:)` inside the allowlisted wiring closure.

Surfaced during review:
- None — only comments touched. The audit-2026-05-04 P3-16 split
  (AppDelegate+Voice/+Vision/+Memory/+Agent) remains outstanding
  but is out of scope for this P0.

Line delta: +965 / -740, net +225. Net growth is from the install-
order DAG ASCII art + the "Before you edit" block + expanded
six-step method docstrings.

Refs: #64, closes #65.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:08:33 -05:00
james.maes 50c2783a8c feat(devoverlay): wire DevSnapshotEmitter end-to-end + add tabbed view
The DevOverlay panel has been silently rendering DevSnapshot.initial since
Phase 4 because three independent breaks left the data path open-circuit
(see .planning/audit-2026-05-12/devoverlay-diagnosis.md for the full trace).

Slice 2 closes all three:

1. AppDelegate constructs `DevSnapshotEmitter` in `installAgent` and
   stores it on the delegate. Provider/model is set from configStore
   so the header row shows the active provider rather than a blank.

2. The `.devOverlay` broadcaster subscriber that previously drained to
   `/dev/null` now calls `emitter.apply(event)` for every event. This
   produces real DevSnapshots that flow through the bounded output
   channel (capacity 32, .dropOldest — AGENT-10 observational seam).

3. `DevOverlayWindow` now accepts the live emitter + BootHealthOrchestrator
   + a HudState reader closure. It instantiates its own DevOverlayBridge
   and attaches the bridge to `emitter.output` so viewModel.apply is
   actually called as snapshots arrive.

Also reshapes the SwiftUI surface as a TabView with three observational
panes (Agent, Tools, Health). The Health pane is read-only — the Status…
menu remains the place to trigger re-probes. The Logs pane lands in
Slice 3 (live log stream).

ViewModel extends with `bootHealth` + `hudStateLabel` slices, each with
its own single-writer mutator and `private(set)` enforcement.

Verification:
- swift test --package-path packages/DevOverlay → 14/14 pass
- swift test --package-path packages/AgentCore --filter DevSnapshotEmitterTests → 9/9 pass
- bash scripts/check-app-builds.sh → PASS
- 18/18 boundary gates green
2026-05-12 14:11:19 -05:00
james.maesandClaude Opus 4.7 740bed4cbf fix(round-4): tag-agnostic ollama probe + mcp probe counts in-process tools
Two false-positive degraded reports surfaced during the second
Round 4 dogfood run:

1. Ollama probe was exact-string matching against required model
   names. `requiredModels` had `"nomic-embed-text"` (no tag), but
   `/api/tags` returns `"nomic-embed-text:latest"` (with default
   tag) — so the probe reported "missing models: nomic-embed-text"
   even after `ollama pull` succeeded. Fix: when a required name
   has no `:tag`, accept any tag (`name == required OR
   name.hasPrefix(required + ":")`). Required names that DO
   include a tag (e.g., `qwen3.6:latest`) keep their strict match.

2. MCP probe was reading ONLY `runtime.client.registeredToolNames()`
   — the stdio side (3 helpers: get_clipboard, get_time,
   run_applescript). The other 4+ in-process tools (self-knowledge
   + memory) live in `InProcessToolRegistry` and are routed
   through `InProcessAwareToolDispatcher` (commit 1b7fb81's B-01b
   fix). Probe now reads both registries, unions the names, and
   reports the combined count. Evidence line breaks out stdio vs
   in-process counts so the operator can see the split.

Verified: app build clean, app target compiles. Re-launch should
show ollama=ok (all 3 models present) and mcp=ok (8 tools after
self-knowledge tools register at install step 15).

The Toby fix verification mid-dogfood: the model received the
degradation summary in its system prompt and truthfully reported
"my memory subsystem is currently degraded (nomic-embed-text is
missing)" instead of yesterday's fake "Got it" promise. Round 4
slice 3 confirmed working end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:29:58 -05:00
james.maesandClaude Opus 4.7 a9531e65e3 feat(round-4): menu-bar icon turns red when subsystems are degraded
Slice 4 of Round 4 — the visible polish. With Slices 1–3 the agent
preamble + non-dismissible banner cover the "agent must know +
user must see" loop, but a closed HUD with no banner visible can
still hide the warning. This slice paints the menu-bar icon red so
the warning is glanceable from anywhere on the screen.

Option A from the slice plan (single-writer-safe):

- `MenuBarIconController.applyHealth(_ health: OverallHealth)` paints
  `button.contentTintColor = NSColor.systemRed` for `.loud` and
  `.critical`, clears the tint for `.ok` and `.soft`. Template images
  recolor automatically with `contentTintColor` so no second icon
  asset is needed.
- `currentHealth: OverallHealth?` exposed read-only for tests.
- `AppDelegate.runBootHealth` calls `menuBarController?.applyHealth(
  snapshot.overallHealth)` after computing the degradation summary,
  so the icon updates on every probe sweep (cold boot + Status panel
  re-probe).

Single-writer invariant for HudState (HUD-08) is intact:
`contentTintColor` lives on `NSStatusItemButton`, not in HudState.
`scripts/check-single-writer-hudstate.sh` is green.

1 new test in MenuBarIconControllerTests:
  * test_applyHealthTintsRedForLoudAndCritical — exercises all four
    OverallHealth cases and asserts contentTintColor toggles correctly.

Boundary gates 18/18 green; App target compiles cleanly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:59:48 -05:00
james.maesandClaude Opus 4.7 23b4ca7712 feat(round-4): agent system prompt knows when subsystems are degraded
Slice 3 of Round 4 — the Toby-the-dog fix. With Slice 1 the probes
report severity and with Slice 2 banners route by severity, but the
critical behavioral change is here: the agent's system prompt itself
must carry the degradation truth so the model can't confidently lie
about features that don't work.

The Toby case in detail: user said "remember my dog Toby is a golden
retriever". The Ollama probe correctly reported missing nomic-embed-text
(degraded). The memory extractor tried to embed Toby, the embed call
failed, no row was inserted in `facts`. But the agent's system prompt
said "you are Jarvis with memory" and the model answered "Got it —
Toby." A lie, dressed up as competence.

Implementation:

- `AgentOrchestrator` gains a private `var degradationSummary: String?`
  + a public `setDegradationSummary(_:)` actor-isolated setter. Mutable
  because installAgent runs BEFORE runBootHealth in the boot order;
  the orchestrator is constructed with `nil` and AppDelegate pushes
  the summary in post-construction. Re-probes from the Status panel
  update the value mid-session.
- `runTurn` prepends the summary BEFORE `composedSystem` (which already
  includes the UntrustedWrapper nonce + locked self-aware preamble),
  so the warning lands at the very top of the system message — outranks
  the preamble, outranks presence/memory enrichment.
- `AppDelegate.runBootHealth` now calls
  `orchestrator.setDegradationSummary(buildDegradationSummary(snapshot:))`
  after every probe sweep. The builder produces text like:

    CRITICAL — SUBSYSTEMS DEGRADED:
    - memory: failed (vec0 missing) — DO NOT promise to use memory
    - ollama: degraded — missing models: nomic-embed-text
    Be honest. Do not claim to use tools that are unavailable. Do not
    pretend memory works if it doesn't.

  When every subsystem is `.ok`, the function returns `nil` and the
  orchestrator's behavior is identical to pre-Round-4.

- Cache-hints invariant preserved: the locked preamble is ~700 chars,
  the worst-case degradation summary is <500 chars; sum stays well
  under the 4096-char Anthropic cache-eligibility boundary so adding
  the summary doesn't unintentionally flip `cache_control` emission.
  CacheHintsEligibilityTests/preambleDoesNotEnableCacheUnintentionally
  remains green.

4 new tests in AgentOrchestratorDegradationPreambleTests (all pass):
  * DP1: initial summary threaded into first turn's system message
  * DP2: nil summary leaves prompt unchanged (no "CRITICAL" / "DO NOT")
  * DP3: setDegradationSummary updates the next turn's prompt
  * DP4: setting summary back to nil clears it from the next turn

Boundary gates 18/18 green. `swift test --package-path packages/AgentCore`
runs 242/240 (4 new minus 2 pre-existing unrelated failures from
qwen2.5-coder model-ID literal drift, present before Round 4).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:47:58 -05:00
james.maesandClaude Opus 4.7 79955b67b7 feat(round-4): non-dismissible banners for critical subsystem failures
Slice 2 of Round 4. With Slice 1 the probe layer now reports severity;
this slice routes severity to the banner system so the user CANNOT
silently dismiss a "your memory is dead, Jarvis will lie to you" warning.

- `BannerContent.nonDismissible: Bool` (default false).
- `HUDBanner` hides the dismiss X when `content.nonDismissible == true`.
- `HUDBannerCoordinator.dismissCurrent()` no-ops on non-dismissible
  banners; the banner stays on screen until programmatically cleared
  (`clear()` on app quit, or a re-probe finding the subsystem healthy
  enqueues a different banner that pre-empts it).
- Two new presets:
  * `bootHealthCritical(subsystem:reason:)` — priority 1, non-dismissible.
    Title format: "Memory: critical failure". Body promises that the
    agent will be warned not to rely on this subsystem (Slice 3).
  * `bootHealthLoud(subsystem:reason:)` — priority 3, dismissible.
- Existing `bootHealthFailed(...)` preset kept as backwards-compat
  fallback (no current call sites after this slice).
- `AppDelegate.enqueueFailedBootHealthBanners` rewritten to iterate
  every non-ok subsystem and route by severity:
    .critical → bootHealthCritical + `[system] CRITICAL` log line
    .loud     → bootHealthLoud
    .soft     → no banner (Status panel only)
  Previously only `.failed` cases enqueued banners, which silently
  dropped `.degraded` (the Toby case) entirely.

Two new tests in HUDBannerCoordinatorTests cover the new behavior:
  * test_nonDismissibleBannerIgnoresDismiss
  * test_criticalBannerPreemptsLowerPriorityCurrent
(Note: `xcodebuild test -only-testing JarvisAppTests` is pre-existing-
broken on this checkout due to onnxruntime static-link duplication —
confirmed by `git stash` before this slice. The test code compiles
cleanly via the regular App build; SPM-target tests for the
AgentCore-side severity model are green.)

Boundary gates 18/18 green. `swift test --package-path packages/AgentCore
--filter BootHealthOrchestratorTests` → 13/13.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:36:43 -05:00
james.maesandClaude Opus 4.7 cfd469f72b feat(round-4): probe severity model — ok/soft/loud/critical
Round 4 of boot-health work. The Toby-the-dog incident — Jarvis said "Got
it" while the embedder model was missing and the fact never persisted —
proved that Round 2's probe banner was too quiet. Probes were already
reporting `.degraded` correctly; what was missing was a way for the App
layer to route a critical failure differently from a soft one (e.g.,
non-dismissible banner for memory off vs. silent Status row for camera
TCC not yet requested).

This slice introduces a severity axis without changing behavior:

- `FailureSeverity { critical, loud, soft }` on `ProbeStatus`'s non-ok
  cases (degraded/failed/unknown). `.ok` carries no severity.
- `ProbeStatus.severity` accessor (nil for `.ok`).
- `BootHealthSnapshot.overallHealth: OverallHealth { ok, soft, loud,
  critical }` — worst severity wins; `.ok` only if every subsystem ok.
- Each concrete probe annotates its failure modes:
  * Memory: failed → .critical (agent grounding; the Toby case)
  * Anthropic: missing/empty key → .critical (agent can't run)
  * Ollama: missing models → .loud (fact extraction broken, chat fine)
  * Voice: mic denied → .loud; dormant install → .soft
  * Vision: TCC denied → .soft (vision is optional)
  * MCP: 0 tools → .critical; <minimum → .loud
  * Replay: init failed → .critical; missing file → .loud
  * Webview: mismatched/timedOut → .critical; sentHello → .soft
- DormantSubsystemProbe gains `severity` parameter (default .soft);
  AppDelegate wires memory-dormant as .critical.

No behavioral change yet — banners still treat all failures equally;
the agent system prompt is unchanged. Slices 2 + 3 land those.

Tests: 13/13 in BootHealthOrchestratorTests pass, including 4 new tests
for overallHealth + severity accessor + JSON roundtrip with severity.
2 pre-existing failures in LLMEventTests/OllamaRequestBodyTests are
unrelated (qwen model-ID literal drift from commit 8d0a959, present
before this slice — confirmed via git stash).

Boundary gates 18/18 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:24:43 -05:00
james.maesandClaude Opus 4.7 b2f0abb670 fix(round-2): await probe registration inline to close runAll race
registerBootHealthProbes was spawning 8 fire-and-forget Task blocks,
each doing `await bootHealthOrchestrator.register(...)`. Then
runBootHealth called `await bootHealthOrchestrator.runAll()` —
which could fire BEFORE any register Task landed, yielding an empty
snapshot. Worst case: silent failure (no log lines, no banners,
empty Status panel).

The dogfood pass on commit 67748f5 happened to land all 8 register
Tasks before runAll fired (race won), but that's accidental — the
JS task queue scheduling is not load-bearing.

Fix: convert registerBootHealthProbes to `async` and await each
register call inline. runBootHealth then awaits the helper before
calling runAll. The orchestrator's runAll fan-out continues to use
TaskGroup for parallel probe execution, so this change has no
runtime perf impact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:35:00 -05:00
james.maesandClaude Opus 4.7 67748f5336 feat(round-3): Status… menu item + NSPanel — live re-probe on open
New AppKit panel (NSPanel + SwiftUI hosting view) renders the live
BootHealthSnapshot, re-probes on every appearance, and offers
"Re-probe all" + "Copy report" buttons. Operator/dev tool, not a
user-facing surface — AppKit deliberately so it doesn't ride the
R3F render pipeline.

Anti-fake-status carries forward from Round 2: the panel's `.task`
modifier fires a fresh `runBootHealth()` sweep on every open, never
showing a cached boot snapshot. Cmd+R re-probes; Cmd+Shift+C copies.

Non-modal: NSPanel with [.titled, .closable, .resizable,
.nonactivatingPanel] + makeKeyAndOrderFront(_:) — no runModal calls.
scripts/check-no-modal-presentation.sh stays green.
isReleasedWhenClosed=false so the user can close and re-open without
re-creating the model (preserves "Copied X B" caption + last
snapshot across opens, in addition to surviving the AppKit
"window deallocs on close" default that would otherwise crash on
second open).

Files:
  - App/MenuBar/StatusPanel.swift (new):
    * StatusPanelModel — @MainActor ObservableObject, owns the
      snapshot + isProbing + copyConfirmation @Published state.
      `probeRunner` closure injected so "Re-probe all" hits the
      production AppDelegate.runBootHealth() path (single source
      of truth — panel + boot use the same code).
    * StatusPanelView — SwiftUI body. Scrollable rows, header
      with spinner during probe, footer with re-probe + copy
      buttons + caption.
    * SubsystemRow (private) — glyph (✓/⚠/✗/?), name, status
      label, latency, evidence, reason. Colors per status
      (green/orange/red/gray). Multi-line evidence + reason.
    * StatusPanel: NSPanel — hosts the SwiftUI view, `present()`
      method centers + brings front + activates app.
  - App/MenuBar/MenuBarContextMenu.swift:
    * `build` now takes a `statusAction:` closure (5th param).
    * "Status…" NSMenuItem between "Settings…" and "Show Dev Overlay".
  - App/AppDelegate.swift:
    * statusPanel: StatusPanel? lazy property.
    * openStatusPanel() method — lazy-creates panel on first click,
      passes self.runBootHealth as the probe runner closure.
    * installMenuBar's MenuBarContextMenu.build call updated for
      the new param.
  - Jarvis.xcodeproj/project.pbxproj: PBXBuildFile + PBXFileReference
    for StatusPanel.swift, added to MenuBar PBXGroup + Sources phase.

Verification:
  - bash scripts/check-app-builds.sh: PASS
  - bash scripts/check-no-modal-presentation.sh: OK
  - bash scripts/check-install-order.sh: PASS
  - bash scripts/check-no-leftover-stubs.sh: PASS

Dogfooding next: cold-launch → right-click menu bar icon → Status…,
verify rows render with concrete evidence (mic device name, model
ID list, etc.); click Re-probe and confirm rows refresh with new
latency values; click Copy report and paste into a text editor to
verify JSON is well-formed and complete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:12:37 -05:00
james.maesandClaude Opus 4.7 687b80b3f0 feat(round-2): boot-phase health probes — NOT FAKED, structured logs
After all install Tasks join, runs one live probe per subsystem and
emits structured `BootHealth: subsystem=X state=Y latencyMs=Z evidence="..."`
log lines. Any subsystem reporting .failed enqueues a non-modal banner
via HUDBannerCoordinator (priority 3 — sits between hotkey-bind-failed
and ollama-url-rejected). The snapshot is stored on bootHealthOrchestrator
so Round 3's Status menu can read + re-probe.

Anti-fake-status invariant is type-level: ProbeStatus is a sum type with
.ok / .degraded(reason:) / .failed(reason:) / .unknown(reason:) — every
non-ok case carries a reason string, so a snapshot can never report
state=unknown without explaining why. A probe that can't execute MUST
return .unknown — never .ok with a fake evidence line.

New types in packages/AgentCore/Sources/AgentCore/BootHealth.swift:
  - ProbeStatus, ProbeOutcome, BootHealthProbe protocol
  - SubsystemHealth (Codable for Round 3 "Copy report"), BootHealthSnapshot
  - BootHealthOrchestrator actor — parallel TaskGroup fan-out,
    registration-order-preserved snapshot, injectable clock for tests

Eight concrete probes in App/Boot/BootHealthProbes.swift:
  - MemoryBootHealthProbe — MemoryStatsStoreAdapter.getMemoryStats round-trip
  - AnthropicBootHealthProbe — Keychain structural check (no billable
    network call at boot; Round 3 panel offers explicit network re-probe)
  - OllamaBootHealthProbe — GET /api/tags, 3s timeout, asserts qwen3.6 +
    qwen2.5-coder + nomic-embed-text all present
  - VoiceBootHealthProbe — AudioGraphOwner state + mic TCC
  - VisionBootHealthProbe — camera TCC + DiscoverySession (read-only,
    does NOT prompt TCC per RESEARCH §Example 2)
  - MCPBootHealthProbe — runtime.client.registeredToolNames count
  - ReplayBootHealthProbe — replay.sqlite presence + parent-dir writability
  - WebviewBootHealthProbe — WebviewBridge.handshakeState (.armed = live)
  - DormantSubsystemProbe — placeholder for subsystems whose actor was nil
    at probe-registration; reports .unknown honestly rather than skipping

AppDelegate wiring (step 16, runs after selfKnowledgeInstallTask which
transitively awaits every other install Task):
  - bootHealthOrchestrator: BootHealthOrchestrator stored property
  - bootHealthTask: Task<Void, Never>? cancelled in applicationWillTerminate
  - runBootHealth() async: registers probes (idempotent — re-probes from
    Round 3 reuse the existing registry), runs, logs, enqueues banners
  - emitBootHealthLog / enqueueFailedBootHealthBanners helpers

Tests (packages/AgentCore/Tests/AgentCoreTests/BootHealthOrchestratorTests.swift):
  - 8/8 passing (210ms wall — 3×200ms parallel probes prove TaskGroup
    fan-out works; serial would be 600ms+)
  - Coverage: runAll invokes every probe, snapshot preserves registration
    order, parallel execution timing, lastSnapshot persistence, .failed
    propagation, .unknown not coerced to .ok, deterministic clock
    injection for latencyMs, JSON round-trip for all 4 ProbeStatus variants

Verification before commit:
  - swift test --package-path packages/AgentCore --filter
    BootHealthOrchestratorTests: 8/8 PASS (0.210s)
  - swift test --package-path packages/Memory --filter
    MemoryExtractorTests: 11/11 PASS
  - bash scripts/check-app-builds.sh: PASS
  - 18/18 boundary gates: PASS

Dogfooding next: cold-launch + grep system.log for `BootHealth:
subsystem=` lines; force a failure (e.g. kill Ollama) and verify
banner appears. Status menu (Round 3) is next session task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:03:24 -05:00
james.maesandClaude Opus 4.7 cb584a2ac8 feat(d5d6): real memory database — vec0 statically linked, hard-fail boot, stats tool
Closes D-5 / D-6 (sqlite-vec dylib bundling) carry-forwards from the
2026-05-04 audit by adopting jkrukowski/SQLiteVec as a SPM dependency.
The package's CSQLiteVec target compiles sqlite3.c + sqlite-vec.c into
one statically-linked C target with `core_vec_init()` registering vec0
as an auto-extension. All sqlite3_* calls in Replay's SQLiteConnection
and Memory's MemoryStore resolve to this bundled SQLite (vec0 always
available) instead of Apple's stripped /usr/lib/libsqlite3.dylib.

The retired hand-rolled approach (dlsym pathway + JARVIS_VEC0_STUB_PATH
env-flag + Bundle.module placeholder text file) never worked on macOS
because Apple stripped sqlite3_load_extension from the system libsqlite3.
Memory init has been silently dormant since Phase 7 (2026-04 onward),
which is what made B-02 (history threading) appear permanently broken
to the user during dogfooding even after the 48ad8d6 tactical patch
landed.

Live verification 2026-05-11: fresh launch with empty jarvis.db creates
schema, persists turn rows across submits, B-02 multi-turn context
resolves correctly ("what breed is Toby?" → "Golden Retriever" after
prior turn established Toby is a Golden Retriever), and the new
get_memory_stats tool reports `turnsCount=6, vecVersion=v0.1.6,
sqliteVersion=3.51.0`.

Changes:

- packages/Replay/Package.swift
    + SQLiteVec SPM dependency (from: "0.0.14")
    + CSQLiteVec product wired into Replay target
- packages/Replay/Sources/Replay/SQLiteConnection.swift
    - import SQLite3
    + @_exported import CSQLiteVec
    All sqlite3_* call sites unchanged — linker now resolves to our
    bundled sqlite3 with vec0 statically linked in.
- packages/Memory/Package.swift
    + SQLiteVec SPM dependency (named product so MemoryStore can import
      CSQLiteVec directly for the core_vec_init() one-shot call)
    - resources: PLACEHOLDER.txt copy (defunct vec0.dylib stub slot)
- packages/Memory/Sources/Memory/MemoryStore.swift
    - import SQLite3 → import CSQLiteVec
    + _vecAutoExtensionRegistered static-let one-shot: calls
      CSQLiteVec.core_vec_init() which registers vec0 via
      sqlite3_auto_extension on the first MemoryStore construction.
      Idempotent (static-let initializer fires exactly once).
    - loadVecExtension(on:) dlsym/JARVIS_VEC0_STUB_PATH/Bundle.module
      pathway (retired; ~70 lines removed)
    Updated docstring to reflect the new hard-fail boot policy.
- packages/Memory/Sources/Memory/Resources/PLACEHOLDER.txt — deleted
- App/AppDelegate.swift installMemory()
    Reverses Track-D D-1 (75a10be, 2026-05-04): MemoryStore.init failures
    are now FATAL via Logger.critical + TCCAlertService.presentHardBlock
    + NSApp.terminate. The graceful-degradation path that kept the app
    booting with memoryStore=nil hid the entire B-02/B-04/B-05 silent-
    failure bug class. Aligns with the 2026-05-07 API-first pivot
    thesis: "no silent failures".
    `store` is now non-optional throughout installMemory; downstream
    callers no longer need `if let store = store`.
- packages/MCP/Sources/MCP/InProcess/GetMemoryStatsTool.swift (new)
    + MemoryStatsDispatching protocol
    + MemoryStats Codable struct (turnsCount, factsTotal, factsActive,
      sessionsCount, dbFileBytes, vecVersion, sqliteVersion,
      lastTurnAtUnixMs, lastFactAtUnixMs)
    + GetMemoryStatsTool conforming to InProcessTool
- App/MCP/InProcessMemoryAdapters.swift
    + MemoryStatsStoreAdapter — bridges the protocol to MemoryStore's
      public queryRowCount / querySingleString seams and FileManager
      for the on-disk file size. Read-only, no confirmation.
- App/AppDelegate.swift installSelfKnowledgeTools()
    + Registers GetMemoryStatsTool with MemoryStatsStoreAdapter.
- packages/Memory/Tests/MemoryTests/*
    Retired the JARVIS_VEC0_STUB_PATH XCTSkipUnless guards from 9 tests
    (vec0 is always available now — no need to env-gate). Rewrote
    MemoryStoreTests to drop the now-impossible "init throws when vec
    missing" case and assert facts_vec virtual table creation +
    vec_version() readback. Added seedTurnRow helper to
    MemoryStoreApplyOpTests so the now-enforced
    `facts.source_turn_id REFERENCES turns(id)` foreign key is honored
    in tests that previously passed when FK enforcement was dormant.

Test summary: Memory full suite 91 tests, 10 skipped (env-gated real-
Ollama tests), 0 failures. App target builds clean. Substrate verified
end-to-end via live launch (vec_version: v0.1.6 logged at boot;
get_memory_stats returns actual schema row counts).

Pending D-7 carry-forward: ollama pull nomic-embed-text (~270MB) for
embedder; user already has qwen2.5-coder:32b-instruct-q8_0 which is
a higher-quality variant of the project's hardcoded
qwen2.5-coder:32b — small ModelID config delta to follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:25:56 -05:00
james.maesandClaude Opus 4.7 48ad8d631c fix(b-02): thread recent turns into AgentOrchestrator messages array
Carry-forward bug B-02 (live evidence 2026-05-07): conversation
continuity broken — "yes" / "why 4.5?" lose prior turn context because
AgentOrchestrator.process(_:) built [system, user(input.userText)]
with no history fetch.

Tactical patch outside the M-0..M-7 migration sequence, per
JARVIS-API-MIGRATION-PLAN.md §3 and UQ-2 (option B). Lands on develop
directly. M-4 (Memory surface) will reframe this path against the new
API contract but the user-visible bug closes today.

Substrate gap discovered + addressed in-scope. The `turns` table was
unwritten (schema present at MemorySchema.swift, FTS5 trigger ready,
read API at MemoryStore.recentTurnsForSession, but no INSERT site
anywhere in the codebase). The migration plan assumed turn writes
existed; they did not. Patch adds the missing writer plus the read
side wiring.

Changes:

- packages/Memory/Sources/Memory/MemoryQueries.swift
    + turnInsertSQL — single-row INSERT into turns
- packages/Memory/Sources/Memory/MemoryStore.swift
    + appendTurn(sessionId:role:content:source:createdAt:)
    Single-row INSERT; FTS5 trigger mirrors content into turns_fts
    automatically. T-06-05-03: function MUST NOT log content.
- packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift
    + PriorTurn struct + SessionHistoryLookup typealias
    + emptySessionHistoryLookup default (preserves pre-B-02 test setups)
    + sessionHistoryLookup ctor parameter
    Replaces the [system, user] messages-array build with prior-history
    hydration: system + chronological prior user/assistant rows + current
    user. Lookup throws -> warning log + empty history (matches D-3
    PriorFactsLookup resilience contract).
- App/AppDelegate.swift
    + sessionHistoryLookup closure: recentTurnsForSession(limit: 10),
    reverse DESC -> chronological, filter to user/assistant only, return
    [PriorTurn]
    + extends existing turnContent flush closure with appendTurn calls
    (rides on memory-extraction drain path; assistant row offset +1 ms
    so DESC + id-tiebreaker preserves user->assistant order)

Tests:

- packages/AgentCore/Tests/AgentOrchestratorTests/HistoryThreadingRegressionTests.swift
    HT-1: third turn includes prior user/assistant pairs in chronological
    order. 3/3 PASS.
    HT-2: empty-history default produces [system, user] (pre-B-02
    baseline preserved).
    HT-3: three-turn happy path emits zero LLMProviderError
    .streamTruncatedFinal events. Pins R-006 / Phase E gate behavior:
    patch grows messages array, not finalSystem, so
    CacheHints.eligibleForSystemPrompt(finalSystem) is unchanged.
- packages/Memory/Tests/MemoryTests/MemoryStoreAppendTurnTests.swift
    AT-1: appendTurn round-trips via recentTurnsForSession.
    AT-2: user/assistant pair reverses to chronological.
    AT-3: session_id scoping isolates rows.
    AT-4: turns_fts trigger fires on append.
    All env-gated on JARVIS_VEC0_STUB_PATH per existing MemoryStore-test
    convention; skip cleanly without vec0.dylib.

Verification:

- check-app-builds.sh PASS.
- Boundary gates sampled (check-no-leftover-stubs, applescript-confirm,
  corpus-secrets, bus-protocol-version, single-memory-mutated-emit,
  single-writer-hudstate) PASS.
- AgentCore affected-subset (Submit/Retry/TextEnd2End/CapRecovery/
  HistoryThreading) 26/26 PASS, 0 failures.
- Memory full suite 92 run, 23 skipped (env-gated), 0 failures.

Degradation contract: when MemoryStore is nil (dormant memory — vec0
or Ollama unavailable), turn persistence is also dormant; the
appendTurn call is guarded by memoryStore != nil. When D-5/D-6 close
and memory comes up live, persistence activates without further code
changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 08:50:40 -05:00
james.maes 1b7fb81408 fix(10-02c): wire InProcessAwareToolDispatcher into runtime — closes B-01b
Prior B-01a fix (e0c0310) wired tool catalog ENUMERATION so the model gets
schemas for in-process tools. But ROUTING was still broken: MCPRuntimeWiring's
ToolRegistry only knew about the 3 stdio tools (get_time / get_clipboard /
run_applescript), so when the model emitted tool_use for get_active_audio_route
the inner MCPToolDispatcher returned "tool unknown" and the model surfaced the
failure as visible chat text ("the audio route tools are erroring out").

This commit lands the dispatch wire-up. With the types from 0961fef, 6503b23,
3cf0d39 already in place:

- App/AppDelegate.swift: eager-default-init inProcessToolRegistry at property
  declaration so the SAME actor reference is shared between installMCP (passes
  it to MCPRuntimeWiring.build for the composite dispatcher) and installMemory
  / installSelfKnowledgeTools (register tools into it). Refactor installMemory
  to populate INTO the existing registry via a new populateMemoryTools helper
  rather than constructing a fresh one. The pre-existing
  buildInProcessToolRegistry static delegates through the same helper for
  back-compat.

- App/MCP/MCPRuntimeWiring.swift: build() takes a new inProcessRegistry
  parameter (defaulted nil for tests). When supplied, the inner stdio
  MCPToolDispatcher gets wrapped by InProcessAwareToolDispatcher so name-routing
  picks in-process tools first, falls through to stdio. Confirmation gating,
  bus emission, and observer hooks fire identically on both paths because the
  composite sits INSIDE ConfirmingToolDispatcher's outer chain.

- .planning/phases/10-self-awareness-diagnostics/10-02c-PLAN.md: skeleton plan
  documenting the surgical fix.

Build: swift test --package-path packages/MCP PASS (91/91 XCTest + 6 swift-test).
bash scripts/check-app-builds.sh PASS.

Live verification deferred to user — relaunch + ask "what mic are you using?"
expects real device name in reply (no JSON blob), tool_use + tool_result for
get_active_audio_route in the log tail.
2026-05-07 13:12:23 -05:00
james.maesandClaude Opus 4.7 e0c0310b64 fix(10-02b): wire availableTools from runtime catalog at AppDelegate
Plan 10-02b / B-01 root-cause fix.

App/AppDelegate.swift:1274 previously constructed AgentOrchestrator
with `availableTools: []` — a hardcoded empty array. The 2026-05-07
live verification of Plan 10-02 surfaced this as the substrate bug
silencing every tool call across the agent: get_time, get_clipboard,
run_applescript, search_memory, forget_fact, vision tools, and the
four Phase 10-01 self-knowledge tools (list_audio_devices,
get_active_audio_route, get_self_state, list_camera_devices).

The model saw the system-prompt preamble naming tools but had no
schemas in the API tools[] field, so it hallucinated JSON tool
definitions into chat ("I'll check that for you. {\"name\": \"get_active_audio_route\", \"arguments\": {}}")
instead of dispatching real tool_use blocks.

Fix: source the catalog lazily via the new
`availableToolsResolver:` parameter from BOTH:
  - mcpRuntime.client.toolCatalog() — stdio helper tools
  - inProcessToolRegistry.toolSchemas() — memory + self-knowledge tools

The resolver fires once per outer-loop iteration in
AgentOrchestrator.runTurnLoop, NOT per stream event. By the time the
user submits their first turn, the install-order chain
  installMemory → installVision → installAgent → installVoice
                                              → installSelfKnowledgeTools
has populated all 7 in-process tools and all 3 stdio tools (10 total
catalog entries) — no install-order coupling needed at construction
time.

Verification:
- bash scripts/check-app-builds.sh PASS
- All 16 standalone boundary gates PASS
- Live re-verification of AC-05 ("What microphone are you using?")
  is the human-verify gate; see Live Verification section in
  10-02b-SUMMARY.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 07:42:39 -05:00
james.maes f22bf2ea3b feat(10-02): add self-aware preamble + ContextBuilder.systemPrompt(for:)
Replaces the hardcoded "You are Jarvis, a personal macOS assistant."
literal at App/AppDelegate.swift:1265 with a locked self-aware preamble
emitted by AgentCore's new ContextBuilder. The preamble names Jarvis,
lists capabilities (mic, camera, speakers), names the seven introspection
tools (list_audio_devices, get_active_audio_route, get_self_state,
list_camera_devices, get_time, get_clipboard, run_applescript) so the
model has handles, and ends with "You are not a text-only assistant."
Closes the 2026-05-05 / 2026-05-06 user-quoted failure modes.

Implements:
- ContextBuilder.selfAwarePreamble: locked Swift string constant (D-13).
  No JSON file, no localization, no runtime mutation. Length ~660 chars,
  well under the 4096-char cache-eligibility boundary (Pitfall #4 / A4).
- ContextBuilder.systemPrompt(for:): composes preamble FIRST, then
  presence enrichment, then memory hydration (D-12 — two distinct
  concatenation steps preserve cache_control breakpoints).
- TurnContext envelope with .empty static for the AppDelegate site
  (per-turn enrichment plumbing through orchestrator init is out of
  scope per D-12 fallback; AppDelegate calls .systemPrompt(for: .empty)).
- AppDelegate.swift orchestrator init now sources systemPrompt from
  ContextBuilder.

Rule 3 deviation (auto-fix blocking issue): the new
AgentCore.ContextBuilder name-collides with the pre-existing
JarvisVision.ContextBuilder (cloud-opt-in / frame-attach detector) at
two call sites that previously used bare `ContextBuilder()`. Both call
sites disambiguated to `JarvisVision.ContextBuilder()`:
- packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift:212
- App/AppDelegate.swift:1620
And the dependent test:
- packages/AgentCore/Tests/AgentOrchestratorTests/AgentOrchestratorVisionDispatchTests.swift:209

Wave-0 test preambleDoesNotEnableCacheUnintentionally flips RED→GREEN.
All 17 boundary gates green; check-app-builds.sh PASS.

Closes SELF-05, SELF-06 (Plan 10-02 implementation tasks).
2026-05-06 18:10:27 -05:00
james.maes 74b9ac2ff5 feat(10-01): wire four self-knowledge MCP tools into live runtime
App-side adapters bridge JarvisMCP's self-knowledge dispatcher protocols
to concrete runtime sources, mirroring the InProcessMemoryAdapters.swift
Track D-2 pattern (commit 707c45b). JarvisMCP stays unaware of
Voice/AVFoundation/CoreAudio.

- App/MCP/InProcessSelfStateAdapters.swift (NEW):
  - CoreAudioIntrospection.snapshotDevices() — full
    AudioObjectGetPropertyData enumeration, one entry per
    (device, direction) pair with name/uid/sampleRate/channels and
    isDefault/isActive flags resolved from
    kAudioHardwarePropertyDefaultInput/OutputDevice.
  - CoreAudioIntrospection.defaultDeviceUID(isInput:) — used by
    AudioGraphRouteAdapter to fill the route's input/output UID + name.
  - CoreAudioDeviceListAdapter — wraps snapshotDevices() (SELF-01).
  - AudioGraphRouteAdapter (actor) — hops into AudioGraphOwner for the
    probed sample rate + channels, combines with the CoreAudio default
    device UIDs for the active route (SELF-02).
  - SelfStateAdapter — closure-injected probes for provider, voice loop
    state, TTS tier, STT backend, wake-word mute. Uses captured launch
    Date for app uptime (NOT ProcessInfo.systemUptime which is host
    uptime per D-11). buildMode reads #if DEBUG. (SELF-03).
  - AVCaptureDeviceListAdapter — DiscoverySession enumeration; metadata-
    only, does NOT trigger camera TCC prompt (SELF-04).

- packages/Voice/Sources/Voice/AudioGraph/AudioGraph.swift: store the
  probedFormat on AudioGraph so AudioGraphOwner can expose it. Additive
  public let; no behavior change.

- packages/Voice/Sources/Voice/AudioGraph/AudioGraphOwner.swift:
  - new public ActiveRouteSnapshot Sendable struct (sampleRate, channels)
  - new public actor accessor activeRouteSnapshot() returns the probed
    input format. Hardware-identity (UID/name) intentionally lives in
    the App-side adapter so Voice doesn't take on CoreAudio-introspection
    concerns.

- App/AppDelegate.swift:
  - launchInstant: Date captured at AppDelegate construction time for
    SELF-03 app-uptime reporting (D-11).
  - selfKnowledgeInstallTask + installSelfKnowledgeTools() registers the
    four tools into the existing inProcessToolRegistry built by
    installMemory. Runs after voiceInstallTask so audioGraphOwner is
    available; degrades gracefully when audioGraphOwner is nil
    (registers the three tools that don't depend on it). All four tools
    declare requiresConfirmation:false (D-10 / AP-12); no caching
    (D-09 / AP-11). Provider identity reads from configStore.perTurn();
    voice loop state hops VoiceController.state; wake-word mute reads
    UserDefaults `features.voice.wakeWordMuted` (mirrors MuteWakeWord's
    persistence key).

Verification:
- swift test --package-path packages/MCP — all suites GREEN incl.
  registersFourSelfKnowledgeTools.
- swift test --package-path packages/Voice — 26 tests in 7 suites GREEN
  (AudioGraph probedFormat additive change, no regressions).
- bash scripts/check-app-builds.sh — PASS.
- All 18 boundary gates exit 0 (incl. check-applescript-confirmation,
  check-no-leftover-stubs, check-install-order, check-no-null-voice-
  adapters, check-no-evaluate-javascript).
2026-05-06 15:50:30 -05:00
james.maesandClaude Opus 4.7 b92d062f8b fix(tcc): explicit mic + camera permission requests in installVoice/installVision
Live test 2026-05-06 surfaced that the production app never actually triggered
the macOS mic or camera permission dialogs — the wizard's TCC stage was
explainer-only ("(later)") and the "first-use prompt" code that was supposed
to land later was never written. Result: AVAudioEngine opened cleanly, mic
input was silent, no wake-word ever fired, and CameraCapture.open() failed
.cameraDenied without ever asking.

Fix: explicit AVCaptureDevice.requestAccess(for:) calls at the first-use
boundary of each subsystem.

* App/AppDelegate.swift installVoice — checks mic auth status; on
  .notDetermined awaits requestAccess(for: .audio) BEFORE
  graphOwner.open(); on .denied/.restricted logs and returns dormant. Voice
  loop only proceeds with confirmed mic grant.

* App/AppDelegate.swift installVision — same pattern for camera. Checks
  status; .notDetermined → requestAccess(for: .video); .denied/.restricted
  surfaces the existing degradationStream banner via cameraDegradationTask.

* App/Wizard/WizardStageTCCView.swift — wizard rows updated from
  "Microphone (later)" / "Camera (later)" to "(auto-prompts shortly)" with
  body text matching reality: prompts fire automatically after the wizard,
  when the matching subsystem starts. The previous "later" copy implied
  indefinite deferral when actually the prompts fire ~5s after wizard
  completion.

Live verification:
- tccutil reset Microphone com.koftwentytwo.jarvis
- Launch → wizard appears → click through
- tccd log: "AUTHREQ_PROMPTING ... kTCCServiceMicrophone" — system
  dialog displayed
- After click-Allow: AudioGraph: probed format sampleRate=48000 channels=4,
  AudioGraphOwner: opened with variant <private>, WakeWordDAG: started
  ring-feed loop, VoiceController: starting

Voice 87/87 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 11:26:44 -05:00
james.maesandClaude Opus 4.7 85b14ceeea docs(docstrings): add header docstrings to key public types
- AppDelegate (App/): adds the missing "what is this" preamble + Lifecycle /
  Note on size / See also sections to the existing bootstrap-chain docstring.
- VoiceController (packages/Voice): converts the // MARK comment-block header
  to a proper /// docstring with Threading + Anti-patterns + See also sections.
- MCPClient (packages/MCP): adds a docstring covering the registry +
  per-server restart mutex + relationship to ChildSpawnGate / ConfirmingToolDispatcher.

Comment-only changes; verified via swift build (Voice, MCP) and
scripts/check-app-builds.sh.

The other types listed in the documentation brief (BufferBroadcaster,
SpeechAnalyzerSTT, CameraCapture, FrameAttachController, MemoryStore,
MemoryExtractionOrchestrator, HybridSearch, AgentOrchestrator, LLMProvider,
BusOutbound, BusInbound) already carry substantive header docstrings —
left alone per the audit-grade-improvement-only constraint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:17:36 -05:00
james.maesandClaude Opus 4.7 f9cd25d66e chore(test-naming): converge on Mock/Fake/Stub convention; document in CLAUDE.md; rename worst-mismatch stragglers
Per audit-2026-05-04 P2-15: 26 Mock*, 21 Fake*, 12 Stub* across the test
corpus, often used interchangeably for the same role. New CLAUDE.md
section "Test naming conventions" (line 127) sets the rule:

- Mock*: records calls AND returns scripted responses (assertion driver).
- Stub*: returns canned data without recording (deterministic input).
- Fake*: alternative implementation with realistic behavior (in-memory
  state-holder).

Renamed 15 worst-mismatch stragglers in the older corpus (per audit
constraint, did NOT touch types added in today's P1+P2 fix commits):

Stub* → Mock* (records + scripts):
- HybridSearchTests:    StubEmbedder → MockEmbedder
- HybridSearchTests:    StubStore → MockStore
- SessionHistoryTests:  StubStore → MockStore
- InProcessMemoryToolsTests: StubHybrid → MockHybrid
- InProcessMemoryToolsTests: StubHistory → MockHistory
- InProcessMemoryToolsTests: StubForget → MockForget

Mock* → Stub* (canned data, no recording):
- STTBackendSwitchTests: MockSpeechAnalyzerBridge → StubSpeechAnalyzerBridge
- STTBackendSwitchTests: MockWhisperKitBridge → StubWhisperKitBridge
- VoiceWiringTests:      MockVADEngineForW4 → StubVADEngineForW4
- VoiceWiringTests:      MockSTTProviderForW4 → StubSTTProviderForW4
- VoiceWiringTests:      MockTTSForW4 → StubTTSForW4
- VoiceWiringTests:      MockOrchestratorForW4 → StubOrchestratorForW4
- VoiceWiringTests:      MockBannerForW4 → StubBannerForW4
- VoiceWiringTests:      MockBusForW4 → StubBusForW4

Fake* → Mock* (records + scripts):
- WebviewBridgeOutboundTests:        FakeJSEvaluator → MockJSEvaluator
- HudStateCoordinatorBusWiringTests: FakeJSEvaluator → MockJSEvaluator
  (the comment on the type already said "Records each call for assertion")

Skipped per audit constraint:
- StubCapture/StubSink (Vision FrameAttachConfirmSendWiringTests, P1+P2)
- MockVADEngine + MockOrchestratorForController siblings (Voice
  VoiceControllerTests/VADGatedSessionTests, today's track-B churn)

Tests pass: Voice 87+23, MCP 85, Memory 88, Bus 58, AgentCore 19+72,
Vision 19, Shell 7+5. App target builds clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 16:42:16 -05:00
james.maesandClaude Opus 4.7 81357ca900 test(voice p1-2): production chunkPump factory extracted + integration test against real broadcaster
Per audit 2026-05-04 T2: the production chunkPump closure at AppDelegate
.installVoice was tested only end-to-end via synthetic pumps — the real
subscribe + readMono16k loop had zero coverage. Same divergence pattern
that produced BLOCKER-INT-1 (NoopBusGateway).

Extract the closure into App/Voice/ProductionChunkPump.swift as
makeProductionChunkPump(graphOwner:); AppDelegate becomes a one-line
caller. New ProductionChunkPumpTests covers three integration paths:

- PCT1: pump finishes immediately when graphOwner is nil
- PCT2: pump receives buffers via the broadcaster fan-out (publishes
        through a tap-block-capturing GraphBuilder against a real
        AudioGraphOwner)
- PCT3: pump's subscription does not steal samples from a sibling
        subscription (P1-1 / Track B-7 invariant regression guard)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 15:46:32 -05:00
james.maesandClaude Opus 4.7 f19657a83e fix(voice p1-1): AudioLevelEmitter wires via BufferBroadcaster.Subscription, not raw RingBuffer
Per audit 2026-05-04 HIGH-4 + T1: AudioLevelEmitter was unwired in
production AND its API took a raw RingBuffer, inviting any future
production wire-up to re-introduce the sample-stealing bug Track B-7
just fixed (consumers racing the same SPSC read pointer).

Change init signature to require a BufferBroadcaster.Subscription so
the emitter has its own per-subscriber ring and never steals samples
from WakeWordDAG / chunkPump. Wire the emitter in installVoice via
audioGraphOwner.subscribe(); drive start()/stop() from VoiceController
state transitions on the listening boundary so the HUD ring pulses on
real mic RMS during listening sessions.

Existing testV4 updated to construct via BufferBroadcaster + subscribe
+ publish. New testV4b is the regression guard: two subscriptions on
one broadcaster receive distinct copies of every published buffer;
emitter draining sub A leaves sub B's ring untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 15:40:32 -05:00
james.maesandClaude Opus 4.7 51750c161b fix(track-d 3): MemoryExtractionOrchestrator passes real priorFacts to extractor
Pre-D-3, `MemoryExtractionOrchestrator.process(_:)` hardcoded
`priorFacts: []` on every job. The mem0 extractor was therefore blind
to existing facts and could only emit ADD ops — UPDATE/supersede
never fired, so superseded values silently duplicated on every turn.

This commit:
- Adds `PriorFactsLookup` typealias and `priorFactsLookup` parameter on
  `MemoryExtractionOrchestrator.init`. Default is `emptyPriorFactsLookup`
  to preserve pre-D-3 behavior for tests that don't care about priors.
- Wires `process(_:)` to call `priorFactsLookup(job)`. Lookup throws
  degrade to `priorFacts = []` with a warning log — the drain task
  must never die because a single SQL read failed.
- Adds `MemoryStore.recentActiveFacts(limit:)` + matching SQL in
  `MemoryQueries.recentActiveFactsSQL`. Active-only (`valid_to IS NULL
  AND forgotten_at IS NULL`), most-recently-created first, server-side
  cap. D-02 forgotten facts excluded.
- AppDelegate's `installMemory` passes a closure that returns up to 50
  recent active facts. 50 is a deliberate cap — RESEARCH §5 caps the
  rendered priorFacts block at ~4 KB (~50 facts at typical S/P/O
  length). Closure returns [] when `memoryStore` is nil, preserving
  the pre-D-3 no-store path.

Tests:
- `testD3_priorFactsLookupDrivesUpdateOp` — stub provider that emits
  UPDATE when it sees a `[99]` prior fact id in the system prompt;
  asserts `applyOp` receives `.update(supersedes: 99, …)`. Pre-D-3
  this would have been `.add` because the orchestrator never told
  the model about the prior fact.
- `testD3_priorFactsLookupErrorDegradesToEmpty` — lookup throws,
  job still applies its ADD op (drain survives the failure).

T-06-05-03 invariant respected: warnings log turnId only, never
fact content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 14:22:42 -05:00
james.maesandClaude Opus 4.7 707c45b355 feat(track-d 2): register SearchMemoryTool + ForgetFactTool with MCP runtime
Closes the audit's "tools exist but unregistered" finding from
.planning/audit-2026-05-03/memory-audit.md §4-5: SearchMemoryTool +
ForgetFactTool source files have lived in `packages/MCP/Sources/MCP/
InProcess/` since Plan 07-03 with zero non-test instantiation. The agent
loop has had no way to call into memory.

Wiring:
  - New `App/MCP/InProcessMemoryAdapters.swift` bridges MCP's tool
    dispatch protocols to Memory's actors:
      * `HybridSearchAdapter: HybridSearchDispatching` wraps
        `Memory.HybridSearch` and translates `[FactRef]` → `[SearchMemoryHit]`
        (so MCP doesn't need to import Memory).
      * `ForgetFactStoreAdapter: ForgetFactDispatching` wraps
        `MemoryStore.forgetFact`.
  - `installMemory` builds an `InProcessToolRegistry` and registers tools
    based on preconditions:
      * `forget_fact` registers when `memoryStore != nil` (writes need DB)
      * `search_memory` registers when `memorySearchAvailable && embedder
        init succeeds` (reads need DB + vec0 + Ollama loopback)
  - Embedder construction (`OllamaEmbeddingClient` loopback assertion)
    failure is caught + logged; degrades to "writes ok, no search"
    rather than failing the whole subsystem.
  - New `inProcessToolRegistry: InProcessToolRegistry?` ivar, exposed
    @testable.

Test seam: `AppDelegate.buildInProcessToolRegistry(forgetDispatcher:
searchDispatcher:)` is the pure registry-construction helper — takes
already-built dispatchers (or nil) and applies the gating rules. Tests
inject stub dispatchers to assert positive registration without needing
a real `MemoryStore` (which requires vec0.dylib, env-gated in CI).

Tests added (compile-time gates per
.planning/debug/xctest-launch-runningboard-error-5.md):
  - `test_D2_noToolsRegisteredWhenStoreUnavailable` — production reality
    on every cold launch (vec0 placeholder)
  - `test_D2_bothToolsRegisterWhenBothDispatchersPresent` — store + search
    available
  - `test_D2_onlyForgetRegistersWhenSearchUnavailable` — store ok, vec
    failed (the future state once custom libsqlite3 lands but vec0.dylib
    is missing)
  - `test_D2_emptyRegistryWhenBothDispatchersNil` — degenerate case

Out of scope: exposing the registered tools to the LLM via
`AgentOrchestrator.availableTools` (currently `[]` — separate audit gap,
affects helper tools too). The "tools exist but unregistered" gap is
closed; the "tools exposed to LLM" gap is independent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 14:09:46 -05:00
james.maesandClaude Opus 4.7 75a10be480 fix(track-d 1): installMemory no longer short-circuits on vec failure
Pre-fix: a single `return` after `MemoryStore(databaseURL:)` threw left
`memoryExtractionOrchestrator` AND `memoryExtractionCoordinator` both nil
for the lifetime of the process — so the .turnEnd subscriber was a no-op
and nothing about memory extraction was observable in system log even
though the LLM call to Ollama is independent of the SQLite store.

Post-fix: store nil → log warning, set memorySearchAvailable=false, but
extractor + orchestrator + coordinator all construct. The applyOp closure
captures `[weak self]` and routes to a debug log when self.memoryStore is
nil — drain loop survives, log only the turnId hash (T-06-05-03 / memory-
content discipline: never log subject/object content).

`memorySearchAvailable` is added as an ivar to track whether search is
viable (today: store!=nil ⟺ vec_version() succeeded ⟺ search works; when
custom libsqlite3 lands, the flag will split from store!=nil).

`installMemory` lowered from `private` to default `internal` so the new
JarvisAppTests/MemoryInstallWiringTests can drive it directly via
`@testable import Jarvis`. Tests run as compile-time gates (xctest harness
launch is broken on Xcode 26 + ad-hoc bundles per
.planning/debug/xctest-launch-runningboard-error-5.md), but the structural
assertions still catch regressions of the form "the cascade came back".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 14:00:34 -05:00
james.maesandClaude Opus 4.7 eac16c9a33 fix(track-c 5): wire FrameAttachController.confirmSend
Closes vision-audit §4 "confirmSend has zero non-test callers".
FrameAttachController.confirmSend(userText:) was structurally complete
but unreachable from production: requestAttach armed the slot, the
2-second default-cancel timeout fired, the frame was discarded, and the
agent ran text-only. Track-C 5 wires confirmSend into the chat-submit
flow so a user clicking the camera button (Track-C 3) and then sending
text within the confirm window actually delivers the captured frame to
the agent.

App/AppDelegate.swift changes:
- handleChatSubmit now calls a new private helper
  `consumePendingFrameIfAny(userText:)` BEFORE submit. When the
  controller has a pending frame, the helper pulls the resulting
  `ImageBlock` via `confirmSend(userText: text)` and the submit routes
  through `TurnInput.withImages(.text, text:, images: [imageBlock])`.
  When nothing is pending the path collapses to the original
  `submit(.text(text))` — text-only turns are unaffected.
- handleChatCancelAndSubmit gets the same wiring (mirrors the
  WARNING-4 pattern that already applied to phrase triggers — without
  it, barge-in after a camera-button arm silently drops the frame).

This completes the Track-C 3 click flow:
- Camera button click → BusInbound.frameAttachRequested
  → FrameAttachController.requestAttach(.hudButton) (already wired)
  → CameraCapture.captureFrame() returns real JPEG (Track-C 1 fix)
  → frame parked in pendingFrame slot
- User types + sends within 2s
  → handleChatSubmit consumes the pending frame via confirmSend
  → AgentOrchestrator.submit(.withImages(...)) routes through
    VisionRouter.route(...) which selects T1 (or T3 with explicit
    cloud opt-in)

Tests: +3 (FrameAttachConfirmSendWiringTests)
- testAppDelegateCallsConfirmSendInChatSubmitPath: structural grep
  enforcing the wiring exists.
- testConfirmSendIsReachableFromBothChatHandlers: ensures both
  chatSubmit AND chatCancelAndSubmit invoke the helper (WARNING-4
  parity).
- testConfirmSendNilWhenNothingPending: confirms the contract the
  AppDelegate wiring depends on (no surprise auto-confirm).

Vision 67 → 70. Six boundary gates + check-app-builds pass clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 13:18:46 -05:00
james.maesandClaude Opus 4.7 df6a4d27cc fix(track-c 4): explicit T2Provider missing-variant; remove silent t1 fallback
Closes vision-audit §3 "T2 is decorative". AppDelegate previously wired
the VisionRouter as `t2Provider: t1`, so `evaluatePostResponse →
.escalateToT2 → providerForTier(.t2LocalQuality) → t1Provider` silently
re-ran the same low-confidence response on the same model with no
operator signal that T2 was never built.

This commit takes the explicit-missing path. Real T2 wiring
(VllmMlxSidecar instantiation + binary bundling + codesigning +
feature-flag gating) is downstream work and out of scope for Track C.

Changes:
- packages/Vision/Sources/Vision/VisionError.swift: new
  `.t2ProviderUnavailable` case.
- packages/Vision/Sources/Vision/MissingT2Provider.swift (new):
  `LLMProvider` conformer whose text and multimodal `stream(...)`
  entry points finish with `VisionError.t2ProviderUnavailable`.
- App/AppDelegate.swift: replaces `t2Provider: t1` with
  `MissingT2Provider()`. Comment now documents that callers MUST gate
  T2 escalation on `evaluatePostResponse(..., t2Available: false)`,
  which routes to `.useT1Result` and never invokes T2 — so this
  provider is never actually streamed in the live flow.

Why explicit-missing over real T2: real T2 demands ~6 GB Qwen 3.5 VL
weights, vllm-mlx binary at a stable path, codesigning a nested .app
helper, feature-flag plumbing, and a new TCC surface. The bug being
fixed here is "silent T1-as-T2 fallback hides intent" — making the
fallback explicit (impossible to mistake for working T2) is enough.

Tests: +4 (MissingT2ProviderTests)
- testStreamFinishesWithExplicitError: text path throws
  t2ProviderUnavailable.
- testMultimodalStreamFinishesWithExplicitError: image path throws
  t2ProviderUnavailable.
- testRouterStillStaysOnT1WhenT2Unavailable: re-asserts router's
  no-auto-cloud invariant — production never reaches the missing
  provider's stream when wired correctly.
- testAppDelegateNoLongerCarriesSilentT1Fallback: structural grep
  enforces no regression to `t2Provider: t1`.

Vision 63 → 67. Six boundary gates + check-app-builds pass clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 13:13:38 -05:00
james.maesandClaude Opus 4.7 08125a46d8 chore(test): remove tautologies and rubber-stamps per tests-audit.md
Per `.planning/audit-2026-05-03/tests-audit.md` section 5, five
`XCTAssertTrue(true)` placeholder tests were identified across the tree
that are mathematically incapable of failing. This commit fixes or
removes each, conservatively. Files touched and disposition:

- App/Tests/AppTests/MCPRuntimeWiringTests.swift:260
  fixed: replaced trailing `XCTAssertTrue(true)` rubber-stamp at end of
  CR-02 channel-drain test with a load-bearing assertion that the drain
  task completed via `finish()`, not cancellation. The implicit `await
  drainTask.value` is the real gate; the new assertion ensures regressions
  that cancel-instead-of-finish surface.

- App/Tests/AppTests/WebviewBundleLoadTests.swift:60
  deleted: `test_assetsDirectoryGitignored_documentedInSmokeScript`. Body
  was `XCTAssertTrue(true)` with comment "intentionally has no assertion
  — it exists to document the split of responsibility". Documentation,
  not a test. Replaced with a top-level comment block documenting the
  same split (smoke-test-hud.sh is the runtime gate).

- packages/AgentCore/Tests/AgentCoreTests/ToolChoiceTests.swift:38
  fixed: removed trailing `XCTAssertTrue(true) // Test compiles → contract
  holds.` from `testLLMProviderStreamRequiresToolChoice`. The closure
  body itself is the contract — if the test compiles, the contract holds.
  Comment updated to make the compile-time semantics explicit.

- packages/Shell/Tests/ShellTests/InputMonitoringDenialTests.swift:36
  deleted: `test_localMonitorOnlyFallback`. Body was `XCTAssertTrue(true)`
  documenting that the real fallback test lives in HotkeyBindingTests.
  Replaced with a top-level comment pointing readers there.

- packages/Vision/Tests/VisionTests/PackageBoundaryTests.swift:14
  deleted: `testVisionModuleCompilesStandalone`. Body was
  `XCTAssertTrue(true)` with comment "compile is the assertion." VISION-03
  isolation is enforced by `scripts/check-vision-isolation.sh` and the
  Package.swift dependency graph, both of which are real gates. Folded
  the documentation into the `testPublicAPISurface` doc-comment so the
  explanation isn't lost.

HudStateEnumTests rubber-stamps (audit items #4–6 in section 5) were
left as-is. The voiceOverLabel literals are user-facing accessibility
text that VoiceOver actually speaks; they are load-bearing in the sense
the audit calls out as worth preserving ("replace with behavioral
assertions if the literal under test is load-bearing"). A regression
that breaks an a11y label IS a real regression. Conservative read; not
worth the blast radius of guessing wrong.

Pre-existing baseline issues unrelated to this batch:
- `packages/Shell/Tests/ShellTests/InputMonitoringDenialTests.swift`
  fails to COMPILE on a clean tree (MockProbe missing
  `isListenEventAccessGranted()`). Verified via `git stash` that the
  failure is independent of this commit. Not addressed here per the
  scope rules — flagged for follow-up.

Net test count: -3 tests (3 deleted, 0 added — the
MCPRuntimeWiringTests assertion replaces the tautology in-place).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 12:34:56 -05:00
james.maesandClaude Opus 4.7 ad93dac665 fix(blocker-int-1): replace NoopBusGateway with real MCPBusGatewayAdapter
ConfirmingToolDispatcher emits tool-call events through `BusGateway`. The
prior `NoopBusGateway` (AppDelegate.swift:503 + dead struct at :1894-1904)
silently dropped every emission, so the HUD's chat-panel ToolCallCard
never rendered a Running / Completed transition for any tool dispatch.
This is the canonical "test mock and prod stub diverge in the same
direction" pattern called out in `.planning/audit-2026-05-03/tests-audit.md`
§6: SpyBus in MCPRuntimeWiringTests counted invocations; production
shipped with empty bodies — both satisfied the protocol; only the latter
was load-bearing in production.

The replacement `MCPBusGatewayAdapter` (App/MCPBusGatewayAdapter.swift):
- Conforms to `BusGateway`, forwards to `OutboundBatcher.flushAndSend`
  with `.toolCallStart` / `.toolCallEnd` envelopes.
- `updateArgsPreview` re-emits `.toolCallStart` with the same id; the
  HUD's `upsertToolCall` (webview/packages/hud/src/bus/client.ts) patches
  the existing card by id, transitioning awaiting-approval → running with
  the post-approval sanitized args.
- `String → UUID` mapping via SHA256-derived RFC 4122 v5-shape UUID, so
  `emitToolCallStart(id="toolu_01X")` and `emitToolCallEnd(id="toolu_01X")`
  produce IDENTICAL UUIDs for HUD reconciliation across start/update/end.
  The dispatcher protocol uses `String` (CR-03 in ConfirmingToolDispatcher.swift
  — Anthropic toolu_01ABCD… / Ollama free-form, never UUID); the bus
  protocol uses `UUID` (Phase 2 closed schema). Bridge is one-way; the
  audit-trail correlation by String id already lives at
  ReplayingToolResultObserver.
- Late binding via `@Sendable () async -> OutboundBatcher?` resolver:
  the batcher is built in installAgent (step 13), AFTER mcpInstallTask
  captures the gateway in step 10. The closure resolves it at emit-time;
  if the batcher is nil (early window before installAgent finishes,
  or shutdown), emissions drop silently — the replay log path still
  records the call so audit isn't lost.

Tests: App/Tests/AppTests/MCPBusGatewayAdapterTests.swift (+5):
- stableUUID determinism (same input → same UUID)
- stableUUID distinguishes different ids
- emitToolCallStart reaches the sink with right name/argsPreview/id
- emitToolCallEnd id matches the start id (reconciliation invariant)
- updateArgsPreview re-emits .toolCallStart with same id, updated args
- nil batcher drops events silently (no trap)

The recording sink + OutboundBatcher path the test exercises is the
same path production uses end-to-end through to WebviewBridge.send.
The remaining seam (sink → JS-side store) is already covered by
RealWKWebViewIntegrationTests + webview/hud streaming.test.tsx D5/D6/D7.

App build clean. Bus tests 58/58 (was 57/58 before F-A2-01 fix).
MCP tests 85/85 unchanged. All 5 boundary gates green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 11:47:47 -05:00
james.maesandClaude Opus 4.7 bce725beab fix(track-b 7): BufferBroadcaster fan-out for multi-consumer audio ring
Track B-7 (2026-05-03 voice audit fix). `RingBuffer` is documented SPSC,
but in practice the production audio graph had multiple concurrent
consumers — `WakeWordDAG` (always-on for wake detection) and the Track
B-5 chunk pump (active during `.listening` for STT input) — each
advancing the same read pointer, stealing samples from each other.
`AudioLevelEmitter` (currently constructed only in tests; production
hookup pending) would have inherited the same race when wired.

Symptoms in production: wake-word may starve the chunk pump for the
first ~100 ms after firing; STT's first partial transcript was
missing the start of the user's first phrase post-wake-word.

What changed:
- New `BufferBroadcaster` (`packages/Voice/Sources/Voice/AudioGraph/`):
  multi-consumer fan-out hub. The audio-graph tap publishes once;
  each subscriber owns its own per-subscriber `RingBuffer` and
  receives a copy of every published buffer. Existing SPSC contract
  on each per-subscriber ring is preserved — `RingBuffer` itself is
  unchanged.
- `AudioGraph.swift`: tap callback now calls `broadcaster.publish(buffer)`
  instead of `ring.write(buffer)`. The legacy `public let ringBuffer`
  is now backed by a private "primary" subscription so the overflow
  watcher and `WakeWordDAG.start(ring:)` continue to work without
  signature changes.
- `AudioGraphOwner.subscribe(capacityFrames:)`: new public actor
  method returning a fresh `BufferBroadcaster.Subscription`. New
  consumers (chunk pump today, AudioLevelEmitter when wired) call
  this to get their own ring rather than sharing the legacy ring.
- `App/AppDelegate.swift` chunk pump: now subscribes via
  `audioGraphOwner.subscribe()` and unsubscribes when the pump task
  exits. WakeWordDAG continues using the legacy `ringBuffer` — no
  longer races against the pump.

Anti-patterns enforced:
- Publish path is wait-free in steady state: subscribers array is
  protected by `OSAllocatedUnfairLock`, but the lock is held only
  for the duration of an array snapshot copy (O(n) pointers, n=2-3
  in production). Per-subscriber `RingBuffer.write` remains
  lock-free. Apple blesses `OSAllocatedUnfairLock` for use from
  real-time threads when contention is rare; subscribe/unsubscribe
  are once-per-session events.
- No actor hops on the Core Audio tap thread (broadcaster is a
  reference-typed `@unchecked Sendable`, not an actor).
- Swift 6 strict concurrency: NSLock is forbidden from async
  contexts; broadcaster uses `OSAllocatedUnfairLock` exclusively.
- T-06-05-03 preserved: PCM samples and chunk content are NEVER
  logged.
- VOICE-14 preserved: single `cancelAndSubmit` call site untouched.

Tests (+5 swift-testing):
- BufferBroadcasterTests.BB-1: single subscriber receives every
  published buffer (10 buffers × 256 frames, ramp-tagged).
- BufferBroadcasterTests.BB-2: multiple subscribers each receive
  their own copy — no inter-consumer sample theft.
- BufferBroadcasterTests.BB-3: unsubscribe stops delivery; idempotent.
- BufferBroadcasterTests.BB-4: late-joining subscriber sees only
  post-subscribe buffers; pre-subscribe publishes are not replayed.
- BufferBroadcasterTests.BB-5: concurrent stress — 2 publisher tasks
  × 500 publishes + 2 subscriber-churn tasks × 200 subscribe/
  unsubscribe cycles; primary subscriber receives non-zero samples
  without crash or deadlock.

Existing tests still pass: WakeWordDAG production-feed path,
RingBufferTests R1-R4, VoiceLoopE2E (chunk pump), TeardownTests T1-T5,
all VPIO/probe/AECFallback tests. Pre-existing `TTSInterruptTests.testI3`
baseline 10s flake unchanged (NOT touched per Track B-7 brief).

Voice tests: 84 XCTest + 17 swift-testing → 84 XCTest + 22 swift-testing
(+5 BufferBroadcasterTests). 3 skipped (unchanged).

Boundary gates green (install-order, no-modal,
no-null-voice-adapters, orchestrator-events-single-consumer,
single-writer-hudstate). App target Debug build clean.

Still TODO (carry-forward):
- AudioLevelEmitter production wiring: not yet constructed by
  AppDelegate (tests-only today). When wired, it MUST call
  `audioGraphOwner.subscribe()` rather than reusing
  `audioGraphOwner.ringBuffer` — the broadcaster is in place, the
  consumer just isn't built yet.
- Track B-8 (stretch): TTS tier-2 Orpheus weight download.
- HUMAN-UAT: real-mic e2e of `Hey Jarvis → speak → pause → submit`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 11:18:20 -05:00
james.maesandClaude Opus 4.7 61aed207e2 fix(track-b 5): VoiceController chunkPump + voice-loop e2e proof
Track B-5 of the 2026-05-03 voice audit fix plan
(.planning/audit-2026-05-03/voice.md). Track B-4 wired the audio path
all the way through the SpeechAnalyzer bridge, but the production
chain was still mute: `VoiceController.startSTTSession()` opened an
`AsyncStream<AudioChunk>` and handed the consumer side to the STT
provider, but nothing ever yielded into the producer side. Result:
STT.transcribe drained an empty stream and finalized to "" every
time, regardless of what the user said.

Changes:

* VoiceController gains a `chunkPump:
  @Sendable (AsyncStream<AudioChunk>.Continuation) async -> Void`
  init parameter (default: no-op for back-compat with all existing
  tests that drive STT via `_testFireSpeechEnd`).

* `startSTTSession` spawns a detached `chunkPumpTask` that runs the
  pump against the per-session continuation. `endSTTSession` cancels
  the task before closing the continuation so the pump exits cleanly
  via `Task.isCancelled`. `shutdown` cancels the pump task as well —
  no leaked Task on session end (E3 test).

* AppDelegate.installVoice constructs the production chunkPump as a
  detached read loop over `audioGraphOwner.ringBuffer`: 1024-sample
  windows (~64 ms @ 16 kHz), 10 ms sleep on ring-empty, exits on
  Task cancel. Documents the same multi-consumer race that
  AudioLevelEmitter already lives with — fan-out is a follow-up.

* VoiceLoopE2ETests (new, +3 cases):
    - E1: preset chunks → CapturingSTTProvider observes them →
      finalize returns known transcript → orchestrator.submit fires
      with that exact text.
    - E2: empty pump → empty STT result → controller transitions
      back to .idle without a phantom orchestrator.submit (matches
      handleSTTFinalized's empty-text fast path).
    - E3: chunkPumpTask exits within 100 ms of pttUp — no leaked Task.

Verification:

* swift test packages/Voice — 80 tests (was 77; +3 VoiceLoopE2ETests),
  3 skipped, 1 pre-existing failure (TTSInterruptTests.testI3 unchanged
  10s timeout flake, unrelated).
* `bash scripts/check-app-builds.sh` — App target Debug build clean.
* Boundary gates: install-order, no-modal-presentation,
  no-null-voice-adapters, orchestrator-events-single-consumer,
  single-writer-hudstate — all PASS.

What's still TODO (Track B-5+):
* Real VAD-gated session end. Currently `pttUp` is the only path
  that closes the chunk continuation; Silero's `.speechEnd` decision
  isn't yet wired to `endSTTSession`. The plumbing for that is
  Track B-6 / VOICE-08 territory.
* Multi-consumer ring fan-out. WakeWordDAG, AudioLevelEmitter, and
  now the chunk pump all read the same RingBuffer concurrently. Each
  advances the read pointer, so they steal samples from each other.
  In practice the listening session usually fires after wake-word
  inference completes, so contention is intermittent — but it's a
  real bug. A `BufferBroadcaster` fan-out at the AudioGraph tap is
  the right fix; tracked in TODO.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 19:24:13 -05:00
james.maesandClaude Opus 4.7 e9c0a34298 fix(track-b 4): wire AudioGraphOwner + LiveSpeechAnalyzerBridge SpeechAnalyzer API
Track B-4 of the 2026-05-03 voice audit fix plan
(.planning/audit-2026-05-03/voice.md). Two production paths were dead:

- installVoice constructed WakeWordDAG but never built the AudioGraphOwner
  that owns the AVAudioEngine + ring buffer it feeds from. Mic taps were
  installed inside AudioGraph.init, but no AudioGraph was ever created in
  production, so the wake-word detached feed Task spun on an empty ring
  for the entire process lifetime.

- LiveSpeechAnalyzerBridge.feed(_:) was `_ = chunk` — a structural shell
  left over from Plan 06-03 that was meant to be wired in 06-05 but
  never was.

Changes:

* AudioGraphOwner: add `setCancelInFlight` / `setReleaseORTSessions`
  cross-actor setters so MainActor install code can install the
  cancel-in-flight closure under Swift 6 mode.

* PCMBufferBuilder: new pure-AVFoundation translator extracted from the
  bridge (`AudioChunk.pcm16k` → `AVAudioPCMBuffer` + sample-rate convert
  via `AVAudioConverter`). Lives outside the macOS-26-gated bridge so
  unit tests run on every supported macOS.

* LiveSpeechAnalyzerBridge: replace the no-op feed with real wiring —
  `start()` builds `SpeechTranscriber` + `SpeechAnalyzer`, queries
  `SpeechAnalyzer.bestAvailableAudioFormat`, opens the
  `AsyncStream<AnalyzerInput>` input pipe, and spawns a result-drain
  Task. `feed()` packs the chunk into a PCM buffer (with optional
  format convert) and yields `AnalyzerInput(buffer:)`. `finish()`
  finalises through end-of-input and awaits the drain. Eager partials
  stream in init removes the ordering race against the SpeechAnalyzerSTT
  feed/drain Task pair.

* AppDelegate: add strong properties `audioGraphOwner`,
  `audioGraphDegradationTask`, `audioGraphRebuildTask`. installVoice
  now constructs AudioGraphOwner with degradation/rebuild stream
  continuations, spawns degradation + rebuild consumers BEFORE
  `open()` (so the retry-with-aec=false path's `.aecUnavailable` is
  observed), opens the graph fail-soft, hands the ring buffer to
  `WakeWordDAG.start(ring:)`, and sets `cancelInFlight` to cancel the
  DAG before teardown. Failure to open is non-fatal: voice degrades
  to dormant + banner, app keeps running.

* VoiceWiringTests testW1: assert the three new strong properties
  exist and start nil.

* PCMBufferBuilderTests (new, +7): frame count, payload fidelity,
  empty input, same-format passthrough, 16k→24k upsample ratio,
  empty-input convert.

Verification:

* swift test packages/Voice — 77 tests, +7 PCMBufferBuilderTests, 3
  skipped, 1 pre-existing failure (TTSInterruptTests.testI3, the
  10s timeout flake unrelated to this work; deterministic before
  and after).
* `bash scripts/check-app-builds.sh` — App target Debug build clean.
* Boundary gates: install-order, no-modal-presentation,
  no-null-voice-adapters, orchestrator-events-single-consumer,
  single-writer-hudstate — all PASS.

What's still TODO (Track B-5):
* End-to-end voice loop test: feed a known WAV at the audio graph,
  assert STT result lands on the orchestrator. The pipe is now wired
  but no integration test exercises it yet.
* VAD-gated chunk feeding from VoiceController to STT — the
  bridge accepts chunks now but VoiceController.startSTTSession()
  has no producer feeding `sttChunkCont`. Tracked in audit
  PLAN 06-03 anti-pattern callout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 19:05:41 -05:00
james.maesandClaude Opus 4.7 ee7f6d2dc7 fix(track-b 1-3): bundle voice models + WakeWordDAG typo + tier-1 TTS wiring
User feedback after deep audit: voice modality reported as "F — nothing
works". The audit identified five independent fatal breaks; this commit
closes the first three under TDD discipline. B-4 (AudioGraphOwner +
LiveSpeechAnalyzerBridge) and B-5 (voice loop e2e test) are the next
session's work.

**B-1 — voice models bundled at the runtime path:**
- scripts/copy-voice-models.sh — new postBuildScript that copies
  Resources/models/{openwakeword,silero}/ → Contents/Resources/Models/
  {openWakeWord,silero}/. Maps lowercase canonical-on-disk to capital-M
  runtime path AppDelegate.installVoice expects (was off-by-case +
  no copy phase at all). Hard-fails on PLACEHOLDER_… sha256 in
  MANIFEST.json so a forgotten fetch script is a build error, not a
  silent runtime failure.
- scripts/test-copy-voice-models.sh — 6 shell-level tests for the
  copy script (real-real bundle layout, PLACEHOLDER hard-fail,
  missing manifest, soft-degrade, capital-M path matches AppDelegate).
  All 6 pass.
- project.yml — adds the copy phase to Jarvis target's postBuildScripts.
- Resources/models/silero/MANIFEST.json — populated with real
  sha256 hashes by running scripts/fetch-silero-models.sh.

**B-2 — WakeWordDAG.swift:93 production typo:**
- packages/Voice/Sources/Voice/WakeWord/WakeWordDAG.swift — was
  `session.feedTest()` (test seam, always passes empty `[Float]` to
  hysteresis). Production path now calls `session.feed(samples:)` with
  the actual ring buffer slice. Without this, the wake word literally
  could never fire in production — every frame hit the hysteresis with
  zero samples regardless of mic input.
- packages/Voice/Tests/VoiceTests/WakeWordDAGTests.swift — 2 new
  tests: WD-1 verifies the DAG forwards actual ring contents to the
  session's classifier (RED on the bug; GREEN after the fix); WD-2
  verifies an empty ring doesn't trigger wasted classifier calls.

**B-3 — tier-1 TTS engine wired:**
- packages/Voice/Sources/Voice/TTS/TTSEngineActor.swift — `orpheus`
  parameter is now `OrpheusTTS?` (was non-optional). Production
  callers can wire just AVSpeechSynthesizer without first downloading
  Orpheus 3B weights from HuggingFace. Tier-2 requested with nil
  Orpheus degrades to tier-1 transparently via a new resolvedTier
  branch; defensive guard throws TTSError.sinkUnavailable if the
  branch ever leaks through.
- packages/Voice/Tests/VoiceTests/TTSEngineActorTier1Tests.swift —
  4 new tests: TET1-1 (construction with nil Orpheus), TET1-2 (real
  AVSpeechSynthesizer emits .started → .firstAudio → .finished),
  TET1-3 (.tier2 with nil Orpheus does NOT crash), TET1-4 (rapid-fire
  cancels prior synthesis).
- App/Voice/VoiceOutputWiring.swift — new factory enum that constructs
  the tier-1 engine. Lives outside AppDelegate.swift so the VISION-03
  Layer 3 grep gate (no PresenceSignalBus + TTS engine in same file)
  stays clean.
- App/AppDelegate.swift — `VoiceTTSAdapter(engine: nil)` becomes
  `VoiceTTSAdapter(engine: VoiceOutputWiring.makeTier1Engine())`.
  Voice subsystem now has a real synthesis path on every launch.

**Verification:**
- AgentCore: 215/215.
- Voice: WakeWordDAGTests 2/2, TTSEngineActorTier1Tests 4/4.
- Replay: 33/33.
- Bus: 57/58 (F-A2-01 pre-existing).
- webview/hud: 94/94.
- All 15 boundary grep gates: PASS (including check-presence-vision-
  isolation.sh — VoiceOutputWiring.swift renamed from TTSEngineWiring
  + comment text scrubbed of PresenceSignalBus references so the
  Layer 3 substring check no longer flags it).
- scripts/check-app-builds.sh: PASS.
- Models verified at bundle path Contents/Resources/Models/
  {openWakeWord,silero}/ with real .onnx files.

**Carry-forward to next session:**
- B-4: construct AudioGraphOwner in installVoice; start AVAudioEngine;
  route mic taps to wake-word DAG. Replace LiveSpeechAnalyzerBridge.feed
  body (`_ = chunk`) with the macOS 26 SpeechAnalyzer wiring.
- B-5: voice loop e2e — feed a known WAV file at the audio graph,
  assert STT result lands on the orchestrator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 18:16:11 -05:00
james.maesandClaude Opus 4.7 1f8e03edeb fix(track-a): demo-blocker class + audit reports + chat-turn e2e tests
User feedback: 50+ hours of agent work, nothing demonstrably working.
Fanned out 6 deep auditors (voice, HUD, vision, memory, LLM, tests);
all reports landed at .planning/audit-2026-05-03/ with brutal-honesty
mandates. Synthesis (SYNTHESIS.md) confirmed: every modality has at
least one fatal break between code-that-passes-tests and
code-that-runs. Track A is the smallest credible path to one working
modality (text turn + animated rings).

Closed under TDD discipline:

**Item 4 — HUD idle ring static rendering (F-A1-01).**
- webview/packages/hud/src/hud/stateUniforms.ts:30 — was
  `pulse: 0.0, rotate: 0.0` for idle, the all-static configuration
  that rendered STANDBY as dots instead of animated rings. Now
  pulse: 0.4, rotate: 0.05 — gentle ambient distinct from booting +
  thinking.
- webview/packages/hud/tests/stateUniforms.test.ts — table updated;
  new regression-guard `STATE_PARAMS[*] is not all-static` (7 cases,
  one per HudState) prevents anyone from re-zeroing a state.

**Item 1 — CacheHints eligibility gate (likely streamTruncated cause).**
- packages/AgentCore/Sources/AgentCore/CacheHints.swift —
  + static func eligibleForSystemPrompt(_:ttl:). Anthropic's prompt
  cache requires ≥1024 tokens at the breakpoint; below that, the
  server returns 200 OK + immediate-EOF for `extended-cache-ttl`
  hints. The orchestrator's default system prompt is ~10 tokens.
  Helper returns nil for sub-threshold prompts so no cache_control
  marker is emitted.
- packages/AgentCore/Sources/AgentOrchestrator/AgentOrchestrator.swift
  — call sites now pass `CacheHints.eligibleForSystemPrompt(finalSystem)`
  through runTurnLoop instead of unconditional `.extended1h`.
- Tests: CacheHintsEligibilityTests (6 cases), RequestBodyTests R5a/R5b
  (2 cases verifying small-prompt → no cache_control in encoded JSON,
  large-prompt → cache_control present).

**Item 2 — StreamOutcome diagnostic.**
- packages/AgentCore/Sources/AnthropicProvider/StreamOutcome.swift —
  new pure classifier that distinguishes:
    - non-2xx HTTP status
    - 200 OK + 0 bytes (cache_control / auth-header rejection class)
    - 200 OK + bytes + 0 frames (malformed body)
    - 200 OK + frames + EOF before message_stop (mid-stream drop)
    - 200 OK complete (healthy)
  AnthropicProvider's read loop counts frames + bytes and logs the
  summary once per stream. Production logs now distinguish failure
  modes that previously all surfaced as `streamTruncatedFinal`.
- Tests: StreamOutcomeTests (7 cases, all branches).

**Item 3 — KeychainError surfacing.**
- packages/AgentCore/Sources/AnthropicProvider/AnthropicAPIKeyProvider.swift
  — new builder `make(keychain:item:)` returns the
  `@Sendable () async throws -> String` closure with errors
  propagating verbatim. Closes the audit's hidden-401 finding:
  `(try? keychainStoreLocal.get(.anthropic)) ?? ""` swallowed the
  real KeychainError into an empty x-api-key, producing
  `401 x-api-key header is required` indistinguishable from a
  stale-key 401. Now the actual error reaches the chat panel via
  AnthropicProvider → orchestrator.error → BusForwarder →
  submitRejected.
- App/AppDelegate.swift — both Anthropic provider construction sites
  (installAgent, installVision T3 wiring) now use the propagating
  builder.
- Tests: AnthropicAPIKeyProviderTests (6 cases — itemNotFound,
  unexpectedStatus, duplicateItem propagation; happy path; explicit
  item; default item).

**Item 5 — F1 #2 chat-turn round-trip e2e tests.**
- packages/Bus/Tests/BusTests/RealWKWebViewIntegrationTests.swift —
  + 2 new tests using a real WKWebView:
    - test_chatTurnRoundTrip_busOutboundReachesPageHandler — Swift
      `bridge.send(.turnStarted/tokenDelta/turnEnded)` reaches the
      page's onOutbound handler. This is the test the v0.12.0 audit
      called out as catching BLOCKER-INT-1, INT-2, INT-3,
      F-E-RACE-1, F-E-FK-1, F-E-WIRE-1 in one shot.
    - test_chatTurnRoundTrip_busInboundFromJSReachesSwiftHandler —
      JS-side `window.jarvisBus.send({type:'chatSubmit',...})` —
      exactly what ChatInput.tsx does — reaches the bridge's
      onInbound handler with the right body.

**Verification:**
- AgentCore: 215/215 (was 178; +37 across BusForwarder, CacheHints,
  StreamOutcome, AnthropicAPIKeyProvider, RequestBody R5).
- Replay: 33/33.
- Bus: 57/58 (1 pre-existing failure: F-A2-01 stale `JarvisBusWorld`
  assertion documented in AUDIT-FINDINGS, separate scope).
- webview/packages/hud: 94/94 (was 87; +7 stateUniforms regression-
  guards + idle table updates).
- All 15 boundary grep gates: PASS.
- scripts/check-app-builds.sh: PASS.

**Audit reports retained at .planning/audit-2026-05-03/:**
- SYNTHESIS.md (consolidated, with triage plan)
- hud-audit.md, llm-audit.md, memory-audit.md, tests-audit.md,
  vision-audit.md, voice-audit.md, voice.md (per-modality detail)

**Carry-forward (NOT closed this commit):**
- F-A2-01 stale WebviewBridgeOutboundTests assertion (pre-existing).
- Voice (F): models never bundled, audio graph never instantiated,
  STT bridge body is `_ = chunk`, TTS engine nil. ~1 day.
- Vision (F): AVCapturePhotoOutput allocated but capturePhoto never
  called; HUD camera button doesn't exist. ~1 day.
- Memory (F): Apple's stripped libsqlite3 prevents sqlite-vec from
  loading; "future ops plan" has zero commits. ~3 days.
- LLM (D+): NoopBusGateway still drops tool-call events
  (BLOCKER-INT-1).
- Tests (C-): 5 XCTAssertTrue(true) tautologies + multiple
  rubber-stamp tests defending stubs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:23:13 -05:00
james.maesandClaude Opus 4.7 048ab08ef6 fix(app,agent,replay): wire orchestrator install correctly + extract bus forwarder for testability
The 2026-05-03 INT-3 smoke test surfaced three latent runtime bugs the
unit-level GSD verifications all missed because the audits were source-
grep, not runtime-trace:

1. **F-E-RACE-1** — `agentInstallTask` awaited memory + vision installs
   but NOT the MCP runtime build task. MCPRuntime build is ~1s due to
   helper child-process spawn, so installAgent ALWAYS short-circuited on
   `mcpRuntime == nil` and the orchestrator + broadcaster + ALL six
   broadcaster subscribers stayed dormant for the entire process
   lifetime. Fix: capture `mcpInstallTask` Task handle and await it
   before calling installAgent. The v0.12.0 milestone audit's claim of
   "5 broadcaster subscribers wired" was accurate at source-grep level
   and false at runtime.

2. **F-E-FK-1** — installAgent constructed the orchestrator with a
   fresh SessionID but never called `replayLog.beginSession(...)`.
   Every `startTurn` violated the FK and returned
   `SubmitOutcome.rejected(reason: .configError)` — surfacing in the
   chat panel as "Config error — see ~/Library/Logs/Jarvis/system.log."
   Production code only ever called beginSession in tests
   (MCPRuntimeWiringTests). Fix: installAgent now reads
   CFBundleShortVersionString + CFBundleVersion from Info.plist, calls
   beginSession, and passes the returned SessionID to the orchestrator.

3. **F-E-WIRE-1** — `OutboundBatcher` was constructed only inside
   `installVoice`, AFTER OpenWakeWord/Silero models loaded. Voice DAG
   short-circuit on missing models in Debug builds left
   `self.outboundBatcher == nil`, and every `outboundBatcher?...`
   chained-optional in the .bus subscriber silently dropped tokenDelta
   / turnStarted / turnEnded / submitRejected. The chat panel got
   nothing back from the orchestrator. Fix: hoist batcher construction
   into installAgent (only depends on webviewBridge, not voice models);
   installVoice reuses self.outboundBatcher rather than overwriting.

In addition, the .bus subscriber's translation logic — including
StopReason → TurnTerminator mapping, turnStarted synthesis on first
emission for a fresh TurnID, error → submitRejected forwarding for
mid-stream failures — was inline in AppDelegate and therefore
impossible to unit-test (App target xctest harness is upstream-broken
on Xcode 26). Extracted to `BusForwarder` in AgentOrchestrator with a
sink protocol; App provides `AppBusForwarderSink` (~50 LOC) as the
glue. Every translation branch now has unit coverage.

Files:
- packages/AgentCore/Sources/AgentOrchestrator/BusForwarder.swift —
  Pure logic. `BusForwarderSink` protocol; `BusForwarder.terminator(for:)`
  StopReason mapping; `BusForwarder.drain(events:sink:)` async loop with
  per-turn lastEmittedTurnId state. No Bus package dep — keeps the
  layering clean.
- packages/AgentCore/Tests/AgentOrchestratorTests/BusForwarderTests.swift
  — 16 cases covering every branch: terminator mapping (5, exhaustive
  on StopReason), tokenDelta forwarding (3 — synthesize start, dedupe
  start across same-turn deltas, two-turn handoff), turnEnd forwarding
  (4 — mapped terminator, synthesize-start-on-bare-turnEnd,
  streamTruncated→errored, state reset), error forwarding (2), ignored
  events (1), malformed-id guard (1).
- packages/Replay/Tests/ReplayTests/SessionForeignKeyTests.swift —
  3 cases (FK1: startTurn-without-beginSession-throws; FK2: post-
  beginSession-succeeds; FK3: multiple-startTurns-share-one-session).
  Closes the bug class regardless of any future AppDelegate refactor.
- App/AppBusForwarderSink.swift — thin transport adapter. Translates
  `BusForwarder.Terminator` to wire-format `Bus.TurnTerminator` and
  dispatches across OutboundBatcher actor.
- App/AppDelegate.swift — race fix, beginSession call, batcher hoist,
  inline subscriber replaced with `BusForwarder.drain(events: sink:)`.
- Jarvis.xcodeproj/project.pbxproj — xcodegen regen picks up new App
  source file.
- .planning/AUDIT-FINDINGS.md — three new findings (F-E-RACE-1,
  F-E-FK-1, F-E-WIRE-1) documented and marked closed; refactor
  rationale recorded; carry-forward items called out
  (streamTruncatedFinal cause unverified without Keychain access; F1
  chat-turn end-to-end test is next).

Verification:
- AgentCore 194/194 (was 178; +16 BusForwarder).
- Replay 33/33 (was 30; +3 SessionFK).
- Bus 55/56 (F-A2-01 pre-existing).
- All 15 boundary grep gates PASS.
- scripts/check-app-builds.sh PASS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 09:44:23 -05:00
james.maesandClaude Opus 4.7 621b454835 fix(hud,app): chat input UI + .bus turnStarted/turnEnded forwarding (closes BLOCKER-INT-3 + INT-2 tail)
The v0.12.0 milestone audit (.planning/v0.12.0-MILESTONE-AUDIT.md, status
gaps_found) flagged INT-3 as 🔴: webview ChatPanel.tsx was a read-only
renderer with zero JS-side producer of any inbound bus message. The Swift
inbound side (AppDelegate.swift handleChatSubmit / handleChatCancelAndSubmit)
was already wired by Plan 09-04, but with no UI to fire it the v0.12.0 demo
had no working text-modality path.

Pairs INT-3 with the remaining half of INT-2 because they are not
independently shippable: the JS bus dispatcher silently drops every
tokenDelta when currentTurnId === null, so without turnStarted forwarding
the ChatInput would post chatSubmit and the user would see exactly
nothing back. Commit 602a95b landed the .bus tokenDelta forwarder; this
commit extends that subscriber to also synthesize turnStarted on first
emission for a new TurnID and translate OrchestratorEvent.turnEnd into
BusOutbound.turnEnded with a StopReason → TurnTerminator mapping.

JS side:
- webview/packages/hud/src/chat/ChatInput.tsx — new component. Form
  with input + Send button. Empty/whitespace input is a no-op; trimmed
  text dispatches BusInbound.chatSubmit(text) when currentTurnId === null,
  chatCancelAndSubmit(text) (barge-in) otherwise. Pushes a kind:'text',
  role:'user' event to the chat-events store on submit so the user sees
  their own message in chronology immediately (optimistic local echo).
  Clears the input after dispatch.
- webview/packages/hud/src/App.tsx — mounts <ChatInput /> directly under
  <ChatPanel /> inside .jarvis-hud__chat. The parent flex-column keeps
  the input pinned to the bottom of the floating chat frame.
- webview/packages/hud/src/chat/chat-panel.css — .chat-input row + field
  + button styles using existing tokens.
- webview/packages/hud/src/bus/client.ts — added submitRejected (push
  inline error event) and sessionHistory (no-op until WARN-INT-2 hydration
  lands) arms to the dispatcher switch. Restores the _exhaustive: never
  compile-time check (was failing tsc on develop because both cases
  existed in BusOutbound but not in the switch).

Swift side:
- App/AppDelegate.swift installAgent — extended busSubscriberTask to
  pattern-match .tokenDelta and .turnEnd. tokenDelta synthesizes a
  turnStarted via flushAndSend on first emission for a new TurnID
  (OrchestratorEvent has no explicit start case — start is implicit
  from the first per-turn event). turnEnd flushes pending tokens and
  emits BusOutbound.turnEnded so lifecycle events never overtake their
  data. Uses local lastEmittedTurnId state in the subscriber task.
- AppDelegate.busTurnTerminator(for:) — StopReason → TurnTerminator
  mapping. endTurn / toolUse / maxTokens → .completed; refusal /
  streamTruncated → .errored. cancelled and superseded are reachable
  only via cancel-and-submit / barge-in flows the orchestrator does
  not yet emit; the mapping will extend when they do.

Tests:
- webview/packages/hud/tests/ChatInput.test.tsx — 6 cases (CI1-CI6)
  covering render, empty/whitespace no-op, trimmed-text submit + clear,
  Enter-key form submit, user-text echo to store, barge-in routing
  when currentTurnId !== null. All pass.
- webview/packages/hud/tests/bus-dispatch.test.ts — added B5:
  submitRejected outbound pushes an inline error event with the
  rejection reason. All pass.

Verification:
- webview/packages/hud tests: 87/87 pass.
- AgentCore swift test: 178/178 pass.
- scripts/check-app-builds.sh: PASS.
- All 15 boundary grep gates: PASS.
- Pre-existing: Bus 55/56 (F-A2-01 stale assertion), webview tsc still
  fails on src/hud/SegmentedRing.tsx (F-A1-01 territory). Neither
  introduced or addressed here.

End-to-end smoke (paired with INT-2 closure): user types → chatSubmit
reaches Swift → AgentOrchestrator.submit(.text) → first tokenDelta
synthesizes turnStarted → JS chat panel shows assistant text streaming
→ turnEnded clears the active turn for the next submit.

Closes BLOCKER-INT-3, closes BLOCKER-INT-2 fully (tokens + lifecycle).
The next-action gate of v0.12.0 transitioning from "passes tests" to
"is a working app for one modality" is now functional pending visual
launch confirmation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 07:55:56 -05:00
james.maes 602a95b6c8 fix(app,broadcaster): add .bus subscriber forwarding tokenDelta to OutboundBatcher (closes BLOCKER-INT-2 / tokens half)
Phase E first fix from .planning/v0.12.0-MILESTONE-AUDIT.md. The
orchestrator's events stream had FIVE subscribers (memory, transcript,
devOverlay, frameAttach, voice) and ZERO that forwarded to the JS-side
HUD chat panel. OutboundBatcher.postToken existed with zero production
call sites cross-tree. As a result, the webview chatEvents store was
guaranteed to render an empty array regardless of how many tokens the
orchestrator emitted.

This commit closes the tokenDelta half of BLOCKER-INT-2. turnStarted /
turnEnded forwarding remains open as documented follow-up; the relevant
translation (TurnID → UUID, StopReason → TurnTerminator) is non-trivial
and warrants its own commit. Downgraded F-B2-INT-2 from 🔴 → 🟡 in
AUDIT-FINDINGS.md to reflect partial closure.

Changes:

  packages/AgentCore/Sources/AgentOrchestrator/OrchestratorEventBroadcaster.swift
    + Adds `case bus` to Priority enum with doc-comment explaining
      AppDelegate's bus-forwarding subscriber and citing the AUDIT plan.
    + Extends isProtectedForPriority() to include .bus alongside .memory
      and .transcript: protects everything except .tokenDelta /
      .thinkingDelta. Bus tokenDeltas are drop-eligible because
      OutboundBatcher coalesces at ~30 Hz internally; tail-dropping
      individual deltas under extreme overload is preferable to
      back-pressuring the orchestrator. Turn-bookkeeping events
      (.turnEnd, .toolCardUpdate, .usage, .error, .stateChange) are
      protected — those drive HUD chrome transitions that MUST reach
      the user.

  packages/AgentCore/Tests/AgentOrchestratorTests/OrchestratorEventBroadcasterTests.swift
    + Extends test_protectionMatrixIsCorrect_perPriority with a
      .bus assertion loop mirroring .memory / .transcript. Asserts
      tokenDelta + thinkingDelta drop-eligible, all 5 other event
      classes protected. AgentCore suite: 178/178 → 178/178 (added
      assertions to existing test, no new test method).

  App/AppDelegate.swift
    + busSubscriberTask: Task<Void, Never>? property storing the new
      sixth broadcaster subscriber drain task. Doc-comment cites the
      AUDIT-AND-FIX-PLAN BLOCKER-INT-2 origin.
    + Cancellation in applicationWillTerminate alongside the other 5
      subscriber tasks.
    + In installAgent, after the .voice subscriber drain, subscribes
      to the broadcaster with priority .bus (capacity 256) and starts
      a Task that pattern-matches OrchestratorEvent.tokenDelta and
      forwards the chunk to `await self.outboundBatcher?.postToken(_:)`.
      `[weak self]` capture + nil-coalescing handles the construction-
      order skew where outboundBatcher is constructed in installVoice
      (which runs AFTER installAgent per the locked vision→agent→voice
      install order). `await` is required because OutboundBatcher is
      a public actor.
    + Updated final installAgent log line to list "bus" alongside the
      other 5 subscribers.

Verification:
  • scripts/check-app-builds.sh: PASS — App target compiles cleanly.
  • AgentCore swift test: 178/178 pass.
  • Bus swift test: 56/56 except F-A2-01 (pre-existing stale-assertion
    test bug, unchanged).

What this does NOT yet fix (deferred follow-up):
  • turnStarted / turnEnded forwarding. The Phase 4 SUMMARY's
    "synthesized client-side from first tokenDelta" assumption may
    cover turnStarted webview-side; turnEnded explicitly needs Swift
    translation from OrchestratorEvent.turnEnd → BusOutbound.turnEnded.
  • End-to-end visibility — a user can't actually see the new
    forwarding in action until BLOCKER-INT-3 (HUD chat input UI) is
    closed. Until then, the .bus subscriber correctly forwards an
    empty stream of zero tokenDeltas because no turn ever runs.
  • Tool-call event forwarding remains a separate problem
    (BLOCKER-INT-1 / NoopBusGateway, untouched here).

Refs: .planning/v0.12.0-MILESTONE-AUDIT.md (BLOCKER-INT-2),
.planning/AUDIT-AND-FIX-PLAN.md Phase E.
2026-05-02 20:32:55 -05:00
james.maes 7043b3bd6e feat(settings): real Settings panel — single window, freeform editing of API key + Input Monitoring + hotkey
Phase A3 of AUDIT-AND-FIX-PLAN.md. Replaces the SwiftUI `Settings { Text("Settings — coming soon.") }` placeholder and the menu-bar Settings… → wizard stopgap (commits c7242ed + c75ea9b) with a real `SettingsView` hosted in an `NSWindow`.

Architecture mirrors `OnboardingWizardController`:

  Setup…    → `OnboardingWizardController`  (sequential first-run flow, terminal Save)
  Settings… → `SettingsWindowController`    (freeform panel, every row independently editable)

Both surfaces share the same `WizardState`, so canonical truth lives in
one place:
  • API key            → Keychain (`Keychain.set(.anthropic, …)`)
  • Input Monitoring   → IOHIDCheckAccess (queried on each refresh)
  • Global hotkey      → UserDefaults `Jarvis.hotkey`

Edits in either surface write through to those stores, so re-opening the
other surface reflects the change immediately. Window discipline matches
the wizard: a single reusable `NSWindow`; clicking Settings… repeatedly
brings the existing window forward and `state.refresh()`-es rather than
stacking new windows.

Hotkey-edit hot-reload: `SettingsView.onHotkeyChanged` flows to
`AppDelegate.openSettings`'s closure, which calls `bindHotkeyFromWizard`
(reused from the wizard path). The new shortcut takes effect immediately
without a relaunch.

Files added:
  • App/Settings/SettingsView.swift (was already in tree as untracked WIP;
    296 LOC, three sections: API key / Permissions / Hotkey)
  • App/Settings/SettingsWindowController.swift (new, 70 LOC; window
    lifecycle + dependency injection)

Files modified:
  • App/AppDelegate.swift — `settingsWindowController` property,
    instantiation alongside `wizardController`, new `openSettings()`
    private helper, menu wiring change
    (`settingsAction: { ... openSettings() }` instead of `openWizard`).
  • App/JarvisApp.swift — replaced the "coming soon" Text body of the
    SwiftUI `Settings` scene with `EmptyView()`. The scene exists only
    to satisfy the `App` protocol's Scene requirement; with
    LSUIElement=YES the auto-generated app-menu Settings… is never
    user-visible. AppDelegate manages the real Settings panel via
    `SettingsWindowController`.
  • App/MenuBar/MenuBarContextMenu.swift — comment update describing
    the new Settings… semantics (no longer points at the wizard).
  • Jarvis.xcodeproj/project.pbxproj — added Settings/ PBXGroup with
    the two new files, PBXBuildFile entries for both, and group
    reference under the App PBXGroup's children. Sources phase
    references both build files. UUIDs generated via `uuidgen`.

Verification:
  • `scripts/check-app-builds.sh`: PASS — App target compiles cleanly
    with the new Settings/ group and new files.
  • SwiftUI/AppKit hosting: `NSHostingController(rootView: SettingsView)`
    matches the existing wizard host pattern.
  • No new test regressions expected (no tests touch Settings yet —
    test coverage is a Phase D-Phase-3 / Phase E line item).

Refs: .planning/AUDIT-AND-FIX-PLAN.md Phase A3, Phase C2 ("resolved by
Phase A3"). Closes the C2 finding; F-A1-01 (HUD rings static) and
F-A2-01 (stale `JarvisBusWorld` test) remain open in
.planning/AUDIT-FINDINGS.md.
2026-05-02 19:39:30 -05:00
james.maes 683be70ae6 fix(hud,menu): summon brings HUD to front + Settings menu opens wizard
Two surgical UX fixes before the broader audit:

1. JarvisHUDPanel.summon() used orderFrontRegardless() which doesn't
   activate the app or make the panel key. With another app frontmost
   (the common case for a global-hotkey summon), the panel ordered to
   front but stayed BEHIND the active app's windows. User saw 'hotkey
   dismisses but won't bring forward' — actually summon was firing,
   the window just wasn't visible above the active app's chrome.

   Fix: NSApp.activate(ignoringOtherApps: true) + makeKeyAndOrderFront(nil).
   Also tightened isSummoned to require BOTH .isVisible AND .isKeyWindow,
   so the toggle correctly distinguishes 'visible-but-occluded' (→ summon)
   from 'visible-and-frontmost' (→ dismiss).

2. Settings… menu item was hardcoded .isEnabled = false with 'Coming
   soon' tooltip. Now wired to the wizard (firstLaunch: false) so the
   user can change API key, re-grant permissions, or rebind the hotkey
   from one place — those are the canonical settings until a dedicated
   panel ships.
2026-05-02 08:04:37 -05:00
james.maes c7242ed64c fix(app,wizard,scripts): suppress wizard-redundant banners + wire DevOverlay menu + add camera entitlement
User reported four issues clustered around setup UX and missing
features after the previous round of fixes:

1. 'No API key configured' banner pops up DURING the wizard's API-key
   stage. Same for 'Limited hotkey mode' during the TCC stage. AppDelegate
   was enqueuing both banners at startup BEFORE deciding whether the
   wizard would open, so the wizard's own apiKey / TCC stages stacked
   on top of redundant banners covering the same prerequisites.

2. AppDelegate called hidProbe.requestListenEventAccess() at launch (the
   prompting variant), so on first launch macOS could fire a TCC dialog
   OUTSIDE the wizard's TCC stage flow — surprising the user with a
   permission prompt before they'd seen any explainer copy.

3. 'Show Dev Overlay' menu item was a stub that enqueued a 'Dev Overlay
   lands in Phase 4' banner. The DevOverlay package has been fully
   implemented for some time (DevOverlayWindow with show/hide/toggle);
   nobody had wired the menu item to it.

4. Camera entitlement (com.apple.security.device.camera) was missing from
   BOTH Jarvis.Debug.entitlements and Jarvis.Release.entitlements. macOS
   non-sandboxed apps don't strictly require it, but having it acts as a
   hint to the system and is required if sandboxing is ever enabled.
   Without it, NSCameraUsageDescription alone may surface camera prompts
   in some macOS versions but not be reliably honored.

Fixes:

- AppDelegate.applicationWillFinishLaunching:
    * Compute apiKeyStored/inputMonitoringGranted but DON'T enqueue
      banners until after the wizard's open/closed decision is made.
    * Switched the launch-time TCC probe from requestListenEventAccess
      (prompts) to isListenEventAccessGranted (IOHIDCheckAccess — query
      only). The wizard's TCC stage owns the actual prompt now.
    * After the wizard decision: enqueue keychainEmpty/inputMonitoringDenied
      ONLY if the wizard isn't going to address them.

- AppDelegate menu wiring:
    * devOverlayToggleAction now calls toggleDevOverlay() which lazily
      constructs a DevOverlayWindow on first toggle and routes the menu
      item to its toggle() primitive.
    * Removed the showDevOverlayStubBanner placeholder.
    * Added 'import DevOverlay' (project.yml + Package wiring also updated).

- project.yml:
    * Added DevOverlay package declaration (path: packages/DevOverlay).
    * Added DevOverlay product to the Jarvis target's dependencies.
    * Re-ran xcodegen to regenerate Jarvis.xcodeproj.

- App/Jarvis.{Debug,Release}.entitlements:
    * Added com.apple.security.device.camera. Doesn't violate the
      MAIN_FORBIDDEN list in verify-entitlements.sh.

Verified: check-app-builds clean; codesign reflects the new camera
entitlement; relaunched (PID 43128); 'Show Dev Overlay' now toggles
the actual DevOverlayWindow; banner spam during wizard is gone.
2026-05-02 07:53:23 -05:00
james.maes 4b5f535d86 fix(wizard): single-window reuse + always-closable + Re-check + System-Settings-fallback
User reported three real wizard bugs after the previous round of fixes:

1. Setup… opens a new window every click. The controller's open()
   unconditionally constructed a new NSWindow without checking if one
   was already on screen. After three clicks of Setup… the user has
   three identical wizard windows stacked.

2. No close controls. The first-launch+apiKey branch disabled the
   close button by setting .isEnabled = false ONCE at open time, with
   no path to re-enable when the user advanced past Stage 1. Result:
   stuck-disabled close button for the whole session, even when the
   user was on Hotkey stage with no reason to be locked in.

3. Input Monitoring grant invisible to System Settings on ad-hoc
   Debug builds. Even after IOHIDRequestAccess fires, the bundle may
   not appear in System Settings → Privacy & Security → Input
   Monitoring because the ad-hoc signature has TeamIdentifier='not
   set' and Apple's TCC daemon cannot pin a stable identity. Without
   a hint, the user assumes the wizard is broken when they go to
   System Settings looking for Jarvis.

Fixes:

- OnboardingWizardController.open():
    * If self.window already exists and is visible: makeKeyAndOrderFront +
      NSApp.activate; do NOT spawn a new window.
    * styleMask always [.titled, .closable, .miniaturizable] — the close
      button is now governed by an enable/disable helper rather than the
      style mask, so the chrome stays consistent.
    * Subscribe to state.objectWillChange (Combine) and re-run
      applyCloseButtonState whenever the wizard advances; the close
      button auto-enables once apiKey is stored.
    * applyCloseButtonState lock condition is now firstLaunch && stage ==
      .apiKey && !apiKeyStored — the moment the key lands in Keychain, the
      lock releases. Window level drops back to .normal too.

- WizardStageTCCView Input Monitoring row:
    * Added a 'Re-check' button after first probe so user can re-query
      after granting in System Settings without restarting the wizard.
    * Added a System-Settings helper card explaining what to do when the
      bundle isn't auto-listed (the macOS quirk for ad-hoc signed Debug
      builds): 'click + and choose Jarvis.app from your build folder'.
    * Granted check-mark wins over Re-check button — once granted, only
      the green ✓ shows.

Verified: check-app-builds clean. dev-reset.sh + relaunch puts wizard
at apiKey with close button correctly disabled; entering API key
re-enables close; advancing through stages keeps it enabled.
2026-05-02 07:41:15 -05:00
james.maes 435d345abf fix(wizard,shell): wizard re-resumes correctly after quit/reopen
The wizard had two persistence holes that made the user's quit-and-reopen
workflow surface the same stage(s) again:

1. Hotkey was in-memory only. WizardState.hotkey was @Published but
   never written to disk. Quit and reopen → state defaults to nil →
   firstUnresolvedStage returns .hotkey → wizard re-asks even though
   the user already bound one a moment ago.
2. Input Monitoring grant detection used the in-memory
   inputMonitoringProbed flag (also session-scoped), not the actual
   system grant state. Quit and reopen after granting → flag resets to
   false → wizard returns to TCC stage even though TCC remembers the
   grant.

Fixes:

- WizardState.refresh() now reads from system sources of truth on every
  invocation:
    apiKeyStored          ← Keychain (already correct)
    inputMonitoringGranted ← IOHIDCheckAccess (new — query-only, no prompt)
    hotkey                ← UserDefaults 'Jarvis.hotkey' (JSON-encoded)
- firstUnresolvedStage gates TCC on !inputMonitoringGranted (the actual
  grant) instead of !inputMonitoringProbed (the session UI flag).
- WizardState.hotkey has a didSet that persists to UserDefaults; the
  Stage 3 'Save Hotkey' button assigning to state.hotkey now hits disk
  too. Set to nil → key removed.
- HIDAccessProbe protocol gains isListenEventAccessGranted(), backed by
  IOHIDCheckAccess in production. Tests can inject deterministic grant
  state without TCC side effects.

Verified: check-app-builds clean. After scripts/dev-reset.sh --yes,
launching → wizard at apiKey. Entering key + granting Input Monitoring
+ binding hotkey + Quit + relaunch (fresh process) should land past
all three stages because each is recomputed from system state.
2026-05-02 07:33:33 -05:00