Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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
Expand Down
190 changes: 190 additions & 0 deletions app/DeskTidyApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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()

func refresh() { report = EffectiveState.compute() }
}

@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)
} 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
// 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 }

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)
.onAppear { store.refresh() }
.onReceive(ticker) { _ in store.refresh() }
}

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)
}
}
53 changes: 53 additions & 0 deletions docs/R1B_MIGRATION_SPIKE_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions scripts/build-app.sh
Original file line number Diff line number Diff line change
@@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key><string>com.desktidy.app</string>
<key>CFBundleName</key><string>DeskTidy</string>
<key>CFBundleExecutable</key><string>DeskTidy</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.2.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSMinimumSystemVersion</key><string>$MACOS_MIN</string>
<key>LSUIElement</key><true/>
<key>NSHumanReadableCopyright</key><string>MIT — github.com/AnubisQuantumCipher/desktidy</string>
</dict>
</plist>
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
Loading
Loading