From cbb413e477624cfb6ffc08f54395d6a601dd779f Mon Sep 17 00:00:00 2001 From: AnubisQuantumCipher Date: Fri, 14 Aug 2026 09:06:22 -0400 Subject: [PATCH 1/3] R1A checkpoint 1: shared effective-state model + CLI surface + gate matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EffectiveState.compute() derives overall state strictly from evidence (R0 AuthorityGuard verdict, launchd probes, canonical target existence, ledger chain verification) with fail-closed precedence: ambiguous > conflict > degradedLedger > paused > running. New CLI: --effective-state [--json]. Category relocated to Config.swift (same module, zero semantics) so the upcoming app target shares sources without the engine @main. --state-test: 31 gates — 9-fixture matrix, 9 CLI<->model parity checks via real child invocations, fail-closed UI mapping, read-only confinement. R0 unchanged: 31/31 + 17/17. Co-Authored-By: Claude Opus 4.8 --- src/Config.swift | 21 ++++ src/DeskTidy.swift | 32 ++--- src/EffectiveState.swift | 250 +++++++++++++++++++++++++++++++++++++++ src/R1ATests.swift | 241 +++++++++++++++++++++++++++++++++++++ 4 files changed, 525 insertions(+), 19 deletions(-) create mode 100644 src/EffectiveState.swift create mode 100644 src/R1ATests.swift diff --git a/src/Config.swift b/src/Config.swift index 5550745..1ce0465 100644 --- a/src/Config.swift +++ b/src/Config.swift @@ -59,3 +59,24 @@ enum Config { static let enableSmartTriage = true static let smartIntervalSeconds: TimeInterval = 300 } + +// Where a file belongs. Folder names come from Config above (user-editable). +// Lives here (not in the engine file) so lightweight targets — the CLI and the +// R1A menu-bar app — share one definition without importing the engine's @main. +enum Category: CaseIterable { + case inbox, documents, images, screenshots, videos, audio, archives, code, folders + + var folderName: String { + switch self { + case .inbox: return Config.folderInbox + case .documents: return Config.folderDocuments + case .images: return Config.folderImages + case .screenshots: return Config.folderScreenshots + case .videos: return Config.folderVideos + case .audio: return Config.folderAudio + case .archives: return Config.folderArchives + case .code: return Config.folderCode + case .folders: return Config.folderFolders + } + } +} diff --git a/src/DeskTidy.swift b/src/DeskTidy.swift index e561659..d4759e5 100644 --- a/src/DeskTidy.swift +++ b/src/DeskTidy.swift @@ -20,25 +20,6 @@ import PDFKit // • every move is logged; nothing is hidden // ============================================================================ -// Where a file belongs. Folder names come from Config (user-editable). -enum Category: CaseIterable { - case inbox, documents, images, screenshots, videos, audio, archives, code, folders - - var folderName: String { - switch self { - case .inbox: return Config.folderInbox - case .documents: return Config.folderDocuments - case .images: return Config.folderImages - case .screenshots: return Config.folderScreenshots - case .videos: return Config.folderVideos - case .audio: return Config.folderAudio - case .archives: return Config.folderArchives - case .code: return Config.folderCode - case .folders: return Config.folderFolders - } - } -} - // Plain records used by the optional AI pass (safe to keep unconditionally). struct CachedDecision: Codable, Equatable { let fingerprint: String @@ -126,6 +107,19 @@ final class DeskTidy { if arguments.contains("--authority-diagnose") || arguments.contains("--authority-check") { return AuthorityGuard().diagnose(rootPath: target.path, json: arguments.contains("--json")) } + if arguments.contains("--effective-state") { + let report = EffectiveState.compute() + if arguments.contains("--json") { + let enc = JSONEncoder(); enc.outputFormatting = [.prettyPrinted, .sortedKeys] + if let d = try? enc.encode(report) { print(String(decoding: d, as: UTF8.self)) } + } else { + print(EffectiveState.diagnostic(report)) + } + return 0 + } + if arguments.contains("--state-test") { + return R1ATests(binaryPath: CommandLine.arguments[0]).runAll() ? 0 : 1 + } if arguments.contains("--history") { return printHistory(arguments: arguments) } diff --git a/src/EffectiveState.swift b/src/EffectiveState.swift new file mode 100644 index 0000000..bdfbf70 --- /dev/null +++ b/src/EffectiveState.swift @@ -0,0 +1,250 @@ +import Foundation + +// ============================================================================ +// R1A — One read-only effective-state truth, shared by the CLI and the +// menu-bar app. +// +// Everything here is DERIVED from evidence, never guessed: +// • movement authority → the R0 AuthorityGuard (never duplicated); +// • product agent state → launchd probes (live or fixture) for our labels; +// • watched target → the installed product plist's environment, else +// DESKTIDY_TARGET_DIR, else the default — and the +// directory must actually exist to count; +// • ledger health → ReceiptLedger.verifyChain on the real file. +// +// A plist existing on disk is not "running". An API existing is not "works". +// Every unprovable input degrades the overall state — never upgrades it. +// +// This file (plus Config/Authority/Receipts) is the complete dependency set +// of the menu-bar app. It contains NO mutating operations: no moveItem, +// no removeItem, no bootstrap/bootout, no writes to the ledger. +// ============================================================================ + +/// Overall product state, strictly ordered fail-closed: +/// ambiguity dominates conflict dominates ledger damage dominates paused +/// dominates running. Only `runningHealthy` may ever render as healthy. +enum OverallState: String, Codable { + case runningHealthy // DeskTidy is the sole authority, loaded, ledger sane + case pausedNotLoaded // no conflict, but DeskTidy's agent isn't loaded + case foreignConflict // another authority owns the root — we must not run + case degradedLedger // movement history integrity cannot be trusted + case ambiguous // authority/target unprovable — fail closed +} + +enum LedgerHealth: Codable, Equatable { + case valid(receiptCount: Int) + case absent // no ledger yet — normal for a fresh install + case invalid(reason: String) +} + +struct EffectiveStateReport: Codable { + var schema: Int = 1 + var generatedAt: String + var overall: OverallState + var overallReason: String + var watchedTarget: String + var watchedTargetCanonical: String? + var targetExists: Bool + var productAgentLoaded: Bool + var productAgentState: String // running | loadedIdle | notLoaded | stale | uninspectable + var effectiveMoverLabel: String? // provable mover of this root, if any + var effectiveMoverProgram: String? + var foreignMovers: [String] // labels sharing the root (conflict evidence) + var ambiguityReason: String? + var ledger: String // "valid(N)" | "absent" | "invalid: reason" + var ledgerReceiptCount: Int + var suggestionsPresent: Bool // smart-triage suggestions file exists (display-only) + var moverVersion: String +} + +enum EffectiveState { + + /// The single derivation. `now` is injectable for deterministic tests. + static func compute() -> EffectiveStateReport { + let fm = FileManager.default + let env = ProcessInfo.processInfo.environment + let home = fm.homeDirectoryForCurrentUser + + // --- watched target: installed plist env > DESKTIDY_TARGET_DIR > default + let agentsDir: URL = { + if let d = env["DESKTIDY_AGENTS_DIR"], !d.isEmpty { + return URL(fileURLWithPath: (d as NSString).expandingTildeInPath, isDirectory: true) + } + return home.appendingPathComponent("Library/LaunchAgents", isDirectory: true) + }() + var target: String + var targetSource: String + if let fromPlist = installedTarget(agentsDir: agentsDir) { + target = fromPlist; targetSource = "installed plist" + } else if let t = env["DESKTIDY_TARGET_DIR"], !t.isEmpty { + target = (t as NSString).expandingTildeInPath; targetSource = "environment" + } else { + target = home.appendingPathComponent(Config.targetDirName).path; targetSource = "default" + } + var isDir: ObjCBool = false + let targetExists = fm.fileExists(atPath: target, isDirectory: &isDir) && isDir.boolValue + let canonical = targetExists ? AuthorityGuard.canonicalize(target).path : nil + + // --- authority (the R0 guard, unmodified) + let guardian = AuthorityGuard() + let decision = guardian.evaluate(rootPath: target) + + // --- product agent state (same probe mechanism, self labels) + let (records, _) = guardian.relevantMovers(for: AuthorityGuard.canonicalize(target)) + let selfRecord = records.first { $0.isSelf && $0.label == "com.desktidy.sort" } + let productState = selfRecord?.state ?? .notLoaded + let productLoaded = productState == .running || productState == .loadedIdle + + // --- ledger health + let appDir: URL = { + if let a = env["DESKTIDY_APP_DIR"], !a.isEmpty { + return URL(fileURLWithPath: (a as NSString).expandingTildeInPath, isDirectory: true) + } + return home.appendingPathComponent("Library/Application Support/DeskTidy", isDirectory: true) + }() + let ledger = ReceiptLedger(appDirectory: appDir) + let ledgerHealth: LedgerHealth + if !fm.fileExists(atPath: ledger.ledgerURL.path) { + ledgerHealth = .absent + } else if let problem = ledger.verifyChain() { + ledgerHealth = .invalid(reason: problem) + } else { + ledgerHealth = .valid(receiptCount: ledger.readAll().receipts.count) + } + + // --- effective mover: only when provable from live evidence + var moverLabel: String? + var moverProgram: String? + var foreign: [String] = [] + switch decision { + case .conflict(let movers): + foreign = movers.map { $0.label } + if let live = movers.first(where: { $0.state == .running }) ?? movers.first { + moverLabel = live.label; moverProgram = live.programPath + } + case .sole, .soleWithStale: + if productLoaded { moverLabel = "com.desktidy.sort"; moverProgram = selfRecord?.programPath } + case .ambiguous: + break // unprovable — leave nil rather than guess + } + + // --- overall, strictly fail-closed + var overall: OverallState + var reason: String + var ambiguity: String? + if !targetExists { + overall = .ambiguous + reason = "watched target does not exist (\(targetSource): \(target))" + ambiguity = reason + } else { + switch decision { + case .ambiguous(let why, _): + overall = .ambiguous + reason = "movement authority unprovable: \(why)" + ambiguity = why + case .conflict: + overall = .foreignConflict + reason = "another authority owns this root: \(foreign.joined(separator: ", "))" + case .sole, .soleWithStale: + if case .invalid(let why) = ledgerHealth { + overall = .degradedLedger + reason = "receipt ledger failed verification: \(why)" + } else if productLoaded { + overall = .runningHealthy + reason = "DeskTidy is the sole movement authority for this root" + } else { + overall = .pausedNotLoaded + reason = "no conflicting authority, and DeskTidy's agent is not loaded" + } + } + } + + let ledgerString: String + let ledgerCount: Int + switch ledgerHealth { + case .valid(let n): ledgerString = "valid(\(n))"; ledgerCount = n + case .absent: ledgerString = "absent"; ledgerCount = 0 + case .invalid(let why): ledgerString = "invalid: \(why)"; ledgerCount = 0 + } + + let suggestions = targetExists && fm.fileExists( + atPath: URL(fileURLWithPath: target) + .appendingPathComponent(Config.folderInbox) + .appendingPathComponent("SMART_TRIAGE_SUGGESTIONS.md").path) + + let iso = ISO8601DateFormatter() + return EffectiveStateReport( + generatedAt: iso.string(from: Date()), + overall: overall, + overallReason: reason, + watchedTarget: target, + watchedTargetCanonical: canonical, + targetExists: targetExists, + productAgentLoaded: productLoaded, + productAgentState: productState.rawValue, + effectiveMoverLabel: moverLabel, + effectiveMoverProgram: moverProgram, + foreignMovers: foreign, + ambiguityReason: ambiguity, + ledger: ledgerString, + ledgerReceiptCount: ledgerCount, + suggestionsPresent: suggestions, + moverVersion: DeskTidyVersion.string + ) + } + + /// Watched target recorded in the installed product plist, if any. + private static func installedTarget(agentsDir: URL) -> String? { + let plist = agentsDir.appendingPathComponent("com.desktidy.sort.plist") + guard let data = FileManager.default.contents(atPath: plist.path), + let obj = try? PropertyListSerialization.propertyList(from: data, format: nil), + let dict = obj as? [String: Any], + let envDict = dict["EnvironmentVariables"] as? [String: String], + let t = envDict["DESKTIDY_TARGET_DIR"], !t.isEmpty else { return nil } + return t + } + + // ------------------------------------------------------------------ UI mapping + // The menu-bar presentation derives from the report ONLY through these + // pure functions, so fail-closed rendering is testable without a GUI. + + static func menuBarSymbol(for overall: OverallState) -> String { + switch overall { + case .runningHealthy: return "tray.full" + case .pausedNotLoaded: return "pause.circle" + case .foreignConflict: return "exclamationmark.triangle" + case .degradedLedger: return "exclamationmark.triangle" + case .ambiguous: return "questionmark.circle" + } + } + + static func statusLine(for report: EffectiveStateReport) -> String { + switch report.overall { + case .runningHealthy: return "Active — sole authority for \(shortPath(report.watchedTarget))" + case .pausedNotLoaded: return "Paused — agent not loaded" + case .foreignConflict: return "Conflict — \(report.foreignMovers.joined(separator: ", ")) owns this folder" + case .degradedLedger: return "Attention — receipt ledger failed verification" + case .ambiguous: return "Unknown — \(report.ambiguityReason ?? "state unprovable")" + } + } + + static func shortPath(_ path: String) -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path + } + + /// Bounded plain-text diagnostic for the copy action. No file contents. + static func diagnostic(_ r: EffectiveStateReport) -> String { + """ + DeskTidy effective state (\(r.generatedAt)) + overall: \(r.overall.rawValue) — \(r.overallReason) + target: \(r.watchedTarget) (exists: \(r.targetExists)) + product agent: \(r.productAgentState) + effective mover: \(r.effectiveMoverLabel ?? "unprovable")\(r.effectiveMoverProgram.map { " (\($0))" } ?? "") + foreign movers: \(r.foreignMovers.isEmpty ? "none" : r.foreignMovers.joined(separator: ", ")) + ledger: \(r.ledger) + suggestions file present: \(r.suggestionsPresent) + version: \(r.moverVersion) + """ + } +} diff --git a/src/R1ATests.swift b/src/R1ATests.swift new file mode 100644 index 0000000..f39c637 --- /dev/null +++ b/src/R1ATests.swift @@ -0,0 +1,241 @@ +import Foundation + +// ============================================================================ +// R1A gates — the effective-state fixture matrix, cross-surface parity, and +// fail-closed UI mapping. Run via `desktidy-sort --state-test`. +// +// Every fixture is an isolated temp world (agents dir + launchd-state file + +// target root + app dir) injected through the same environment variables the +// real surfaces read. Live launchd, the real Desktop, and the personal mover +// are never touched. +// +// Parity is proven mechanically, not assumed: for each fixture the in-process +// model result (what the app renders) is compared with the JSON printed by a +// REAL child invocation of the CLI (`--effective-state --json`). +// ============================================================================ + +final class R1ATests { + let binaryPath: String + private let fm = FileManager.default + private var passCount = 0 + private var failCount = 0 + + init(binaryPath: String) { self.binaryPath = binaryPath } + + private func check(_ id: String, _ desc: String, _ ok: Bool, _ detail: String = "") { + if ok { print("PASS \(id) \(desc)"); passCount += 1 } + else { print("FAIL \(id) \(desc)\(detail.isEmpty ? "" : " — \(detail)")"); failCount += 1 } + } + + private func tempDir(_ tag: String) -> URL { + let url = fm.temporaryDirectory.appendingPathComponent("desktidy-r1a-\(tag)-\(UUID().uuidString.prefix(8))") + try? fm.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private struct Fixture { + let name: String + let expected: OverallState + let agents: URL + let states: [String: String] + let target: URL + let app: URL + } + + private func writePlist(_ dir: URL, label: String, watch: [String]?, program: String, + targetEnv: String? = nil) { + var dict: [String: Any] = ["Label": label, "ProgramArguments": [program]] + if let watch { dict["WatchPaths"] = watch } + if let targetEnv { dict["EnvironmentVariables"] = ["DESKTIDY_TARGET_DIR": targetEnv] } + let data = try! PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) + try! data.write(to: dir.appendingPathComponent("\(label).plist")) + } + + private func makeProgram(_ name: String) -> String { + let p = tempDir("bin").appendingPathComponent(name) + fm.createFile(atPath: p.path, contents: Data("#!/bin/sh\n".utf8)) + return p.path + } + + /// Compute the model in-process (the app's exact input) under fixture env. + private func modelState(_ f: Fixture) -> EffectiveStateReport { + setenv("DESKTIDY_AGENTS_DIR", f.agents.path, 1) + setenv("DESKTIDY_TARGET_DIR", f.target.path, 1) + setenv("DESKTIDY_APP_DIR", f.app.path, 1) + let stateFile = f.agents.appendingPathComponent("launchd-state.json") + let enc = try! JSONSerialization.data(withJSONObject: f.states) + try! enc.write(to: stateFile) + setenv("DESKTIDY_LAUNCHD_STATE_FILE", stateFile.path, 1) + defer { + unsetenv("DESKTIDY_AGENTS_DIR"); unsetenv("DESKTIDY_TARGET_DIR") + unsetenv("DESKTIDY_APP_DIR"); unsetenv("DESKTIDY_LAUNCHD_STATE_FILE") + } + return EffectiveState.compute() + } + + /// Invoke the real CLI as a child with the fixture env; decode its JSON. + private func cliState(_ f: Fixture) -> EffectiveStateReport? { + let stateFile = f.agents.appendingPathComponent("launchd-state.json") + let p = Process() + p.executableURL = URL(fileURLWithPath: binaryPath) + p.arguments = ["--effective-state", "--json"] + var env = ProcessInfo.processInfo.environment + env["DESKTIDY_AGENTS_DIR"] = f.agents.path + env["DESKTIDY_TARGET_DIR"] = f.target.path + env["DESKTIDY_APP_DIR"] = f.app.path + env["DESKTIDY_LAUNCHD_STATE_FILE"] = stateFile.path + p.environment = env + let out = Pipe(); p.standardOutput = out; p.standardError = Pipe() + do { try p.run(); p.waitUntilExit() } catch { return nil } + let data = out.fileHandleForReading.readDataToEndOfFile() + return try? JSONDecoder().decode(EffectiveStateReport.self, from: data) + } + + // ------------------------------------------------------------------ run + func runAll() -> Bool { + let fixtures = buildFixtures() + + // S-gates: state matrix, one per fixture. + for (i, f) in fixtures.enumerated() { + let report = modelState(f) + check(String(format: "S%02d", i + 1), + "fixture '\(f.name)' → \(f.expected.rawValue)", + report.overall == f.expected, + "got \(report.overall.rawValue): \(report.overallReason)") + } + + // P-gates: cross-surface parity — CLI JSON must equal the model. + for (i, f) in fixtures.enumerated() { + let model = modelState(f) + guard let cli = cliState(f) else { + check(String(format: "P%02d", i + 1), "parity '\(f.name)'", false, "CLI produced no decodable JSON") + continue + } + let same = cli.overall == model.overall + && cli.foreignMovers == model.foreignMovers + && cli.productAgentState == model.productAgentState + && cli.targetExists == model.targetExists + && cli.ledger == model.ledger + check(String(format: "P%02d", i + 1), + "parity '\(f.name)': CLI == app model", + same, + "cli=\(cli.overall.rawValue) model=\(model.overall.rawValue)") + } + + // U-gates: fail-closed UI mapping — unsafe states may never render healthy. + let healthySymbol = EffectiveState.menuBarSymbol(for: .runningHealthy) + for state in [OverallState.foreignConflict, .degradedLedger, .ambiguous] { + check("U-\(state.rawValue)", + "\(state.rawValue) never renders the healthy symbol", + EffectiveState.menuBarSymbol(for: state) != healthySymbol) + } + for (i, f) in fixtures.enumerated() where f.expected != .runningHealthy { + let report = modelState(f) + let line = EffectiveState.statusLine(for: report) + check(String(format: "U%02d", i + 1), + "fixture '\(f.name)' status line is not 'Active'", + !line.hasPrefix("Active"), line) + } + + // R-gates: read-only confinement — computing state and building the + // diagnostic must not create, modify, or remove anything in the target. + do { + let f = fixtures[0] + _ = fm.createFile(atPath: f.target.appendingPathComponent("witness.pdf").path, + contents: Data("w".utf8)) + let before = try! fm.contentsOfDirectory(atPath: f.target.path).sorted() + let report = modelState(f) + _ = EffectiveState.diagnostic(report) + _ = EffectiveState.statusLine(for: report) + _ = cliState(f) + let after = try! fm.contentsOfDirectory(atPath: f.target.path).sorted() + check("R01", "state computation + diagnostic are read-only on the target", before == after, + "before=\(before) after=\(after)") + let ledgerFile = f.app.appendingPathComponent("receipts/ledger.jsonl") + check("R02", "state computation writes no ledger", !fm.fileExists(atPath: ledgerFile.path)) + } + + print("R1A GATES: \(passCount) passed, \(failCount) failed") + return failCount == 0 + } + + // ------------------------------------------------------------ fixtures + private func buildFixtures() -> [Fixture] { + var out: [Fixture] = [] + + func base(_ name: String, _ expected: OverallState, + mutate: (inout Fixture) -> Void = { _ in }) { + var f = Fixture(name: name, expected: expected, + agents: tempDir("agents"), states: [:], + target: tempDir("target"), app: tempDir("app")) + mutate(&f) + out.append(f) + } + + // 1. running under DeskTidy authority + base("running", .runningHealthy) { f in + self.writePlist(f.agents, label: "com.desktidy.sort", watch: [f.target.path], + program: self.makeProgram("desktidy-sort"), targetEnv: f.target.path) + f = Fixture(name: f.name, expected: f.expected, agents: f.agents, + states: ["com.desktidy.sort": "running"], target: f.target, app: f.app) + } + // 2. paused / not loaded (no plists at all) + base("paused", .pausedNotLoaded) + // 3. same-root foreign conflict + base("foreign-conflict", .foreignConflict) { f in + self.writePlist(f.agents, label: "com.example.other-mover", watch: [f.target.path], + program: self.makeProgram("other")) + f = Fixture(name: f.name, expected: f.expected, agents: f.agents, + states: ["com.example.other-mover": "running"], target: f.target, app: f.app) + } + // 4. symlink-equivalent conflict + base("symlink-conflict", .foreignConflict) { f in + let alias = self.tempDir("links").appendingPathComponent("alias") + try? self.fm.createSymbolicLink(at: alias, withDestinationURL: f.target) + self.writePlist(f.agents, label: "com.example.aliased", watch: [alias.path], + program: self.makeProgram("aliased")) + f = Fixture(name: f.name, expected: f.expected, agents: f.agents, + states: ["com.example.aliased": "loaded"], target: f.target, app: f.app) + } + // 5. disjoint foreign mover → not a conflict; nothing loaded ⇒ paused + base("disjoint-foreign", .pausedNotLoaded) { f in + self.writePlist(f.agents, label: "com.example.elsewhere", + watch: [self.tempDir("other-root").path], + program: self.makeProgram("elsewhere")) + f = Fixture(name: f.name, expected: f.expected, agents: f.agents, + states: ["com.example.elsewhere": "running"], target: f.target, app: f.app) + } + // 6. stale foreign plist (executable gone, not loaded) → paused, non-blocking + base("stale-plist", .pausedNotLoaded) { f in + self.writePlist(f.agents, label: "com.example.stale", watch: [f.target.path], + program: "/nonexistent/gone-\(UUID().uuidString)") + f = Fixture(name: f.name, expected: f.expected, agents: f.agents, + states: ["com.example.stale": "not-loaded"], target: f.target, app: f.app) + } + // 7. unreadable agent definition → ambiguous + base("unreadable-agent", .ambiguous) { f in + try? Data("not a plist".utf8).write(to: f.agents.appendingPathComponent("com.example.junk.plist")) + } + // 8. missing target directory → ambiguous + base("missing-target", .ambiguous) { f in + try? self.fm.removeItem(at: f.target) + } + // 9. invalid ledger (tampered chain) → degradedLedger + base("invalid-ledger", .degradedLedger) { f in + let ledger = ReceiptLedger(appDirectory: f.app) + let svc = MovementService(root: f.target, ledger: ledger, + moverVersion: DeskTidyVersion.string, log: { _ in }) + let src = f.target.appendingPathComponent("seed.pdf") + self.fm.createFile(atPath: src.path, contents: Data("s".utf8)) + try? self.fm.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -3600)], + ofItemAtPath: src.path) + _ = svc.perform(source: src, category: .documents, ruleID: "seed", + settleMTime: Date(timeIntervalSinceNow: -3600), settleAge: 3600) + if var text = try? String(contentsOf: ledger.ledgerURL, encoding: .utf8) { + text = text.replacingOccurrences(of: "seed.pdf", with: "SEED.pdf") + try? Data(text.utf8).write(to: ledger.ledgerURL) + } + } + return out + } +} From 41b7057bf5b72d5cf7d3cb1d815d76137ed89d3e Mon Sep 17 00:00:00 2001 From: AnubisQuantumCipher Date: Fri, 14 Aug 2026 09:12:14 -0400 Subject: [PATCH 2/3] R1A checkpoint 2: menu-bar app (read-only), CI harness, tamper gate, docs truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/DeskTidyApp.swift: MenuBarExtra over the shared EffectiveState model — presentation only (comment-stripping CI grep proves no mutation symbols). Read-only actions: reveal folder, reveal receipts, copy bounded diagnostic. --smoke gives CI a headless run of the real app binary; fixture smokes assert foreignConflict and pausedNotLoaded outcomes. scripts/build-app.sh builds the bundle with plain swiftc (macOS 14 floor, no Xcode project). Live-machine evidence (read-only): the app binary's smoke on this Mac reports overall=foreignConflict against the real personal mover instead of claiming to run. GUI pixel observation: INDETERMINATE (ad-hoc app not indexable for screen tooling) — deliberately not claimed. A→B→A: conflict→healthy mutation compiled, S03/S04/U03/U04 failed for the intended reason ('Active — sole authority' under conflict), restore byte-identical (bc479aa3…), all 31 R1A + 31 R0 + 17 self-test green. README truth fixes per mission: undo claim → receipts language; 'smart move' roadmap item → human-approval preview per ML authority policy; app status stated as experimental/from-source only. R1B SMAppService migration spike contract written (docs/R1B_MIGRATION_SPIKE_CONTRACT.md) — not implemented. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 33 +++++ CHANGELOG.md | 13 ++ README.md | 10 +- app/DeskTidyApp.swift | 193 +++++++++++++++++++++++++++ docs/R1B_MIGRATION_SPIKE_CONTRACT.md | 53 ++++++++ scripts/build-app.sh | 51 +++++++ src/Config.swift | 2 + src/DeskTidy.swift | 1 - src/EffectiveState.swift | 4 +- 9 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 app/DeskTidyApp.swift create mode 100644 docs/R1B_MIGRATION_SPIKE_CONTRACT.md create mode 100755 scripts/build-app.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 397fe1b..cce3be4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,39 @@ jobs: grep -q 'AuthorityGuard().evaluate' src/DeskTidy.swift echo "all setup/start paths invoke the authority guard" + - name: R1A effective-state gates (matrix, parity, fail-closed UI, read-only) + run: ./build/desktidy-sort --state-test + + - name: R1A app build + headless smokes (no GUI session needed) + run: | + ./scripts/build-app.sh build + # conflict fixture: the app binary must fail closed, never claim running + AG=$(mktemp -d); TG=$(mktemp -d); AP=$(mktemp -d) + PROG=$(mktemp -d)/mover; printf '#!/bin/sh\n' > "$PROG" + /usr/bin/python3 - "$AG" "$TG" "$PROG" <<'PYEOF2' + import plistlib, sys, os, json + ag, tg, prog = sys.argv[1], sys.argv[2], sys.argv[3] + with open(os.path.join(ag, 'com.example.fixture-mover.plist'), 'wb') as f: + plistlib.dump({'Label': 'com.example.fixture-mover', 'ProgramArguments': [prog], 'WatchPaths': [tg]}, f) + with open(os.path.join(ag, 'state.json'), 'w') as f: + json.dump({'com.example.fixture-mover': 'running'}, f) + PYEOF2 + OUT=$(DESKTIDY_AGENTS_DIR="$AG" DESKTIDY_TARGET_DIR="$TG" DESKTIDY_APP_DIR="$AP" DESKTIDY_LAUNCHD_STATE_FILE="$AG/state.json" ./build/DeskTidy.app/Contents/MacOS/DeskTidy --smoke | tail -1) + echo "$OUT"; test "$OUT" = "SMOKE overall=foreignConflict" + # clean fixture: pausedNotLoaded + AG2=$(mktemp -d); TG2=$(mktemp -d); AP2=$(mktemp -d) + OUT2=$(DESKTIDY_AGENTS_DIR="$AG2" DESKTIDY_TARGET_DIR="$TG2" DESKTIDY_APP_DIR="$AP2" ./build/DeskTidy.app/Contents/MacOS/DeskTidy --smoke | tail -1) + echo "$OUT2"; test "$OUT2" = "SMOKE overall=pausedNotLoaded" + + - name: R1A read-only confinement grep (app sources contain no mutation calls) + run: | + for f in app/DeskTidyApp.swift src/EffectiveState.swift; do + if sed 's://.*$::' "$f" | grep -nE 'moveItem|removeItem|copyItem|createFile|bootstrap|bootout|writePending|ledger\.append'; then + echo "mutation symbol found in $f"; exit 1 + fi + done + echo "app surface is mutation-free" + - name: Collision safety (never overwrite) run: | SB="$(mktemp -d)/desk"; APP="$(mktemp -d)/app"; mkdir -p "$SB" "$APP" diff --git a/CHANGELOG.md b/CHANGELOG.md index f9aa3f2..2dc2b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased (branch r1a/public-trust-surface) + +- **Experimental menu-bar app (read-only trust surface):** build from source + with `scripts/build-app.sh`. Shows watched folder, effective movement + authority, agent state, and receipt-ledger health — derived from launchd + evidence and ledger verification via the same `EffectiveState` model the CLI + prints with `desktidy-sort --effective-state [--json]`. Conflict, ambiguity, + and ledger damage always render fail-closed; a plist on disk is never + treated as "running". Read-only actions only (reveal folder/receipts, copy + diagnostic). Not packaged, not shipped. +- **Docs truth pass:** README no longer implies an Undo command exists, and + the roadmap no longer proposes automatic model-authorized moves. + ## v1.2.0 — R0: single movement authority + canonical receipts - **Authority guard:** DeskTidy now refuses to sort a folder that another diff --git a/README.md b/README.md index 99481db..2fe70ac 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ untitled-2.xyz 📁 Inbox ← anything it's unsure about - **It waits before touching a file** (15s by default), so it never grabs something mid-download or mid-save. - **The AI never moves anything.** The optional smart pass writes suggestions to a file. You decide. - **Nothing leaves your Mac.** No servers, no telemetry, no network. The AI is Apple's on-device model. -- **Every move is logged**, so you can always see (and undo) exactly what happened. +- **Every move leaves a receipt.** A hash-chained, crash-recoverable ledger records each move's exact final path (`desktidy-sort --history`), so you can always see — and manually reverse — exactly what happened. A one-click Undo command is planned, not shipped. --- @@ -186,9 +186,13 @@ records every final path; subfolders (including `Inbox/`) are never re-sorted; ## Roadmap -- Menu-bar app with pause/resume and a live activity feed. +- Menu-bar app: an **experimental read-only status surface exists** (build it + from source with `scripts/build-app.sh` — it shows the watched folder, + movement authority, and receipt-ledger health, and refuses to claim + "running" under any conflict or ambiguity). Pause/resume, activity feed, + and notifications are still to come; it is not shipped or packaged. - Per-folder rules and user-defined categories via a JSON config (no rebuild). -- Optional "smart move" mode that acts on high-confidence AI suggestions (opt-in). +- Suggestion previews you can approve in one click — per the [ML authority policy](docs/ML_AUTHORITY_POLICY.md), model output never moves files on its own; approval stays human. - Homebrew tap for one-line install. ## Contributing diff --git a/app/DeskTidyApp.swift b/app/DeskTidyApp.swift new file mode 100644 index 0000000..b500d09 --- /dev/null +++ b/app/DeskTidyApp.swift @@ -0,0 +1,193 @@ +import AppKit +import SwiftUI + +// ============================================================================ +// DeskTidy menu-bar app — R1A: a read-only trust surface. +// +// Everything shown is EffectiveState.compute() — the same derivation the CLI +// prints with `--effective-state`. This file contains presentation only: +// no file moves, no launchd mutation, no receipt writes, no model calls. +// +// R1A actions (all read-only): +// • Reveal watched folder in Finder +// • Reveal receipts folder (only when it exists) +// • Copy diagnostic to clipboard +// • Refresh, Quit +// +// Compiled with the shared sources: Config.swift, Authority.swift, +// Receipts.swift, EffectiveState.swift (see scripts/build-app.sh). +// ============================================================================ + +@MainActor +final class StateStore: ObservableObject { + @Published var report: EffectiveStateReport = EffectiveState.compute() + private var timer: Timer? + + func refresh() { report = EffectiveState.compute() } + + func startAutoRefresh() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in + Task { @MainActor in self?.refresh() } + } + } +} + +@main +struct DeskTidyApp: App { + @StateObject private var store = StateStore() + + init() { + // Headless CI smoke: prove the app binary computes the same effective + // state as the CLI, without needing a GUI session. Prints the shared + // model's diagnostic and exits before any Scene is built. + if CommandLine.arguments.contains("--smoke") { + let report = EffectiveState.compute() + print(EffectiveState.diagnostic(report)) + print("SMOKE overall=\(report.overall.rawValue)") + exit(0) + } + } + + var body: some Scene { + MenuBarExtra { + ContentView(store: store) + .onAppear { store.startAutoRefresh() } + } label: { + // Template-rendered SF Symbol: legible on any wallpaper, filled + // triangle/pause variants signal non-healthy states at a glance. + Image(systemName: EffectiveState.menuBarSymbol(for: store.report.overall)) + } + .menuBarExtraStyle(.window) + } +} + +struct ContentView: View { + @ObservedObject var store: StateStore + + private var r: EffectiveStateReport { store.report } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + // -- headline state (fail-closed wording from the shared model) + HStack(spacing: 8) { + Image(systemName: EffectiveState.menuBarSymbol(for: r.overall)) + .foregroundStyle(color(for: r.overall)) + Text(EffectiveState.statusLine(for: r)) + .font(.system(size: 12, weight: .semibold)) + .fixedSize(horizontal: false, vertical: true) + } + + Divider() + + grid + if r.overall == .foreignConflict { + Text("DeskTidy refuses to sort a folder another automation watches. Use that service's own controls, or point DeskTidy at a different folder.") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if r.suggestionsPresent { + Text("Smart-triage suggestions are waiting in Inbox (suggestions only — nothing is moved automatically).") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Divider() + + // -- read-only actions + HStack(spacing: 8) { + Button("Reveal Folder") { reveal(path: r.watchedTarget) } + .disabled(!r.targetExists) + Button("Reveal Receipts") { revealReceipts() } + .disabled(!receiptsExist()) + Button("Copy Diagnostic") { copyDiagnostic() } + } + .controlSize(.small) + + HStack { + Button("Refresh") { store.refresh() }.controlSize(.small) + Spacer() + Text(r.moverVersion).font(.system(size: 10)).foregroundStyle(.tertiary) + Button("Quit") { NSApplication.shared.terminate(nil) }.controlSize(.small) + } + } + .padding(14) + .frame(width: 340) + } + + private var grid: some View { + Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 4) { + GridRow { + Text("Watching").gridLabel() + Text(EffectiveState.shortPath(r.watchedTarget)).gridValue() + } + GridRow { + Text("Authority").gridLabel() + Text(r.effectiveMoverLabel ?? "unprovable").gridValue() + } + GridRow { + Text("Agent").gridLabel() + Text(r.productAgentState).gridValue() + } + GridRow { + Text("Receipts").gridLabel() + Text(r.ledger).gridValue() + } + } + } + + private func color(for state: OverallState) -> Color { + switch state { + case .runningHealthy: return .green + case .pausedNotLoaded: return .secondary + case .foreignConflict, .degradedLedger: return .orange + case .ambiguous: return .yellow + } + } + + // -- read-only actions --------------------------------------------------- + private func reveal(path: String) { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)]) + } + + private func receiptsDir() -> URL { + let env = ProcessInfo.processInfo.environment + let base: URL + if let a = env["DESKTIDY_APP_DIR"], !a.isEmpty { + base = URL(fileURLWithPath: (a as NSString).expandingTildeInPath, isDirectory: true) + } else { + base = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/DeskTidy", isDirectory: true) + } + return base.appendingPathComponent("receipts", isDirectory: true) + } + + private func receiptsExist() -> Bool { + FileManager.default.fileExists(atPath: receiptsDir().appendingPathComponent("ledger.jsonl").path) + } + + private func revealReceipts() { + NSWorkspace.shared.activateFileViewerSelecting( + [receiptsDir().appendingPathComponent("ledger.jsonl")]) + } + + private func copyDiagnostic() { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(EffectiveState.diagnostic(r), forType: .string) + } +} + +private extension Text { + func gridLabel() -> some View { + self.font(.system(size: 10.5, weight: .medium)).foregroundStyle(.secondary) + } + func gridValue() -> some View { + self.font(.system(size: 10.5, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } +} diff --git a/docs/R1B_MIGRATION_SPIKE_CONTRACT.md b/docs/R1B_MIGRATION_SPIKE_CONTRACT.md new file mode 100644 index 0000000..288586f --- /dev/null +++ b/docs/R1B_MIGRATION_SPIKE_CONTRACT.md @@ -0,0 +1,53 @@ +# R1B Spike Contract — SMAppService Migration (NOT IMPLEMENTED — future authorized mission) + +_Written during R1A as required, from what R1A actually discovered. Nothing in +this document is built. It defines the bounded spike that must precede R1B._ + +## What R1A established (inputs to this contract) + +1. The effective-state model derives truth from launchd evidence + canonical + roots + ledger verification — it never trusts plist presence. The migration + must preserve that: registration change may not introduce a second source + of "running" truth. +2. The R0 authority guard treats DeskTidy's own labels (`com.desktidy.sort`, + `com.desktidy.notify`) as self. An SMAppService-registered agent gets a + bundle-scoped label (`com.desktidy.app.*` or the plist name under + `Contents/Library/LaunchAgents`). **Discovery:** the self-label set and the + guard's enumeration must learn the new label(s) atomically with the + migration, or the app would flag itself as a foreign conflict. +3. CLI installs write plists with `DESKTIDY_TARGET_DIR` in + `EnvironmentVariables`; the state model reads the target from there. + SMAppService embeds a static plist inside the bundle — per-user target + selection must move to a config file read by both surfaces + (`~/Library/Application Support/DeskTidy/config.json` is the candidate), + and the state model's target-derivation order must be updated in the same + change, with parity gates extended accordingly. + +## Spike scope (time-boxed, throwaway branch) + +Prove, on an isolated fixture user-context only: +- `SMAppService.agent(plistName:)` register/unregister round-trip; +- resulting launchd label as observed by `launchctl print` (feeds the guard's + self-label set); +- Login Items visibility string; +- coexistence: legacy CLI plists present + SMAppService registration attempted + → must be detected by the authority guard as a same-root duplicate of + ourselves and REFUSED until the legacy plists are removed by the SAME + explicit user action (no silent unload of anything, ever); +- behavior when FDA is granted to the old CLI binary path but the app bundle + binary is new (expect: TCC re-grant needed; document exact UX). + +## Hard conditions carried from R0/R1A + +- Never modify a non-DeskTidy agent. Never take over a root silently. +- The migration path must fail closed at every step; a half-migrated state + must render as `ambiguous` (never healthy) in both surfaces. +- All gates run on fixtures; the live personal mover on the architect's Mac + is out of bounds. +- The A→B→A tamper discipline applies to the migration guard itself. + +## Exit criteria for the spike + +A written report (not code merged to main) answering: final label set, the +config-file schema for target selection, the exact legacy-detection rule, the +TCC/FDA re-grant story, and the parity-gate additions R1B must ship with. diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 0000000..11873ac --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Build the DeskTidy menu-bar app (R1A read-only trust surface) without Xcode +# project files: plain swiftc + a hand-rolled bundle, ad-hoc signed. +# +# scripts/build-app.sh [output-dir] # default: build/ +# +# The app compiles the SHARED state sources (Config, Authority, Receipts, +# EffectiveState) plus app/DeskTidyApp.swift — the same truth the CLI prints +# via `desktidy-sort --effective-state`. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${1:-$REPO/build}" +APP="$OUT/DeskTidy.app" +MACOS_MIN="14.0" + +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +xcrun swiftc -O -parse-as-library \ + -target "arm64-apple-macosx$MACOS_MIN" \ + "$REPO/src/Config.swift" \ + "$REPO/src/Authority.swift" \ + "$REPO/src/Receipts.swift" \ + "$REPO/src/EffectiveState.swift" \ + "$REPO/app/DeskTidyApp.swift" \ + -o "$APP/Contents/MacOS/DeskTidy" + +# Universal note: CI builds the native slice only; release builds add x86_64 +# via lipo when distribution (R4) begins. + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleIdentifiercom.desktidy.app + CFBundleNameDeskTidy + CFBundleExecutableDeskTidy + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.2.0 + CFBundleVersion1 + LSMinimumSystemVersion$MACOS_MIN + LSUIElement + NSHumanReadableCopyrightMIT — github.com/AnubisQuantumCipher/desktidy + + +PLIST + +codesign -s - -i com.desktidy.app --force "$APP" >/dev/null 2>&1 || true +echo "built: $APP" +"$APP/Contents/MacOS/DeskTidy" --smoke 2>/dev/null || true diff --git a/src/Config.swift b/src/Config.swift index 1ce0465..9837ea6 100644 --- a/src/Config.swift +++ b/src/Config.swift @@ -80,3 +80,5 @@ enum Category: CaseIterable { } } } + +enum DeskTidyVersion { static let string = "v1.2.0" } diff --git a/src/DeskTidy.swift b/src/DeskTidy.swift index d4759e5..3953a4e 100644 --- a/src/DeskTidy.swift +++ b/src/DeskTidy.swift @@ -414,7 +414,6 @@ extension DeskTidy { } } -enum DeskTidyVersion { static let string = "v1.2.0" } @main struct DeskTidyMain { diff --git a/src/EffectiveState.swift b/src/EffectiveState.swift index bdfbf70..ab4516f 100644 --- a/src/EffectiveState.swift +++ b/src/EffectiveState.swift @@ -16,8 +16,8 @@ import Foundation // Every unprovable input degrades the overall state — never upgrades it. // // This file (plus Config/Authority/Receipts) is the complete dependency set -// of the menu-bar app. It contains NO mutating operations: no moveItem, -// no removeItem, no bootstrap/bootout, no writes to the ledger. +// of the menu-bar app. It performs no file mutations, no service (un)loading, +// and no ledger writes — enforced by a comment-stripping CI grep. // ============================================================================ /// Overall product state, strictly ordered fail-closed: From c9f88e3f81b255f39b41a6dd276b6ef35982d26a Mon Sep 17 00:00:00 2001 From: AnubisQuantumCipher Date: Fri, 14 Aug 2026 09:14:55 -0400 Subject: [PATCH 3/3] R1A app: portable periodic refresh for the macos-14 toolchain Replace the StateStore Timer/Task capture (rejected by macos-14's stricter concurrency checking) with SwiftUI's own main-isolated Timer.publish + onReceive. No behavior change: refresh on appear + every 15s. Co-Authored-By: Claude Opus 4.8 --- app/DeskTidyApp.swift | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/DeskTidyApp.swift b/app/DeskTidyApp.swift index b500d09..373f722 100644 --- a/app/DeskTidyApp.swift +++ b/app/DeskTidyApp.swift @@ -21,16 +21,8 @@ import SwiftUI @MainActor final class StateStore: ObservableObject { @Published var report: EffectiveStateReport = EffectiveState.compute() - private var timer: Timer? func refresh() { report = EffectiveState.compute() } - - func startAutoRefresh() { - timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in - Task { @MainActor in self?.refresh() } - } - } } @main @@ -52,7 +44,6 @@ struct DeskTidyApp: App { var body: some Scene { MenuBarExtra { ContentView(store: store) - .onAppear { store.startAutoRefresh() } } label: { // Template-rendered SF Symbol: legible on any wallpaper, filled // triangle/pause variants signal non-healthy states at a glance. @@ -64,6 +55,10 @@ struct DeskTidyApp: App { struct ContentView: View { @ObservedObject var store: StateStore + // Periodic re-derivation on the main runloop — SwiftUI keeps the closure + // main-actor isolated, which also satisfies the macOS 14 toolchain's + // stricter concurrency checking (no manual Timer/Task capture). + private let ticker = Timer.publish(every: 15, on: .main, in: .common).autoconnect() private var r: EffectiveStateReport { store.report } @@ -115,6 +110,8 @@ struct ContentView: View { } .padding(14) .frame(width: 340) + .onAppear { store.refresh() } + .onReceive(ticker) { _ in store.refresh() } } private var grid: some View {