diff --git a/README.md b/README.md index eb61019..bc5df97 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ For the native macOS SwiftUI shell: This builds `macos/build/Braiins Ratchet.app` and opens the real app bundle. Do not use `swift run` for normal operation. +The app is a native visual control room: Mission Control, Research Map, Manual Exposure ledger, Reports, and a Ratchet Lecture. The design rationale is in `docs/APP_DESIGN_RESEARCH.md`. + For a 6-hour monitoring session: ```bash diff --git a/START_HERE.md b/START_HERE.md index ef72993..27ae77f 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -144,6 +144,14 @@ The app is a native cockpit over the same durable Python lifecycle engine. The app includes controls to record and close manual exposure, but the same rule applies: it never places Braiins orders. +The app is organized as: + +1. `Mission Control`: one exact next action, cooldown, metrics, and automation approval. +2. `Research Map`: visual autoresearch stage model. +3. `Manual Exposure`: record or close manually executed Braiins exposure. +4. `Reports`: raw cockpit, report, and ledger artifacts. +5. `Ratchet Lecture`: the general observe, hypothesize, bound, mature, adapt method. + ## Research Pathway The cockpit has two different time horizons: diff --git a/docs/APP_DESIGN_RESEARCH.md b/docs/APP_DESIGN_RESEARCH.md new file mode 100644 index 0000000..90ecec5 --- /dev/null +++ b/docs/APP_DESIGN_RESEARCH.md @@ -0,0 +1,64 @@ +# Native App Design Research + +This app is not supposed to be a prettier terminal. It is supposed to be a control room for a risky, long-running research lifecycle. + +## Design Sources + +Apple's Liquid Glass guidance emphasizes system-native structure before visual effects: + +- Use standard SwiftUI/AppKit structure so controls, navigation, sheets, and toolbars inherit system behavior. +- Keep navigation and controls in a distinct functional layer above the content. +- Avoid overusing custom glass effects; too much glass becomes noise. +- Support arbitrary window sizes with split views. +- Preserve accessibility when transparency or motion is reduced. + +Source: + +Microsoft's Human-AI Interaction Guidelines are directly relevant because this app makes recommendations under uncertainty: + +- Make clear what the system can and cannot do. +- Make clear how well it can do it. +- Show contextually relevant information. +- Explain why the system did what it did. +- Support correction, dismissal, global controls, and cautious adaptation over time. + +Source: + +Nielsen Norman's usability heuristics matter because the operator may be tired, confused, or dealing with real money: + +- Show system status. +- Use real-world language. +- Prevent errors before they happen. +- Prefer recognition over recall. +- Provide clear recovery paths. + +Source: + +## Product Decisions + +The native app now treats the Python engine as a structured state provider, not as a terminal to embed. The new `app-state` command returns JSON with: + +- Current operator state. +- Automation plan. +- Cockpit text for audit/debug. +- Latest OCEAN, Braiins, and strategy proposal payloads. + +The SwiftUI app turns that into native surfaces: + +- `Mission Control`: one exact action, cooldown, approval gate, and metrics. +- `Research Map`: the ratchet pathway as a visual stage model. +- `Manual Exposure`: the ledger for real manually placed Braiins exposure. +- `Reports`: raw artifacts kept available but no longer primary. +- `Ratchet Lecture`: a teachable model of observe, hypothesize, bound, mature, adapt. + +## The Ratchet UX Rule + +The app must always answer these questions without forcing the user to parse logs: + +1. Who is in control right now? +2. What is the earliest useful next action? +3. What evidence artifact exists? +4. What action is blocked for safety? +5. Which single knob, if any, is eligible for later adaptation? + +If the app cannot answer those questions graphically and in plain language, it is failing its purpose. diff --git a/macos/BraiinsRatchet/README.md b/macos/BraiinsRatchet/README.md index d56feb3..bdd4469 100644 --- a/macos/BraiinsRatchet/README.md +++ b/macos/BraiinsRatchet/README.md @@ -14,10 +14,13 @@ This builds `macos/build/Braiins Ratchet.app` and opens the packaged app. Use th ## Current Scope -- Native macOS SwiftUI cockpit. -- Liquid-glass-inspired material panels. -- Buttons for cockpit, lifecycle status, automation proposal, and full report. +- Native macOS SwiftUI control room. +- Mission Control with one explicit next action. +- Research Map with the full ratchet pathway. +- Monitor-only automation approval gate. - Manual exposure recording and closing controls. +- Reports panel for raw artifacts. +- Ratchet Lecture for the general autoresearch method. - Monitor-only. It never places Braiins orders. ## Product Direction diff --git a/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift b/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift index 59e5fa7..e434d53 100644 --- a/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift +++ b/macos/BraiinsRatchet/Sources/BraiinsRatchetMac/BraiinsRatchetApp.swift @@ -1,4 +1,5 @@ import AppKit +import Foundation import SwiftUI @main @@ -17,239 +18,1187 @@ struct BraiinsRatchetApp: App { } struct ContentView: View { - @State private var output = "Press Refresh Cockpit." + @State private var selectedSection: AppSection? = .mission + @State private var appState: AppStatePayload? + @State private var transcript = "Loading native app state..." + @State private var lastCommand = "app-state" + @State private var errorMessage: String? @State private var isRunning = false + @State private var showAutomationApproval = false + @State private var glow = false @State private var manualDescription = "" @State private var maturityHours = "72" @State private var closePositionId = "" + var body: some View { + NavigationSplitView { + List(AppSection.allCases, selection: $selectedSection) { section in + Label(section.title, systemImage: section.systemImage) + .tag(section as AppSection?) + .padding(.vertical, 4) + } + .navigationTitle("Ratchet") + .safeAreaInset(edge: .bottom) { + sidebarFooter + } + } detail: { + ZStack { + AppBackground(glow: glow) + detailView + } + .toolbar { + ToolbarItemGroup { + Button { + Task { await refreshAppState() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(isRunning) + + Button { + Task { await runTextCommand(label: "supervise --status", ["supervise", "--status"]) } + } label: { + Label("Supervisor", systemImage: "waveform.path.ecg") + } + .disabled(isRunning) + } + } + } + .task { + await refreshAppState() + } + .onAppear { + withAnimation(.easeInOut(duration: 3.2).repeatForever(autoreverses: true)) { + glow = true + } + } + .confirmationDialog( + "Approve monitor-only automation?", + isPresented: $showAutomationApproval, + titleVisibility: .visible + ) { + Button("Approve and run monitor-only plan") { + Task { await runTextCommand(label: "pipeline --yes", ["pipeline", "--yes"], refreshAfterwards: true) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text(appState?.automationPlan.title ?? "The app will run only the printed monitor-only plan. It will not place Braiins orders.") + } + } + + @ViewBuilder + private var detailView: some View { + switch selectedSection ?? .mission { + case .mission: + MissionControlView( + appState: appState, + transcript: transcript, + isRunning: isRunning, + glow: glow, + refresh: { Task { await refreshAppState() } }, + previewAutomation: { Task { await runTextCommand(label: "pipeline preview", ["pipeline"], input: "no\n") } }, + approveAutomation: { showAutomationApproval = true } + ) + case .map: + ResearchMapView(appState: appState, glow: glow) + case .exposure: + ManualExposureView( + appState: appState, + manualDescription: $manualDescription, + maturityHours: $maturityHours, + closePositionId: $closePositionId, + isRunning: isRunning, + record: recordManualExposure, + close: closeManualExposure, + list: { Task { await runTextCommand(label: "position list", ["position", "list"], refreshAfterwards: true) } } + ) + case .reports: + ReportsView( + transcript: transcript, + lastCommand: lastCommand, + isRunning: isRunning, + loadReport: { Task { await runTextCommand(label: "report", ["report"]) } }, + loadLedger: { Task { await runTextCommand(label: "experiments", ["experiments"]) } }, + loadCockpit: { Task { await runTextCommand(label: "next", ["next"]) } } + ) + case .lecture: + RatchetLectureView() + } + } + + private var sidebarFooter: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(nsImage: AppIconFactory.makeIcon(size: 34)) + .resizable() + .frame(width: 34, height: 34) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text("Monitor-only") + .font(.caption.weight(.bold)) + Text("No owner-token execution") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + if isRunning { + ProgressView("Working") + .controlSize(.small) + } + } + .padding(12) + } + + @MainActor + private func refreshAppState() async { + isRunning = true + errorMessage = nil + lastCommand = "app-state" + let result = await RatchetProcess.loadAppState() + switch result { + case .success(let payload): + appState = payload + transcript = payload.cockpit + case .failure(let message): + errorMessage = message + transcript = message + } + isRunning = false + } + + @MainActor + private func runTextCommand( + label: String, + _ arguments: [String], + input: String? = nil, + refreshAfterwards: Bool = false + ) async { + isRunning = true + lastCommand = label + transcript = "Running ./scripts/ratchet \(arguments.joined(separator: " ")) ..." + let result = await RatchetProcess.run(arguments: arguments, input: input) + transcript = result + isRunning = false + if refreshAfterwards { + await refreshAppState() + } + } + + private func recordManualExposure() { + let description = manualDescription.trimmingCharacters(in: .whitespacesAndNewlines) + let hours = maturityHours.trimmingCharacters(in: .whitespacesAndNewlines) + guard !description.isEmpty else { + transcript = "Enter a manual exposure description first." + return + } + + Task { + await runTextCommand( + label: "position open", + ["position", "open", "--description", description, "--maturity-hours", hours.isEmpty ? "72" : hours], + refreshAfterwards: true + ) + } + } + + private func closeManualExposure() { + let positionId = closePositionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !positionId.isEmpty else { + transcript = "Enter a manual position ID first." + return + } + + Task { + await runTextCommand(label: "position close", ["position", "close", positionId], refreshAfterwards: true) + } + } +} + +enum AppSection: String, CaseIterable, Identifiable { + case mission + case map + case exposure + case reports + case lecture + + var id: String { rawValue } + + var title: String { + switch self { + case .mission: "Mission Control" + case .map: "Research Map" + case .exposure: "Manual Exposure" + case .reports: "Reports" + case .lecture: "Ratchet Lecture" + } + } + + var systemImage: String { + switch self { + case .mission: "scope" + case .map: "point.3.connected.trianglepath.dotted" + case .exposure: "lock.shield" + case .reports: "doc.text.magnifyingglass" + case .lecture: "graduationcap" + } + } +} + +struct MissionControlView: View { + let appState: AppStatePayload? + let transcript: String + let isRunning: Bool + let glow: Bool + let refresh: () -> Void + let previewAutomation: () -> Void + let approveAutomation: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .top, spacing: 22) { + HeroPanel(appState: appState, glow: glow) + .frame(minWidth: 420) + + VStack(spacing: 14) { + AutoresearchOrb(phase: ResearchPhase.from(appState), glow: glow) + .frame(height: 250) + AutomationCard( + plan: appState?.automationPlan, + isRunning: isRunning, + preview: previewAutomation, + approve: approveAutomation + ) + } + .frame(width: 350) + } + + MetricsDeck(appState: appState) + ResearchTimeline(appState: appState, compact: false) + PlainEnglishCard(appState: appState, transcript: transcript) + } + .padding(28) + } + } +} + +struct HeroPanel: View { + let appState: AppStatePayload? + let glow: Bool + + private var directive: Directive { + Directive.from(appState) + } + + var body: some View { + GlassPanel { + VStack(alignment: .leading, spacing: 22) { + HStack(spacing: 12) { + Image(nsImage: AppIconFactory.makeIcon(size: 46)) + .resizable() + .frame(width: 46, height: 46) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text("Braiins Ratchet") + .font(.largeTitle.weight(.black)) + Text("Autoresearch control room for real-money mining experiments") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 10) { + Text("Do This Now") + .font(.caption.weight(.heavy)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Text(directive.title) + .font(.system(size: 44, weight: .black, design: .rounded)) + .foregroundStyle(directive.color) + Text(directive.detail) + .font(.title3.weight(.semibold)) + .foregroundStyle(.primary) + + if let command = directive.command { + HStack(spacing: 10) { + Image(systemName: "terminal") + Text(command) + .font(.system(.body, design: .monospaced).weight(.semibold)) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.black.opacity(0.18), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + + if let watch = appState?.operatorState.completedWatch { + CooldownGauge(watch: watch) + } + + SafetyStrip() + } + } + } +} + +struct AutomationCard: View { + let plan: AutomationPlanPayload? + let isRunning: Bool + let preview: () -> Void + let approve: () -> Void + + var body: some View { + GlassPanel { + VStack(alignment: .leading, spacing: 14) { + Label("Automation Gate", systemImage: "checkmark.seal") + .font(.headline) + Text(plan?.title ?? "Load state to see the next safe automation step.") + .font(.title3.weight(.bold)) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array((plan?.steps ?? []).enumerated()), id: \.offset) { index, step in + HStack(alignment: .top, spacing: 8) { + Text("\(index + 1)") + .font(.caption.weight(.black)) + .foregroundStyle(.white) + .frame(width: 20, height: 20) + .background(.green.opacity(0.75), in: Circle()) + Text(step) + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + + HStack { + Button("Preview") { + preview() + } + .buttonStyle(.bordered) + Button("Approve") { + approve() + } + .buttonStyle(.borderedProminent) + .disabled(!canApprove || isRunning) + } + } + } + } + + private var canApprove: Bool { + guard let kind = plan?.kind else { return false } + return !["no_action", "external_wait", "manual_exposure_hold"].contains(kind) + } +} + +struct MetricsDeck: View { + let appState: AppStatePayload? + + var body: some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 14), count: 4), spacing: 14) { + MetricTile( + title: "Market Freshness", + value: freshnessText, + detail: appState?.operatorState.latestMarketTimestamp ?? "no sample", + symbol: "clock" + ) + MetricTile( + title: "Strategy Action", + value: appState?.operatorState.action ?? "none", + detail: actionDetail, + symbol: "target" + ) + MetricTile( + title: "Manual Exposure", + value: exposureText, + detail: "Blocks new experiments while active", + symbol: "shield.lefthalf.filled" + ) + MetricTile( + title: "Latest Report", + value: appState?.operatorState.latestReport?.lastPathComponent ?? "none", + detail: appState?.operatorState.latestReport ?? "No artifact yet", + symbol: "doc.text" + ) + } + } + + private var freshnessText: String { + guard let state = appState?.operatorState else { return "loading" } + if state.isFresh { return "fresh" } + if let minutes = state.freshnessMinutes { return "stale \(minutes)m" } + return "unknown" + } + + private var actionDetail: String { + guard let action = appState?.operatorState.action else { return "No proposal loaded" } + if action == "manual_canary" { return "Learning opportunity, not profit proof" } + if action == "manual_bid" { return "Profit-seeking signal; manual review required" } + return "No useful market action" + } + + private var exposureText: String { + let count = appState?.operatorState.activeManualPositions.count ?? 0 + return count == 0 ? "none" : "\(count) active" + } +} + +struct MetricTile: View { + let title: String + let value: String + let detail: String + let symbol: String + + var body: some View { + GlassPanel(padding: 16) { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: symbol) + .font(.title2) + .foregroundStyle(.green) + Text(title) + .font(.caption.weight(.heavy)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Text(value) + .font(.title2.weight(.black)) + .lineLimit(1) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct ResearchTimeline: View { + let appState: AppStatePayload? + let compact: Bool + + private let steps = ResearchStep.allCases + + var body: some View { + GlassPanel { + VStack(alignment: .leading, spacing: 16) { + HStack { + Label("Ratchet Pathway", systemImage: "arrow.triangle.2.circlepath") + .font(.headline) + Spacer() + Text("One knob at a time") + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + } + + HStack(spacing: 0) { + ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in + TimelineNode(step: step, state: state(for: index)) + if index < steps.count - 1 { + Rectangle() + .fill(index < activeIndex ? Color.green.opacity(0.75) : Color.secondary.opacity(0.24)) + .frame(height: 3) + } + } + } + + if !compact { + Text(explanation) + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + } + + private var activeIndex: Int { + ResearchPhase.from(appState).timelineIndex + } + + private func state(for index: Int) -> TimelineNode.StateKind { + if index < activeIndex { return .done } + if index == activeIndex { return .active } + return .future + } + + private var explanation: String { + switch ResearchPhase.from(appState) { + case .setup: "The system needs baseline data before it can reason. First goal: establish a trustworthy local state." + case .refresh: "The market sample is stale. The next useful move is one fresh sample, not another watch loop." + case .watch: "A bounded watch buys information. It measures price action without forcing a real-money bid." + case .cooldown: "The previous watch is evidence. Cooldown prevents loop-chasing and gives the result time to mature." + case .exposure: "Manual Braiins exposure is active. The system should supervise, not create competing experiments." + case .adapt: "Only after repeated mature evidence should one strategy knob change. This is the anti-chaos rule." + } + } +} + +struct TimelineNode: View { + enum StateKind { + case done + case active + case future + } + + let step: ResearchStep + let state: StateKind + + var body: some View { + VStack(spacing: 8) { + ZStack { + Circle() + .fill(fill) + .frame(width: 44, height: 44) + Image(systemName: step.systemImage) + .foregroundStyle(.white) + .font(.headline) + } + Text(step.title) + .font(.caption.weight(.bold)) + .foregroundStyle(state == .future ? .secondary : .primary) + .frame(width: 86) + } + } + + private var fill: Color { + switch state { + case .done: .green.opacity(0.82) + case .active: .orange.opacity(0.92) + case .future: .secondary.opacity(0.25) + } + } +} + +struct PlainEnglishCard: View { + let appState: AppStatePayload? + let transcript: String + + var body: some View { + GlassPanel { + VStack(alignment: .leading, spacing: 12) { + Label("Noob Translation", systemImage: "quote.bubble") + .font(.headline) + Text(summary) + .font(.title3.weight(.semibold)) + Text("This app separates observation from execution. It can tell you what the research engine currently thinks; it cannot secretly spend BTC.") + .foregroundStyle(.secondary) + } + } + } + + private var summary: String { + let directive = Directive.from(appState) + if let watch = appState?.operatorState.completedWatch { + return "You are in cooldown. The earliest useful next action is \(watch.earliestActionLocal), about \(watch.remainingMinutes) minutes from the last refresh." + } + return "\(directive.title): \(directive.detail)" + } +} + +struct ResearchMapView: View { + let appState: AppStatePayload? + let glow: Bool + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .center, spacing: 24) { + AutoresearchOrb(phase: ResearchPhase.from(appState), glow: glow) + .frame(width: 320, height: 320) + VStack(alignment: .leading, spacing: 12) { + Text("Autoresearch Is A Ratchet") + .font(.system(size: 38, weight: .black, design: .rounded)) + Text("A ratchet is a one-way learning machine: it allows progress when evidence matures, and blocks fake progress when you are just repeating the same loop.") + .font(.title3) + .foregroundStyle(.secondary) + } + } + + ResearchTimeline(appState: appState, compact: false) + HypothesisBoard(appState: appState) + } + .padding(28) + } + } +} + +struct HypothesisBoard: View { + let appState: AppStatePayload? + + var body: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) { + PrincipleCard( + title: "Current Hypothesis", + symbol: "lightbulb", + text: appState?.automationPlan.title ?? "Load state first." + ) + PrincipleCard( + title: "Evidence Artifact", + symbol: "archivebox", + text: appState?.operatorState.latestReport ?? "No report exists yet." + ) + PrincipleCard( + title: "Allowed Intervention", + symbol: "slider.horizontal.3", + text: "Change exactly one knob only after mature reports repeat the same pattern." + ) + PrincipleCard( + title: "Blocked Failure Mode", + symbol: "hand.raised", + text: "No loop-chasing. No untracked manual exposure. No automated Braiins owner-token execution." + ) + } + } +} + +struct PrincipleCard: View { + let title: String + let symbol: String + let text: String + + var body: some View { + GlassPanel { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: symbol) + .font(.headline) + Text(text) + .font(.body) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct ManualExposureView: View { + let appState: AppStatePayload? + @Binding var manualDescription: String + @Binding var maturityHours: String + @Binding var closePositionId: String + let isRunning: Bool + let record: () -> Void + let close: () -> Void + let list: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + Text("Manual Exposure Ledger") + .font(.system(size: 38, weight: .black, design: .rounded)) + Text("If you manually place a Braiins bid, record it here. The app then holds the research lifecycle so it does not start a competing experiment while real hashpower may still be maturing.") + .font(.title3) + .foregroundStyle(.secondary) + + GlassPanel { + VStack(alignment: .leading, spacing: 14) { + Label("Record Active Braiins Exposure", systemImage: "plus.circle") + .font(.headline) + TextField("Description, e.g. Braiins order abc, 0.00010 BTC, 180 min", text: $manualDescription) + .textFieldStyle(.roundedBorder) + HStack { + TextField("Maturity hours", text: $maturityHours) + .textFieldStyle(.roundedBorder) + .frame(width: 160) + Button("Record Exposure", action: record) + .buttonStyle(.borderedProminent) + .disabled(isRunning) + } + } + } + + GlassPanel { + VStack(alignment: .leading, spacing: 14) { + Label("Close Finished Exposure", systemImage: "checkmark.circle") + .font(.headline) + TextField("Position ID", text: $closePositionId) + .textFieldStyle(.roundedBorder) + .frame(width: 180) + HStack { + Button("Close Exposure", action: close) + .buttonStyle(.borderedProminent) + .disabled(isRunning) + Button("List Positions", action: list) + .buttonStyle(.bordered) + .disabled(isRunning) + } + } + } + + ActiveExposureList(positions: appState?.operatorState.activeManualPositions ?? []) + } + .padding(28) + } + } +} + +struct ActiveExposureList: View { + let positions: [String] + + var body: some View { + GlassPanel { + VStack(alignment: .leading, spacing: 12) { + Label("Active Exposure", systemImage: "shield") + .font(.headline) + if positions.isEmpty { + Text("No manual positions recorded.") + .foregroundStyle(.secondary) + } else { + ForEach(positions, id: \.self) { position in + Text(position) + .font(.system(.body, design: .monospaced)) + } + } + } + } + } +} + +struct ReportsView: View { + let transcript: String + let lastCommand: String + let isRunning: Bool + let loadReport: () -> Void + let loadLedger: () -> Void + let loadCockpit: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Reports") + .font(.system(size: 36, weight: .black, design: .rounded)) + Text("Raw artifacts remain here, away from the noob cockpit.") + .foregroundStyle(.secondary) + } + Spacer() + Button("Cockpit", action: loadCockpit).disabled(isRunning) + Button("Report", action: loadReport).disabled(isRunning) + Button("Ledger", action: loadLedger).disabled(isRunning) + } + + GlassPanel { + VStack(alignment: .leading, spacing: 10) { + Label(lastCommand, systemImage: "terminal") + .font(.headline) + ScrollView { + Text(transcript) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + } + } + .padding(28) + } +} + +struct RatchetLectureView: View { + private let lessons = [ + ("Observe", "Collect state without acting. Public Braiins price action and OCEAN context are measurements, not commands.", "eye"), + ("Hypothesize", "State one reason a window might be useful. If the reason is vague, the experiment is not ready.", "lightbulb"), + ("Bound", "Keep downside bounded. Canary means buying information, not pretending there is a money printer.", "shippingbox"), + ("Mature", "Wait long enough for mining luck, share windows, and pool variance to mean something.", "hourglass"), + ("Adapt", "Change one knob. If you change many knobs, you destroy attribution and learn nothing.", "dial.medium") + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + Text("The General Ratchet Principle") + .font(.system(size: 40, weight: .black, design: .rounded)) + Text("Autoresearch is not automation for its own sake. It is a disciplined loop that prevents mode collapse in a noisy, non-convex search space.") + .font(.title3) + .foregroundStyle(.secondary) + + ForEach(Array(lessons.enumerated()), id: \.offset) { index, lesson in + GlassPanel { + HStack(alignment: .top, spacing: 16) { + Text("\(index + 1)") + .font(.title.weight(.black)) + .foregroundStyle(.white) + .frame(width: 54, height: 54) + .background(.green.gradient, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + VStack(alignment: .leading, spacing: 8) { + Label(lesson.0, systemImage: lesson.2) + .font(.title2.weight(.bold)) + Text(lesson.1) + .font(.body) + .foregroundStyle(.secondary) + } + } + } + } + } + .padding(28) + } + } +} + +struct AutoresearchOrb: View { + let phase: ResearchPhase + let glow: Bool + + var body: some View { + ZStack { + ForEach(0..<4) { index in + Circle() + .stroke( + AngularGradient( + colors: [.green.opacity(0.15), .mint.opacity(0.75), .orange.opacity(0.55), .green.opacity(0.15)], + center: .center + ), + lineWidth: CGFloat(12 - index * 2) + ) + .frame(width: CGFloat(210 + index * 28), height: CGFloat(210 + index * 28)) + .rotationEffect(.degrees(glow ? Double(24 * (index + 1)) : Double(-18 * (index + 1)))) + .opacity(0.72 - Double(index) * 0.12) + } + + Circle() + .fill(.ultraThinMaterial) + .frame(width: 178, height: 178) + .shadow(color: .green.opacity(glow ? 0.35 : 0.12), radius: glow ? 32 : 14) + + VStack(spacing: 8) { + Image(systemName: phase.symbol) + .font(.system(size: 42, weight: .bold)) + .foregroundStyle(.green) + Text(phase.title) + .font(.title2.weight(.black)) + Text("current phase") + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Current autoresearch phase: \(phase.title)") + } +} + +struct CooldownGauge: View { + let watch: CompletedWatchPayload + + var progress: Double { + guard watch.cooldownMinutes > 0 else { return 1 } + return min(1, max(0, 1 - Double(watch.remainingMinutes) / Double(watch.cooldownMinutes))) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Cooldown", systemImage: "timer") + .font(.headline) + Spacer() + Text("\(Int(progress * 100))%") + .font(.headline.monospacedDigit()) + } + ProgressView(value: progress) + .tint(.green) + Text("Earliest next action: \(watch.earliestActionLocal). Remaining: \(watch.remainingMinutes) minutes.") + .font(.callout) + .foregroundStyle(.secondary) + } + } +} + +struct SafetyStrip: View { + var body: some View { + HStack(spacing: 10) { + Label("No hidden bids", systemImage: "lock") + Label("Manual execution", systemImage: "hand.point.up.left") + Label("Repo-local state", systemImage: "externaldrive") + } + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + } +} + +struct GlassPanel: View { + var padding: CGFloat = 22 + @ViewBuilder let content: Content + + var body: some View { + content + .padding(padding) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(.white.opacity(0.16), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.16), radius: 24, x: 0, y: 16) + } +} + +struct AppBackground: View { + let glow: Bool + var body: some View { ZStack { LinearGradient( colors: [ - Color(red: 0.02, green: 0.05, blue: 0.06), - Color(red: 0.04, green: 0.14, blue: 0.15), - Color(red: 0.20, green: 0.24, blue: 0.18) + Color(red: 0.03, green: 0.06, blue: 0.07), + Color(red: 0.07, green: 0.16, blue: 0.15), + Color(red: 0.22, green: 0.21, blue: 0.14) ], startPoint: .topLeading, endPoint: .bottomTrailing ) - .ignoresSafeArea() - - atmosphericShapes - - HStack(alignment: .top, spacing: 22) { - VStack(alignment: .leading, spacing: 20) { - header - statusDeck - controls - manualExposureControls - } - .frame(width: 390) - - outputPanel - } - .padding(30) - } - .task { - await runRatchet(["next"]) - } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 10) { - Image(nsImage: AppIconFactory.makeIcon(size: 38)) - .resizable() - .frame(width: 38, height: 38) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - Text("Real-money monitor") - .font(.system(size: 13, weight: .black, design: .rounded)) - .foregroundStyle(.black.opacity(0.72)) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color(red: 0.68, green: 0.96, blue: 0.82), in: Capsule()) - } - Text("Braiins Ratchet") - .font(.system(size: 44, weight: .black, design: .rounded)) - .foregroundStyle(.white) - Text("Persistent monitor-only autoresearch cockpit") - .font(.system(size: 18, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.72)) - } - } - - private var atmosphericShapes: some View { - ZStack { Circle() - .fill(Color(red: 0.52, green: 0.95, blue: 0.72).opacity(0.18)) - .frame(width: 420, height: 420) - .blur(radius: 70) - .offset(x: -360, y: -240) + .fill(.green.opacity(glow ? 0.22 : 0.11)) + .frame(width: 560, height: 560) + .blur(radius: 90) + .offset(x: -360, y: -280) Circle() - .fill(Color(red: 0.95, green: 0.67, blue: 0.33).opacity(0.14)) - .frame(width: 360, height: 360) - .blur(radius: 80) - .offset(x: 430, y: 260) - RoundedRectangle(cornerRadius: 80, style: .continuous) - .stroke(.white.opacity(0.06), lineWidth: 1) - .frame(width: 780, height: 460) - .rotationEffect(.degrees(-12)) - .offset(x: 280, y: -130) + .fill(.orange.opacity(glow ? 0.16 : 0.08)) + .frame(width: 440, height: 440) + .blur(radius: 100) + .offset(x: 460, y: 260) + RoundedRectangle(cornerRadius: 90, style: .continuous) + .stroke(.white.opacity(0.07), lineWidth: 1) + .frame(width: 860, height: 520) + .rotationEffect(.degrees(-13)) + .offset(x: 280, y: -170) } .ignoresSafeArea() } +} - private var statusDeck: some View { - VStack(spacing: 12) { - statusCard(title: "Lifecycle", value: "Durable", detail: "SQLite-backed resume after reboot") - statusCard(title: "Execution", value: "Manual", detail: "No owner-token order placement") - statusCard(title: "Exposure", value: "Tracked", detail: "Record long Braiins positions") +enum ResearchStep: String, CaseIterable, Identifiable { + case sense + case price + case watch + case mature + case adapt + + var id: String { rawValue } + + var title: String { + switch self { + case .sense: "Sense" + case .price: "Price" + case .watch: "Watch" + case .mature: "Mature" + case .adapt: "Adapt" } } - private func statusCard(title: String, value: String, detail: String) -> some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(title.uppercased()) - .font(.system(size: 10, weight: .black, design: .rounded)) - .foregroundStyle(.white.opacity(0.48)) - Text(value) - .font(.system(size: 21, weight: .heavy, design: .rounded)) - .foregroundStyle(.white) - Text(detail) - .font(.system(size: 12, weight: .medium, design: .rounded)) - .foregroundStyle(.white.opacity(0.62)) - } - Spacer() + var systemImage: String { + switch self { + case .sense: "antenna.radiowaves.left.and.right" + case .price: "chart.line.uptrend.xyaxis" + case .watch: "binoculars" + case .mature: "hourglass" + case .adapt: "slider.horizontal.3" } - .padding(16) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(.white.opacity(0.13), lineWidth: 1) - ) + } +} + +enum ResearchPhase { + case setup + case refresh + case watch + case cooldown + case exposure + case adapt + + static func from(_ appState: AppStatePayload?) -> ResearchPhase { + guard let state = appState?.operatorState else { return .setup } + if !state.activeManualPositions.isEmpty { return .exposure } + if state.activeWatch != nil { return .watch } + if state.completedWatch != nil { return .cooldown } + if !state.hasOcean || !state.hasMarket || !state.isFresh { return .refresh } + if state.action == "manual_canary" { return .watch } + return .adapt } - private var controls: some View { - VStack(alignment: .leading, spacing: 10) { - glassButton("Refresh Cockpit") { - Task { await runRatchet(["next"]) } - } - glassButton("Lifecycle Status") { - Task { await runRatchet(["supervise", "--status"]) } - } - glassButton("Automation Plan") { - Task { await runRatchet(["pipeline"], input: "no\n") } - } - glassButton("Manual Positions") { - Task { await runRatchet(["position", "list"]) } - } - glassButton("Full Report") { - Task { await runRatchet(["report"]) } - } - if isRunning { - ProgressView() - .controlSize(.small) - .padding(.leading, 8) - } + var title: String { + switch self { + case .setup: "Setup" + case .refresh: "Refresh" + case .watch: "Watch" + case .cooldown: "Mature" + case .exposure: "Hold" + case .adapt: "Adapt" } } - private var manualExposureControls: some View { - VStack(alignment: .leading, spacing: 10) { - Text("Manual Braiins Exposure") - .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.82)) - VStack(spacing: 10) { - TextField("Description, e.g. Braiins order abc 0.0001 BTC", text: $manualDescription) - .textFieldStyle(.plain) - .padding(10) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) - .foregroundStyle(.white) - TextField("Hours", text: $maturityHours) - .textFieldStyle(.plain) - .padding(10) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) - .foregroundStyle(.white) - glassButton("Record Exposure") { - let description = manualDescription.trimmingCharacters(in: .whitespacesAndNewlines) - let hours = maturityHours.trimmingCharacters(in: .whitespacesAndNewlines) - guard !description.isEmpty else { - output = "Enter a manual exposure description first." - return - } - Task { - await runRatchet([ - "position", "open", - "--description", description, - "--maturity-hours", hours.isEmpty ? "72" : hours - ]) - } - } - TextField("ID", text: $closePositionId) - .textFieldStyle(.plain) - .padding(10) - .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) - .foregroundStyle(.white) - glassButton("Close Exposure") { - let positionId = closePositionId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !positionId.isEmpty else { - output = "Enter a manual position ID first." - return - } - Task { await runRatchet(["position", "close", positionId]) } - } - } + var symbol: String { + switch self { + case .setup: "wrench.and.screwdriver" + case .refresh: "arrow.clockwise" + case .watch: "binoculars" + case .cooldown: "timer" + case .exposure: "lock.shield" + case .adapt: "slider.horizontal.3" } - .padding(16) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(.white.opacity(0.14), lineWidth: 1) - ) } - private var outputPanel: some View { - ScrollView { - Text(output) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.white.opacity(0.92)) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) - .padding(26) + var timelineIndex: Int { + switch self { + case .setup: 0 + case .refresh: 1 + case .watch: 2 + case .cooldown, .exposure: 3 + case .adapt: 4 + } + } +} + +struct Directive { + let title: String + let detail: String + let command: String? + let color: Color + + static func from(_ appState: AppStatePayload?) -> Directive { + guard let appState else { + return Directive(title: "LOAD STATE", detail: "The app is reading the ratchet lifecycle database.", command: nil, color: .secondary) + } + if let watch = appState.operatorState.completedWatch { + return Directive( + title: "STOP", + detail: "Wait until \(watch.earliestActionLocal). Repeating the same watch now would be loop-chasing.", + command: "./scripts/ratchet once", + color: .orange + ) + } + if appState.operatorState.activeWatch != nil { + return Directive(title: "WAIT", detail: "A watch is already running. Do not start another one.", command: nil, color: .orange) + } + if !appState.operatorState.activeManualPositions.isEmpty { + return Directive(title: "HOLD", detail: "Manual Braiins exposure is active. Supervise it; do not start new experiments.", command: "./scripts/ratchet supervise --status", color: .orange) + } + if !appState.operatorState.hasOcean || !appState.operatorState.hasMarket || !appState.operatorState.isFresh { + return Directive(title: "REFRESH", detail: "The latest market state is stale or missing. Collect exactly one fresh sample.", command: "./scripts/ratchet once", color: .green) + } + if appState.operatorState.action == "manual_canary" { + return Directive(title: "WATCH", detail: "Run one bounded passive watch. This buys information, not a promise of profit.", command: "./scripts/ratchet watch 2", color: .green) + } + if appState.operatorState.action == "manual_bid" { + return Directive(title: "REVIEW", detail: "Read the full report before any manual Braiins action.", command: "./scripts/ratchet report", color: .green) + } + return Directive(title: "OBSERVE", detail: "No useful action window is visible right now.", command: nil, color: .secondary) + } +} + +struct AppStatePayload: Codable { + let generatedAt: String + let operatorState: OperatorStatePayload + let automationPlan: AutomationPlanPayload + let cockpit: String + let latest: LatestPayload + + enum CodingKeys: String, CodingKey { + case generatedAt = "generated_at" + case operatorState = "operator_state" + case automationPlan = "automation_plan" + case cockpit + case latest + } +} + +struct OperatorStatePayload: Codable { + let hasOcean: Bool + let hasMarket: Bool + let action: String? + let activeWatch: String? + let completedWatch: CompletedWatchPayload? + let isFresh: Bool + let freshnessMinutes: Int? + let latestReport: String? + let runningRuns: [String] + let latestOceanTimestamp: String? + let latestMarketTimestamp: String? + let activeManualPositions: [String] + + enum CodingKeys: String, CodingKey { + case hasOcean = "has_ocean" + case hasMarket = "has_market" + case action + case activeWatch = "active_watch" + case completedWatch = "completed_watch" + case isFresh = "is_fresh" + case freshnessMinutes = "freshness_minutes" + case latestReport = "latest_report" + case runningRuns = "running_runs" + case latestOceanTimestamp = "latest_ocean_timestamp" + case latestMarketTimestamp = "latest_market_timestamp" + case activeManualPositions = "active_manual_positions" + } +} + +struct CompletedWatchPayload: Codable { + let reportPath: String + let ageMinutes: Int + let remainingMinutes: Int + let cooldownMinutes: Int + let earliestActionUtc: String + let earliestActionLocal: String + + enum CodingKeys: String, CodingKey { + case reportPath = "report_path" + case ageMinutes = "age_minutes" + case remainingMinutes = "remaining_minutes" + case cooldownMinutes = "cooldown_minutes" + case earliestActionUtc = "earliest_action_utc" + case earliestActionLocal = "earliest_action_local" + } +} + +struct AutomationPlanPayload: Codable { + let kind: String + let title: String + let steps: [String] + let waitSeconds: Int + + enum CodingKeys: String, CodingKey { + case kind + case title + case steps + case waitSeconds = "wait_seconds" + } +} + +struct LatestPayload: Codable { + let ocean: [String: LooseString]? + let market: [String: LooseString]? + let proposal: [String: LooseString]? +} + +struct LooseString: Codable, CustomStringConvertible { + let description: String + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + description = "n/a" + } else if let string = try? container.decode(String.self) { + description = string + } else if let int = try? container.decode(Int.self) { + description = String(int) + } else if let double = try? container.decode(Double.self) { + description = String(double) + } else if let bool = try? container.decode(Bool.self) { + description = String(bool) + } else { + description = "n/a" } - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .stroke(.white.opacity(0.16), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.35), radius: 28, x: 0, y: 18) - .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func glassButton(_ title: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(title) - .font(.system(size: 14, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - } - .buttonStyle(.plain) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.thinMaterial, in: Capsule()) - .overlay(Capsule().stroke(.white.opacity(0.18), lineWidth: 1)) - .disabled(isRunning) + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) } +} - @MainActor - private func runRatchet(_ arguments: [String], input: String? = nil) async { - isRunning = true - output = "Running ./scripts/ratchet \(arguments.joined(separator: " ")) ..." - let result = await RatchetProcess.run(arguments: arguments, input: input) - output = result - isRunning = false +enum AppStateLoadResult { + case success(AppStatePayload) + case failure(String) +} + +extension String { + var lastPathComponent: String { + URL(fileURLWithPath: self).lastPathComponent } } @@ -310,21 +1259,59 @@ enum AppIconFactory { } enum RatchetProcess { + static func loadAppState() async -> AppStateLoadResult { + await Task.detached { + guard let repoRoot = findRepoRoot() else { + return .failure(repoNotFoundMessage) + } + + let script = repoRoot.appendingPathComponent("scripts/ratchet").path + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", ([script, "app-state"]).map(shellQuote).joined(separator: " ")] + process.currentDirectoryURL = repoRoot + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: outputData, encoding: .utf8) ?? "" + let errors = String(data: errorData, encoding: .utf8) ?? "" + + guard process.terminationStatus == 0 else { + return .failure(errors.isEmpty ? output : errors) + } + + do { + let payload = try JSONDecoder().decode(AppStatePayload.self, from: outputData) + return .success(payload) + } catch { + return .failure(""" + Could not decode native app state. + + Decode error: \(error.localizedDescription) + + Output: + \(output) + \(errors) + """) + } + } catch { + return .failure("Failed to load app state: \(error.localizedDescription)") + } + }.value + } + static func run(arguments: [String], input: String? = nil) async -> String { await Task.detached { guard let repoRoot = findRepoRoot() else { - return """ - Braiins Ratchet cannot find its repository. - - Expected to find: - scripts/ratchet - - Start the packaged app through: - ./scripts/ratchet app - - Or open this bundle from inside the BraiinsRatchet repository: - macos/build/Braiins Ratchet.app - """ + return repoNotFoundMessage } let script = repoRoot.appendingPathComponent("scripts/ratchet").path @@ -386,4 +1373,19 @@ enum RatchetProcess { private static func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } + + private static var repoNotFoundMessage: String { + """ + Braiins Ratchet cannot find its repository. + + Expected to find: + scripts/ratchet + + Start the packaged app through: + ./scripts/ratchet app + + Or open this bundle from inside the BraiinsRatchet repository: + macos/build/Braiins Ratchet.app + """ + } } diff --git a/scripts/ratchet b/scripts/ratchet index bb6d8a8..82ce7d5 100755 --- a/scripts/ratchet +++ b/scripts/ratchet @@ -18,6 +18,7 @@ Commands: app Build and open the native macOS app. position Record/list/close manually executed Braiins exposure. report Print the latest stored report without fetching new data. + app-state Print structured JSON for the native macOS app. experiments Print the Karpathy-style experiment ledger. retro SINCE [UNTIL] Write a retroactive report from stored snapshots. raw-cycle Run one full monitor cycle and print raw JSON. @@ -42,7 +43,7 @@ USAGE ensure_venv() { if [[ ! -x "$PYTHON_BIN" ]]; then - echo "Creating local virtual environment at .venv ..." + echo "Creating local virtual environment at .venv ..." >&2 python3 -m venv "$ROOT_DIR/.venv" fi } @@ -113,6 +114,10 @@ cmd_report() { run_python -m braiins_ratchet.cli report } +cmd_app_state() { + run_python -m braiins_ratchet.cli app-state +} + cmd_pipeline() { run_python -m braiins_ratchet.cli pipeline "$@" } @@ -177,6 +182,7 @@ main() { app|mac-app) cmd_app "$@" ;; position|positions) cmd_position "$@" ;; report) cmd_report "$@" ;; + app-state) cmd_app_state "$@" ;; experiments) cmd_experiments "$@" ;; retro) cmd_retro "$@" ;; raw-cycle) cmd_raw_cycle "$@" ;; diff --git a/src/braiins_ratchet/cli.py b/src/braiins_ratchet/cli.py index fa521bd..1e32d50 100644 --- a/src/braiins_ratchet/cli.py +++ b/src/braiins_ratchet/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from dataclasses import asdict from datetime import UTC, datetime, timedelta import json from pathlib import Path @@ -17,7 +18,7 @@ from .experiments import ( summarize_since, write_retro_report, ) -from .guidance import build_operator_cockpit +from .guidance import build_operator_cockpit, get_operator_state from .lifecycle import ( close_manual_position, open_manual_position, @@ -34,6 +35,7 @@ from .storage import ( init_db, latest_market_snapshot, latest_ocean_snapshot, + latest_proposal, save_market_snapshot, save_ocean_snapshot, save_proposal, @@ -157,6 +159,26 @@ def cmd_next(_: argparse.Namespace) -> int: return 0 +def cmd_app_state(_: argparse.Namespace) -> int: + with connect() as conn: + init_db(conn) + operator_state = get_operator_state(conn) + automation_plan = build_automation_plan(conn) + payload = { + "generated_at": datetime.now(UTC).isoformat(timespec="seconds"), + "operator_state": asdict(operator_state), + "automation_plan": asdict(automation_plan), + "cockpit": build_operator_cockpit(conn), + "latest": { + "ocean": _object_dict(latest_ocean_snapshot(conn)), + "market": _object_dict(latest_market_snapshot(conn)), + "proposal": _object_dict(latest_proposal(conn)), + }, + } + print(json.dumps(payload, default=str, indent=2)) + return 0 + + def cmd_pipeline(args: argparse.Namespace) -> int: config = load_config(Path(args.config) if args.config else None) with connect() as conn: @@ -317,6 +339,12 @@ def _proposal_json(proposal: object) -> str: return json.dumps(proposal, default=default, indent=2) +def _object_dict(value: object | None) -> dict[str, object] | None: + if value is None: + return None + return dict(value.__dict__) if hasattr(value, "__dict__") else {"value": str(value)} + + def _run_one_fresh_cycle(config: object) -> None: with connect() as conn: run_cycle(conn, config) @@ -387,6 +415,9 @@ def build_parser() -> argparse.ArgumentParser: next_step = sub.add_parser("next", help="print exactly what the operator should do next") next_step.set_defaults(func=cmd_next) + app_state = sub.add_parser("app-state", help="print structured JSON for the native app") + app_state.set_defaults(func=cmd_app_state) + pipeline = sub.add_parser("pipeline", help="propose and confirm the next automation step") pipeline.add_argument("--config") pipeline.add_argument("--yes", action="store_true", help="accept the printed plan without prompting") diff --git a/tests/test_mac_app.py b/tests/test_mac_app.py index c79302c..015dd56 100644 --- a/tests/test_mac_app.py +++ b/tests/test_mac_app.py @@ -1,6 +1,8 @@ from pathlib import Path import unittest +from braiins_ratchet.cli import build_parser + ROOT = Path(__file__).resolve().parents[1] @@ -12,8 +14,14 @@ class MacAppPackagingTest(unittest.TestCase): self.assertIn("app|mac-app", text) self.assertIn("cmd_app", text) + self.assertIn("app-state", text) self.assertNotIn("swift run BraiinsRatchetMac", text) + def test_python_cli_exposes_structured_app_state(self): + args = build_parser().parse_args(["app-state"]) + + self.assertEqual(args.func.__name__, "cmd_app_state") + def test_mac_app_builder_creates_bundle_contract(self): builder = ROOT / "scripts" / "build_mac_app" text = builder.read_text() @@ -36,3 +44,14 @@ class MacAppPackagingTest(unittest.TestCase): text = path.read_text() self.assertIn("./scripts/ratchet app", text) self.assertNotIn("swift run BraiinsRatchetMac", text) + + def test_swift_app_uses_native_dashboard_not_raw_terminal_as_primary_ui(self): + source = ROOT / "macos" / "BraiinsRatchet" / "Sources" / "BraiinsRatchetMac" / "BraiinsRatchetApp.swift" + text = source.read_text() + + self.assertIn("NavigationSplitView", text) + self.assertIn("MissionControlView", text) + self.assertIn("ResearchTimeline", text) + self.assertIn("AutoresearchOrb", text) + self.assertIn("AppStatePayload", text) + self.assertIn("loadAppState", text)