Commit Graph
25 Commits
Author SHA1 Message Date
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.maes 0961fef40b feat(10-02c): add InProcessAwareToolDispatcher composite for B-01b dispatch routing
- Composite ToolDispatcher: name-routes between InProcessToolRegistry (memory
  + four self-knowledge tools) and inner stdio dispatcher (MCPToolDispatcher).
- Sits INSIDE ConfirmingToolDispatcher's chain so confirmation gating, bus
  emission, and observer hooks fire identically on both paths.
- In-process result bytes flow through the same SanitizeForModel.prepareForBoundary
  + ToolResultObserver pipeline as stdio results (audit-trail parity).
- Tools registered AFTER composite construction are still routable because
  the composite holds the registry actor reference and queries by name on
  every dispatch (resolves the installSelfKnowledgeTools-runs-after-installAgent
  install-order wrinkle).
- Closes 5/5 cases in InProcessAwareDispatchRoutingTests.
2026-05-07 11:19:13 -05:00
james.maes 6503b23301 feat(10-02c): add InProcessConfirmationCache for sync confirmation lookup
- Lock-protected Set<String> mirror of registered tools' requiresConfirmation flags.
- InProcessToolRegistry.register(_:) now writes through to the cache so the
  upcoming InProcessAwareToolDispatcher can answer requiresConfirmation(toolName:)
  synchronously from a nonisolated context (ToolDispatcher protocol contract).
- OSAllocatedUnfairLock: low-overhead, available since macOS 13.
2026-05-07 11:19:09 -05:00
james.maesandClaude Opus 4.7 043402c670 feat(10-02b): retain stdio Tool defs in MCPClient + add toolCatalog()
Plan 10-02b / B-01: MCPClient previously discarded the Tool definitions
returned by each helper's tools/list response, retaining only the
toolToServer name→server lookup. The Anthropic / Ollama provider
request bodies need name + description + inputSchema for each stdio
tool, but with the definitions lost there was nothing to feed the
catalog wiring at App/AppDelegate.swift:1274.

Changes:
- MCPClient gains a private `toolDefinitions: [String: Tool]` cache
  populated from each helper's listTools() response alongside
  toolToServer.
- New `toolCatalog() -> [ToolSchema]` re-encodes Tool.inputSchema
  (Value, JSON-encodable) to bytes and projects to the AgentCore
  ToolSchema shape. Empty descriptions degrade to a placeholder
  ("MCP tool '...' (no description provided by helper).") so we
  never drop a tool from the catalog.
- shutdown() also clears toolDefinitions (lifecycle parity with
  toolToServer / registry).
- Imports AgentCore for ToolSchema (asymmetric dep already declared
  in Package.swift; mirrors MCPToolDispatcher.swift's usage).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 07:33:28 -05:00
james.maesandClaude Opus 4.7 c5c5072b5d feat(10-02b): add toolDescription to InProcessTool + toolSchemas() projection
Adds the substrate that the production fix at App/AppDelegate.swift:1274
needs to source the tool catalog from the in-process registry instead of
the hardcoded empty array.

Protocol change:
- InProcessTool gains `var toolDescription: String { get }` — a
  human-readable one-line description surfaced in the Anthropic / Ollama
  tools[] payload's `description` field. Named `toolDescription` (not
  `description`) to avoid colliding with CustomStringConvertible.

All 7 production InProcessTool implementations updated with descriptions
calibrated to drive correct tool selection by the model:
- list_audio_devices, get_active_audio_route, get_self_state,
  list_camera_devices (Phase 10-01 self-knowledge tools)
- search_memory, forget_fact, search_conversation (Phase 7 memory tools)

Registry change:
- InProcessToolRegistry.toolSchemas() projects every registered tool to
  a ToolSchema(name, description, inputSchema) suitable for the LLM
  request body. Returns name-sorted for prompt-cache determinism.
- Adds AgentCore import to InProcessToolRegistry for the ToolSchema type.

Test coverage:
- InProcessToolRegistrySchemasTests asserts all 7 tools project
  correctly with non-empty descriptions and inputSchema bytes.
- All 85 existing MCP tests pass unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 07:28:20 -05:00
james.maes b9ac76d07e feat(10-01): implement four self-knowledge MCP in-process tools
- ListAudioDevicesTool / list_audio_devices — wraps `AudioDeviceListing`
  dispatcher; encodes `{"devices": [...]}`. AudioDeviceEntry holds
  name/uid/isDefault/isActive/isInput/sampleRate/channels.
- GetActiveAudioRouteTool / get_active_audio_route — encodes
  `{"route": <route|null>}` with explicit nil-key emission so the model
  can disambiguate "graph closed" from "graph open with missing fields".
- GetSelfStateTool / get_self_state — SelfState carries all 11 D-11
  fields (appName, appVersion, buildMode, pid, uptimeSeconds,
  llmProvider, llmModel, ttsTier, sttBackend, wakeWordMuted,
  voiceLoopState).
- ListCameraDevicesTool / list_camera_devices — encodes
  `{"cameras": [...]}`. CameraDeviceEntry holds
  localizedName/uniqueID/isConnected/position.

All four declare `requiresConfirmation = false` explicitly per D-10
(read-only, no side effects; AP-12 — never default the flag).
JSONEncoder uses `.sortedKeys` for stable test fixtures. No caching
(D-09 / AP-11). All 6 Wave-0 tests GREEN; existing MCP suites unchanged.
2026-05-06 15:23:04 -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 57847f2f58 chore(test-seams): move _test*/_force* seams from public to internal
Per audit-2026-05-04 P2-14: VoiceController._forceState,
_testFireSpeechEnd, _testCurrentSttSessionId were declared `public`
solely so XCTest could see them. Same for MCPClient._testHandle and
MCPServerHandle._testProcessIdentifier. These leaked test-only entry
points into each package's public API — anyone importing Voice could
call _forceState(.speaking) from production code.

Voice (4 seams): public → internal. Voice tests already use
@testable import Voice, so no test-side change required.

MCP (2 seams): public → @_spi(Testing) public. MCP tests opt in
via @testable @_spi(Testing) import JarvisMCP. The Harness package
(non-test) consumes _testHandle / _testProcessIdentifier for the
MCPCrashRunner SIGKILL recipe — opt in via @_spi(Testing) import.
@testable alone does NOT expose @_spi symbols — the SPI must be
explicitly imported, which is the desired discipline (each consumer
acknowledges they're reaching past the public ABI).

6 test seams total moved off the public ABI. Voice + MCP tests
pass (87 + 85). Harness builds clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 16:22:14 -05:00
james.maes 9fc8d44340 refactor(07-05): extract ChildSpawnGate into JarvisChildSpawn target (Rule 3 deviation)
[Rule 3 - Blocking] VllmMlxSidecar (Task 4) needs ChildSpawnGate but
packages/Vision can't depend on JarvisMCP without transitively pulling
AgentOrchestrator (which would violate VISION-03 boundary).

Solution: hoist ChildSpawnGate.swift into a new JarvisChildSpawn SPM
target inside the MCP package. JarvisMCP keeps the same singleton via
the new dep; Vision can now depend on JarvisChildSpawn alone.

- New target JarvisChildSpawn (only depends on swift-log)
- JarvisMCP gains JarvisChildSpawn as a dependency
- MCPServerHandle.swift / MCPClient.swift gain 'import JarvisChildSpawn'
- ChildSpawnGate uses Logger directly instead of MCPLogChannel.logger
  (avoids dragging MCPLogChannel into the standalone target)
- Tests updated to '@testable import JarvisChildSpawn'
- All 3 ChildSpawnGate tests pass; full MCP build clean
2026-04-29 17:51:18 -05:00
james.maes 52457f0159 feat(07-03): in-process MCP tools + BusOutbound.sessionHistory + version bump
- InProcessTool protocol + InProcessToolRegistry actor (PATTERNS 2.18A):
  bypasses stdio framing for memory tools
- SearchMemoryTool (D-07 all-sessions, requiresConfirmation=false)
- SearchConversationTool (TEXT-03 session-scoped, requiresConfirmation=false)
- ForgetFactTool (D-02 destructive, requiresConfirmation=true)
- BusOutbound.sessionHistory(turns: [TurnRow]) additive case +
  hand-written Codable arms; Bus.TurnRow local mirror (no Bus->Memory
  dependency edge introduced)
- BUS_PROTOCOL_VERSION 2.0.0 -> 2.1.0 (MINOR: additive case); TS-side
  catch-up deferred to Plan 07-06 per plan body — check-bus-protocol-
  version.sh xcodebuild pre-script will fail until then (expected)

Bus 50 tests, MCP 85 tests, Memory 67/9 skipped, Replay 27 — all green.
.memoryRetrieval grep gate stays at 1 production hit; .memoryMutation
grep gate stays at 1; no new chat-panel components.
2026-04-29 17:19:59 -05:00
james.maesandClaude Opus 4.7 ccf1d3623c fix(mcp): path-aware ChildSpawnGate — fatal only on developer-owned FDs
Promote the FD_CLOEXEC sweep from "fatal-error in Debug on every
non-CLOEXEC FD" to "fatal-error only when the FD belongs to an open site
under our developer-owned prefixes; retrofit + log everything else."

The original spec assumed every long-lived FD in the process is opened
by our code, where a missing O_CLOEXEC is a developer regression worth
halting on. WKWebView violates that assumption — the GPU process
init opens system Metal libraries (e.g., AppleMetalOpenGLRenderer.bundle
default.metallib) without O_CLOEXEC, and ⌘R from Xcode inherits lldb's
debugger pipes, neither of which is actionable from our codebase.

Developer-owned prefixes (computed once via Bundle.main.bundlePath and
NSHomeDirectory):

  - <Bundle.main.bundlePath>/
  - ~/Library/Logs/Jarvis/
  - ~/Library/Application Support/Jarvis/

For these, Debug builds still fatal-error so a regression at our open
sites is loud and immediate. Everything else — system frameworks,
anonymous pipes/sockets, lldb-inherited FDs — gets retrofitted with a
debug log line.

Bonus: Debug fatalError message now includes the offending FD's path
via fcntl(F_GETPATH), so future regressions name the file directly
instead of forcing a separate diagnostic round.

This unblocks ⌘R from Xcode against the Debug build. 79/79
JarvisMCPPackageTests still pass; the FD-leak baseline test reports
delta=0 across the suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:45:34 -05:00
james.maesandClaude Opus 4.7 af1c7700d7 fix(05-review): WR-06 make MCPClient.callTool arguments optional
Match the SDK's `Client.callTool(name:arguments:)` signature where
`arguments: [String: Value]?` is optional. Callers can now invoke
no-arg tools without manually passing `[:]`. MCPToolDispatcher now
preserves the JSON-RPC `arguments: null` vs `arguments: {}`
distinction by passing `nil` when the model emits no args bytes.

Updated MCPClientCalling protocol + MockClient conformer in
MCPToolDispatcherTests to match the optional shape. Added
test_callTool_withoutArguments_isAccepted that exercises the default
nil parameter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:36:05 -05:00
james.maesandClaude Opus 4.7 7ee91ae578 fix(05-review): WR-03 explicit cancellation handling on broker timer
Replaced `try? await Task.sleep + Task.isCancelled` with an explicit
do/catch that early-returns on CancellationError. Previously the
`try?` swallowed the cancellation error and a window opened between
sleep returning and Task.isCancelled being observed during which a
cancelled timer could still fire .timeout. Today the broker's
first-write-wins guard saves us, but a future contributor removing
that guard would silently introduce double-resolve.

Adds test_response_atTimerBoundary_firstWriteWins_noDoubleResolve —
loops 10x at a 50ms timeout, sends .deny at T+49ms, asserts the
broker resolves exactly once (deny OR timeout, both legal) and dismiss
fires exactly once. Catches double-resolve regressions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:30:32 -05:00
james.maesandClaude Opus 4.7 094c067b94 fix(05-review): WR-02 hold broker and presenter strongly through holder
Previously ConfirmationPresenter held broker as `weak var` and
ConfirmationPresenterHolder held its inner presenter as `weak var` —
a doubly-weak chain that could silently break Approve/Deny button
responses if either side deinit'd during a pending request. The user
would see the panel close on Approve, then the orchestrator would
hang for 60s until the broker's timer fired .timeout.

Switched both to strong references. The cycle motivation
(broker → presenter → broker) is broken by the holder indirection:
broker holds the holder, holder holds presenter strongly, presenter
holds broker strongly. The chain is acyclic when read top-down because
the broker does not hold the presenter directly.

Documents the lifecycle invariant on MCPRuntime: AppDelegate must hold
runtime strongly for the lifetime of the process; orchestrator must
not outlive the runtime.

Adds ConfirmationLifecycleTests with three guards:
- canonical hold pattern always resolves Approve
- wedged presenter (never calls broker.response) still resolves via
  timeout — the timer is the load-bearing fallback
- source-level: ConfirmationPresenter declares `let broker:` (strong),
  not `weak var broker:`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:28:54 -05:00
james.maesandClaude Opus 4.7 d07ac08f0e fix(05-review): CR-03+WR-04 String toolUseId and success-path toolCallEnd
CR-03: BusGateway's toolUseId parameter is now String (matching the bus
schema's Phase 2 closed contract) instead of UUID. Previously the
dispatcher synthesized `UUID(uuidString: toolUse.id) ?? UUID()`, which
silently produced a fresh random UUID for every Anthropic 'toolu_…' or
Ollama free-form id — breaking correlation across bus / replay /
orchestrator. The original String id now flows verbatim from
toolCallStart through argsPreviewUpdate to toolCallEnd. The broker's
internal UUID is now a fresh-per-call value and does NOT replace the
bus-facing id.

WR-04: ConfirmingToolDispatcher now emits toolCallEnd(ok: true, …) on
the success path so bus consumers (HUD ToolCallCard) transition
Running → Completed. On dispatch error post-approval, toolCallEnd
fires with ok: false carrying the error description before re-throwing.

Adds three new tests (busToolUseId verbatim across emissions, non-UUID
id flows through, success path emits 3 events). Updates SpyBus and
SpyBus-equivalent in MCPRuntimeWiringTests to use String toolUseId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 15:54:34 -05:00
james.maesandClaude Opus 4.7 65c88d2a28 fix(05-review): CR-01 close pipe FDs on MCPServerHandle init-failure paths
Both initialize-failure throw arms in MCPServerHandle.start() (the
client.connect() catch and the helperMissingToolsCapability guard) now
call a new closeParentPipeEnds helper before throwing. Without this,
each failed start() leaked 3 parent-side pipe FDs (stdin write, stdout
read, stderr read) — Foundation's Pipe deinit doesn't close FDs when
the underlying FileHandle was created with closeOnDealloc:false (the
default), and terminateProcessImmediately only SIGKILLs the child.

Adds MCPServerHandleInitFailureFDLeakTests with four structural
assertions: helper exists, closes all three retained ends, both throw
arms call it, cleanup precedes the throw on both arms.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 15:48:39 -05:00
james.maes 738f88a1c9 feat(05-05): ConfirmingToolDispatcher — broker-gated wrapper + 9 tests
Closes MCP-04 (full args-redaction seal pre-approval) and SEC-08
(defense-in-depth args masking on the bus). Final wiring of AGENT-11's
timeout-as-deny semantics — .timeout outcome triggers a JarvisLogChannel.mcp
WARNING line + ConfirmationError.timedOut throw.

- Public actor ConfirmingToolDispatcher conforms to ToolDispatcher and
  composes around an inner ToolDispatcher (production: MCPToolDispatcher).
  Plan 04-04's orchestrator code is unchanged; the gating happens by
  composition.
- Pre-approval bus emission's argsPreview is the literal
  '{"awaitingApproval":true}' seal. Post-approval, a follow-up
  updateArgsPreview carries the sanitized real args.
- BusGateway protocol abstracts the three bus surfaces the dispatcher
  needs (start / update / end). App-level wiring implements it.
- defaultLogWarning routes to swift-log Logger(label: 'mcp') with a
  'subsystem=ConfirmingToolDispatcher' metadata key. Tests inject a
  recording closure.
- 9 tests: pass-through fast path; awaiting-approval seal ordering;
  approve / deny / timeout / barge outcomes; inner-error propagation
  after approval; sync requiresConfirmation delegation; explicit
  args-redaction-before-approval invariant. test_OC_T1_timeoutLogsWarning
  name fixed by plan medium-finding.
2026-04-25 14:25:42 -05:00
james.maes 315d92c67b feat(05-05): ConfirmationPresenter — native AppKit NSPanel.beginSheet (no modal)
Closes MCP-04 (confirmation prompts use NSPanel + beginSheet, ZERO modal-
session calls).

- @MainActor final class ConfirmationPresenter conforms to
  ConfirmationPresenting via nonisolated trampolines that hop to MainActor.
- Hidden owner NSPanel (alpha=0, .floating, .nonactivatingPanel) hosts the
  beginSheet attachment; the per-id child panel renders SwiftUI content
  (Approve/Deny buttons) via NSHostingView.
- Approve / Deny button handlers fire-and-forget broker.response(id:.approve)
  and (.deny) — the only path to broker.response(.approve) is this native
  AppKit surface (T-05-05-04).
- File contains zero matches for runModal / beginModalSession / NSApp.run /
  NSApplication.shared.run outside doc comments. Plan 05-05 Task 5 lint
  enforces this codebase-wide.
2026-04-25 14:21:49 -05:00
james.maes 5d8ef0c2ec feat(05-05): ConfirmationBroker FSM + 60s timeout + 9 RED→GREEN tests
Closes MCP-09 (broker FSM, first-write-wins on .approve / .deny / .timeout
/ .barge) and AGENT-11 (60s timeout-as-deny via Task.sleep with sub-second
test-injection knob).

- ConfirmationOutcome.swift defines the four-outcome enum, matching
  ConfirmationError throw-type, and ConfirmationPresenting protocol so the
  broker can compile + test against an actor mock without pulling AppKit.
- ConfirmationBroker.swift is a public actor whose serial executor IS the
  synchronization primitive; no locks, no atomics. The 'resolved' guard
  inside response(id:outcome:) enforces MCP-09 first-write-wins.
- 9 tests cover the four-outcome happy paths, the sub-second timeout, the
  late-hop no-op invariants (after .approve and after .timeout), the
  concurrent-response race, the unknown-id no-op, and the dismiss-once
  lifecycle invariant.
2026-04-25 14:20:21 -05:00
james.maes fbe6c6c798 feat(05-04): GREEN — MCPToolDispatcher conforms to ToolDispatcher
Concrete `ToolDispatcher` (declared in AgentOrchestrator) that proxies
to MCPClient, joins all .text content blocks, runs the SEC-07
sanitize+headTruncate pipeline, and returns prepared bytes.

Architectural notes baked into doc comments:

  - Wrap step is intentionally absent. The orchestrator's
    UntrustedWrapper.wrap(_:) runs AFTER dispatch returns. Calling a
    wrap step here would double-wrap and corrupt the per-turn nonce
    invariant (Plan 04-04 §dispatch loop).

  - ToolResultObserver protocol exposes BOTH pre-sanitize raw bytes
    AND post-sanitize prepared bytes for replay capture (SEC-07 audit
    trail). Plan 05-05 wires the actual ReplayLog writer in
    AppDelegate; this plan only exposes the callback.

  - MCPClientCalling protocol extracted so unit tests can mock the
    SDK boundary without spawning helper subprocesses. Production
    conformance is the one-liner extension on MCPClient.

  - requiresConfirmation(toolName:) is nonisolated — backed by a
    ToolRegistry value-type snapshot captured in init. Satisfies the
    protocol's sync requirement without an actor hop.

  - Argument decoding tolerates empty / malformed JSON by returning
    [:] (the helper's inputSchema handles missing fields with a
    structured isError response).

8 dispatcher tests + 49 total MCP tests pass. AgentCore 134/134,
Replay 27/27, Bus 47/47 all still green (no upstream regressions).
Release build exits 0 with the new AgentCore SPM dep.
2026-04-25 14:03:39 -05:00
james.maes 497fd1be44 feat(05-04): GREEN — ToolRegistry value type for tool→server lookup
Sendable value type with register / server / requiresConfirmation /
toolNames. Last-write-wins on duplicate register; sorted toolNames for
deterministic enumeration. Captured by MCPToolDispatcher as a
`nonisolated let` snapshot at init so the protocol's sync
requiresConfirmation can be served without an actor hop.

5 tests pass.
2026-04-25 13:57:30 -05:00
james.maes 55b40c19de feat(05-04): GREEN — SanitizeForModel sanitize + headTruncate pipeline
SEC-07 boundary scrubber + head-truncator. Three public functions:

  - sanitize(_:) — split on \n FIRST so LF survives, then per-line:
    scalar-range filter strips C0 (except \t), DEL, bidi overrides
    (U+202A..E), bidi isolates (U+2066..9), zero-width (U+200B..D,
    U+2060), BOM (U+FEFF). Per-line UTF-8 byte cap at 4096 with
    `…[line-truncated]` marker.

  - headTruncate(_:capBytes:) — byte-count truncation with explicit
    `\n…[tool-result-truncated at <N> bytes]` marker. Default cap
    8192 matches AGENT-08's ToolResultPacker.modelFacingCapBytes.

  - prepareForBoundary(_:capBytes:) — canonical fixed-order
    composition, the SOLE entry point from MCPToolDispatcher.
    Wrap step is intentionally absent — orchestrator owns it.

18 tests pass.
2026-04-25 13:55:59 -05:00
james.maes 5fee4060e1 feat(05-01): per-server restart mutex + 100-cycle FD-leak test
- MCPServerHandle.claimOrShareRestart(buildTask:): atomic claim-or-share
  primitive that runs entirely under handle actor isolation. The closure-
  based design ensures the Task is only constructed inside the actor's
  serial execution, so concurrent callers can't race past the slot check
  and stampede the spawn path.
- MCPClient.callTool: layers the per-server mutex on top —
    1. await any in-flight restart; then
    2. if still crashed, claim-or-share the slot (claimant runs start();
       sharers await the same task);
    3. dispatch through the SDK client.
  Slot is cleared on both success and failure paths so a future call
  after a failed restart can attempt again. No backoff/retry inside the
  mutex — RESEARCH A5 contract.
- MCPServerHandle.start: pin PID at terminationHandler wire time so a
  STALE handler from a prior helper (post-restart) cannot flip
  isCrashed=true on the LIVE process; handleTermination(forPID:) early-
  returns when self.process.processIdentifier != pid.
- MCPServerHandle.start: set FD_CLOEXEC on the parent-side pipe ends
  (stdin write, stdout read, stderr read) immediately after Process.run().
  Foundation creates Pipe FDs without O_CLOEXEC; without this retrofit
  the Debug-build ChildSpawnGate fatalErrors when a subsequent spawn
  sweeps the parent FD table. CLOEXEC at FD-creation time is the gate's
  primary defense per RESEARCH Pitfall #8.
- MCPRestartTests: 4 tests
    1. test_singleCrash_followedBy_callTool_succeedsAfterRestart
    2. test_concurrentCallsDuringRestart_shareOneRestart  (8-way contention,
       tally side-channel verifies exactly 2 helper starts: initial + 1 restart)
    3. test_inFlightCallTool_duringCrash_throws_serverCrashed (mock_slow
       call SIGKILL'd mid-flight)
    4. test_100_crashCycles_noFDLeak  (baseline=4, final=4, delta=0)

Plan: 05-01 Task 3 of 3.
2026-04-25 10:57:07 -05:00
james.maes 8dbd6f0c87 feat(05-01): MCPServerHandle + MCPClient happy path + MockHelper fixture
- MCPServerHandle actor: spawns helper via ChildSpawnGate.shared.prepare(),
  sets process.environment = minimalEnvironment, launches with 3 Pipes,
  closes child-side pipe ends in the parent (FD-leak guard for restart
  cycles), wires StdioTransport with EXPLICIT FileDescriptor args (single-
  line so the grep gate matches literally), connects SDK Client, asserts
  Initialize.Result.capabilities.tools != nil, drains stderr to log channel,
  installs terminationHandler -> handleTermination async path.
- MCPServerHandle.callTool wraps the SDK's (content, isError?) tuple back
  into CallTool.Result so downstream consumers see the documented type.
  Errors during a call are translated to JarvisMCPError.serverCrashed only
  when isCrashed is set; otherwise rethrown verbatim.
- MCPClient actor: registry of MCPServerHandle keyed by server name,
  toolToServer name lookup populated from each helper's tools/list.
  Restart-mutex layering arrives in Task 3.
- ToolMetadata struct: name + server + requiresConfirmation. Plan 05-04
  /05-05 consume this for dispatch routing + confirmation broker.
- MockHelper fixture (test-only, internal to JarvisMCPTests): SDK Server
  with mock_echo + mock_slow tools, MOCK_HELPER_CRASH_AFTER env knob to
  exit(137) after N calls, MOCK_HELPER_TALLY_PATH side channel for
  restart-cycle counting.
- MockHelperBuilder: shells out to `swift build -c release` once per test
  run, caches the binary URL.
- Tests: 4 happy-path tests (echo round-trip, spawn-failed, helperMissing,
  toolMetadata) + 3 source-level Pitfall #1 regression guards.

Plan: 05-01 Task 2 of 3.
2026-04-25 10:40:34 -05:00
james.maes 896d7fb49a feat(05-01): JarvisMCP scaffold + SDK v0.12.0 pin + ChildSpawnGate
- New SPM package packages/MCP/ with library/target named JarvisMCP
  (avoids name collision with the SDK's MCP product/target — mirrors
  the JarvisLogging convention).
- Pins modelcontextprotocol/swift-sdk at exact 0.12.0 (no range float;
  pre-1.0 SDK). Resolves cleanly under Swift 6.3 toolchain.
- ChildSpawnGate.shared singleton with FD_CLOEXEC sweep (fatalError
  in DEBUG, retrofit + warn in RELEASE) + minimalEnvironment constant
  ['PATH': '/usr/bin:/bin'].
- JarvisMCPError surface (helperMissing / helperMissingToolsCapability /
  startupTimeout / serverCrashed / spawnFailed) — distinct from SDK's
  MCP.MCPError type.
- MCPLogChannel.logger(label:) factory routes all package log lines
  through 'jarvis.mcp.<label>' for grep-able provenance.
- Tests: SDK pin + tools-version >= 6.1 host check (2/2);
  ChildSpawnGate env + prepare + child env-isolation (3/3).

Plan: 05-01 Task 1 of 3.
2026-04-25 10:33:08 -05:00