Skip to content
Merged
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
13 changes: 7 additions & 6 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,13 @@ store, including case variants and resolved symlink aliases.

## Good contribution areas

The [Project / Asset Model v1 proposal](PROJECT_ASSET_MODEL_V1.md) defines the next
identity/evidence contract, producer boundaries and staged migration. It is design intent
for review, not an implemented common model. Its contract separates collection outcomes,
positive evidence with explicit provenance references, and assessments. The first migration
must produce evidence from a real Work input while preserving the existing UI identity;
the execution boundaries above remain authoritative.
The [Project / Asset Model v1 contract](PROJECT_ASSET_MODEL_V1.md) separates collection
outcomes, positive evidence with explicit provenance references, and assessments. Phase 1
implements pure identity/evidence/snapshot types in `ModoreDomain` and one Work adapter.
Recorded workspace claims retain their session collection run; inferred project mappings
have a separate composition run that changes only when a builder input is published.
Work keeps its legacy IDs, equality and UI behavior. Storage/runtime integration remains
planned, and the execution boundaries above remain authoritative.

- Verified Korean/Japanese application whitelist entries.
- False-positive fixtures that contain no personal scan data.
Expand Down
50 changes: 31 additions & 19 deletions docs/PROJECT_ASSET_MODEL_V1.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Project / Asset Model v1

Status: **Design intent — review before implementation.** The Swift declarations below
describe the first migration contract; they are not currently shipped APIs.
Status: **Phase 1 implemented; later migrations remain design intent.** The declarations
below summarize the contract. The validated public APIs live in `shared/ModoreDomain`;
the Work observations and adapter live in the Mac app. Storage/runtime integration remains planned.

Modore explains the structure and provenance of durable local state, supplies evidence
for protection and recovery decisions, and revalidates every action at a separate
Expand Down Expand Up @@ -55,7 +56,7 @@ public struct AssetIdentity: Hashable, Codable, Sendable {
}

public enum EvidenceSource: String, Codable, Sendable {
case scree, repositoryAssessment, storageScan, processObservation
case scree, projectAttribution, repositoryAssessment, storageScan, processObservation
case fileAccess, sessionBackup, cleanupReceipt, recoveryHistory
}

Expand Down Expand Up @@ -86,7 +87,7 @@ public struct Coverage: Equatable, Codable, Sendable {
}

public enum EvidenceMethod: String, Codable, Sendable {
case recordedWorkspace, knownRootAncestry, conventionalWorktreePath
case recordedWorkspace, knownRootAncestry, conventionalWorktreePath, workspaceFallback
case gitRegistry, pathAncestry, projectManifest
case processWorkingDirectory, processOpenFile
case transcriptReadInvocation, transcriptWriteInvocation, transcriptShellReference
Expand All @@ -111,7 +112,11 @@ public struct ProducerFailure: Equatable, Codable, Sendable {
public let detail: String // Display only; never an assessment input.
}

public struct ProducerSnapshot<Record: Codable & Sendable>: Codable, Sendable {
public protocol EvidenceBackedRecord: Codable, Sendable {
var evidence: EvidenceRecord { get }
}

public struct ProducerSnapshot<Record: EvidenceBackedRecord>: Codable, Sendable {
public let producer: EvidenceSource
public let runID: String
public let schemaVersion: Int
Expand Down Expand Up @@ -233,20 +238,27 @@ executable plan or validated target.
## Producer contract

Keep producer implementations and composition in app `Services/`; put only pure schema
and semantics in `shared/ModoreDomain`. The first Work adapter consumes an actual
`SessionIndexEntry` and the existing builder's resolved project key. It emits a typed Work
workspace observation with the logical session identity, recorded workspace, project identity
and `EvidenceRecord` using `recordedWorkspace + recordedClaim`. If the builder maps that
workspace to an ancestor or conventional worktree root, represent that mapping separately
as inferred evidence rather than asserting the session recorded that root. Reuse the
builder's result and matching rule, without changing its grouping algorithm. Missing
workspace produces no membership evidence and stays in the existing unassigned bucket.

Phase 1 uses `ProducerSnapshot<WorkWorkspaceObservation>` at this adapter boundary.
The existing collection invocation supplies a run ID that survives re-adaptation/rendering;
the adapter derives record IDs deterministically within that run. It does not request
another scan or invent missing timestamps. Observation payloads and adapter code stay in
the app; only the envelope and pure value types live in `ModoreDomain`.
and semantics in `shared/ModoreDomain`. The Work adapter emits separate records:
`WorkWorkspaceObservation` carries the provider's recorded workspace with `.scree` provenance;
`WorkProjectAttribution` carries the builder's inferred project mapping with
`.projectAttribution` provenance and its input observation. An unknown workspace produces
neither and stays in the unassigned bucket. Physical artifact identity remains explicit
when no logical provider ID exists.

Each accepted session-index publication gets a collection run ID. Each publication of any
builder input (session index, scree report, repository assessments, failures or unscanned roots)
also gets a new composition run ID and retains the input revision tuple. Root-only changes
never relabel the session observation. `WorkProvenance` creates IDs at these publication
boundaries, not in `workProjects`, adapters or SwiftUI bodies. Task generations remain
separate stale-write controls. Failed/cancelled loads cannot re-stamp retained facts.

The adapter emits separate `ProducerSnapshot<WorkWorkspaceObservation>` and
`ProducerSnapshot<WorkProjectAttribution>` envelopes. Record IDs identify input positions
within immutable runs; repeated adaptation retains references. The collector timestamp is
unknown when absent from the DTO; composition time describes the actual inference. The
existing builder supplies both the root and the matching rule, including an explicitly
inferred workspace fallback. Work rows attach the result without changing their legacy
String IDs, equality, sorting, selection or action behavior.

Snapshots distinguish collection outcome from positive records. Failed/not-collected/unknown
attempts emit no current positive records; recovered valid records from a run interrupted by
Expand Down
40 changes: 40 additions & 0 deletions macos/Modore/Sources/Modore/Models/WorkEvidence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation
import ModoreDomain

/// Revisions of the published inputs to WorkProjectBuilder, not task generations.
enum WorkInput: String, Codable, Sendable { case sessionIndex, screeReport, repoAssessments, repoScanFailures, reposNotScanned }

struct WorkProvenance {
private(set) var inputRuns: [WorkInput: String] = [:]
private(set) var compositionRunID = UUID().uuidString
private(set) var composedAt = Date()

/// Called only when an input is published, never by a getter or SwiftUI body.
/// Even a same-valued newly published snapshot is a distinct observation run.
mutating func didPublish(_ input: WorkInput) {
inputRuns[input] = UUID().uuidString
compositionRunID = UUID().uuidString
composedAt = Date()
}
}

/// What the provider recorded. Deliberately contains no inferred project identity.
struct WorkWorkspaceObservation: EvidenceBackedRecord, Equatable {
let session: AssetIdentity
let recordedWorkspace: String
let evidence: EvidenceRecord
}

/// What Modore infers from the currently published combination of Work inputs.
/// The input claim keeps its original collection reference and timestamp.
struct WorkProjectAttribution: EvidenceBackedRecord, Equatable {
let workspace: WorkWorkspaceObservation
let project: ProjectIdentity
let inputRuns: [WorkInput: String]
let evidence: EvidenceRecord
}

struct WorkEvidence {
let metadata: ProducerSnapshot<WorkWorkspaceObservation>?
let attribution: ProducerSnapshot<WorkProjectAttribution>
}
35 changes: 27 additions & 8 deletions macos/Modore/Sources/Modore/Models/WorkModels.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import MothballCore
import ModoreDomain

/// One title for one session, as `scree.py titles` returns it.
struct SessionTitle: Decodable, Equatable {
Expand Down Expand Up @@ -30,6 +31,8 @@ struct WorkProject: Identifiable {
let path: String
var sessions: [SessionIndexEntry] = []
var worktrees: [ScreeWorktreeItem] = []
/// Display-only evidence; deliberately excluded from legacy row equality and IDs.
var workspaceAttributions: [WorkProjectAttribution] = []

/// What is known about this project's git state -- including the two
/// cases where the answer is that nothing is known.
Expand Down Expand Up @@ -58,6 +61,10 @@ struct WorkProject: Identifiable {

var id: String { WorkProjectBuilder.canonical(path) }

var identity: ProjectIdentity? {
isUnassigned ? nil : try? ProjectIdentity(comparisonKey: id)
}

/// Conversations that exist but could not be placed in a project.
var isUnassigned: Bool { path == WorkProjectBuilder.unassignedID }

Expand Down Expand Up @@ -258,10 +265,7 @@ enum WorkProjectBuilder {
scanFailures: [String: String] = [:],
notScanned: [String] = []
) -> [WorkProject] {
var roots: Set<String> = []
for assessment in assessments { roots.insert(canonical(assessment.pathText)) }
for worktree in worktrees { roots.insert(canonical(worktree.repo)) }
for root in gitRoots { roots.insert(canonical(root)) }
let roots = knownRoots(worktrees: worktrees, assessments: assessments, gitRoots: gitRoots)

var projects: [String: WorkProject] = [:]
// Case-folded key, real spelling for display. macOS filesystems are
Expand Down Expand Up @@ -324,6 +328,15 @@ enum WorkProjectBuilder {
}
}

static func knownRoots(worktrees: [ScreeWorktreeItem], assessments: [ArchiveCandidate],
gitRoots: [String]) -> Set<String> {
var roots: Set<String> = []
for assessment in assessments { roots.insert(canonical(assessment.pathText)) }
for worktree in worktrees { roots.insert(canonical(worktree.repo)) }
for root in gitRoots { roots.insert(canonical(root)) }
return roots
}

/// The project a workspace belongs to.
///
/// Agent worktrees are folded into their repo even when no scanner has
Expand All @@ -332,6 +345,12 @@ enum WorkProjectBuilder {
/// confirm it would leave the list looking like a pile of adjectives
/// and surnames.
static func projectRoot(for workspace: String, roots: Set<String>) -> String {
projectMapping(for: workspace, roots: roots).path
}

/// The same legacy algorithm, returning the rule used as well as its result.
/// This is attribution evidence, never a second filesystem/Git observation.
static func projectMapping(for workspace: String, roots: Set<String>) -> (path: String, method: EvidenceMethod) {
let normalized = normalize(workspace)
let folded = canonical(workspace)
var best: String?
Expand All @@ -346,15 +365,15 @@ enum WorkProjectBuilder {
if let best {
// Return the caller's own spelling when the match is the whole
// path, so a project keeps the casing it was recorded with.
return folded == best ? normalized : String(normalized.prefix(best.count))
return (folded == best ? normalized : String(normalized.prefix(best.count)), .knownRootAncestry)
}
for marker in ["/.claude/worktrees/", "/.git/worktrees/"] {
if let range = folded.range(of: marker) {
return String(normalized.prefix(folded.distance(
from: folded.startIndex, to: range.lowerBound)))
return (String(normalized.prefix(folded.distance(
from: folded.startIndex, to: range.lowerBound))), .conventionalWorktreePath)
}
}
return normalized
return (normalized, .workspaceFallback)
}

/// The comparison key: trailing slash removed, case folded.
Expand Down
25 changes: 20 additions & 5 deletions macos/Modore/Sources/Modore/Services/MothballService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,18 +360,33 @@ extension ScanModel {
/// Everything the Work screen shows, assembled from the three
/// scanners that used to own a screen each.
var workProjects: [WorkProject] {
WorkProjectBuilder.build(
let worktrees = screeReport?.worktreeItems ?? []
let assessments = repoAssessments ?? []
let gitRoots = (screeReport?.lineagePaths ?? []).filter { $0.hasGit == true }.map(\.path)
var projects = WorkProjectBuilder.build(
sessions: sessionIndex?.sessions ?? [],
worktrees: screeReport?.worktreeItems ?? [],
assessments: repoAssessments ?? [],
worktrees: worktrees,
assessments: assessments,
// Every git path the audit saw, so project identity does not
// depend on which repos survived the archive classifier or the
// scanner's own root limit.
gitRoots: (screeReport?.lineagePaths ?? [])
.filter { $0.hasGit == true }.map(\.path),
gitRoots: gitRoots,
scanFailures: repoScanFailures,
notScanned: reposNotScanned
)
// Evidence cannot influence the established UI grouping, equality, selection
// or action paths. An adaptation failure leaves those rows intact.
if let evidence = try? WorkWorkspaceAdapter.adapt(
index: sessionIndex,
roots: WorkProjectBuilder.knownRoots(worktrees: worktrees, assessments: assessments, gitRoots: gitRoots),
provenance: workProvenance
) {
let byProject = Dictionary(grouping: evidence.attribution.records, by: { $0.project.comparisonKey })
for index in projects.indices {
projects[index].workspaceAttributions = byProject[projects[index].id] ?? []
}
}
return projects
}

/// Loads what the Work screen needs the moment someone opens it.
Expand Down
21 changes: 16 additions & 5 deletions macos/Modore/Sources/Modore/Services/ScanModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,10 @@ final class ScanModel: ObservableObject {
@Published private(set) var deepScanFailure: DeepScanFailure?
@Published private(set) var deepScanAt: Date?
@Published private(set) var cleanupMutationPending = false
@Published var screeReport: ScreeReport?
private(set) var workProvenance = WorkProvenance()
@Published var screeReport: ScreeReport? {
didSet { workProvenance.didPublish(.screeReport) }
}
@Published var screeReportRevision = 0
@Published var screeLoading = false
@Published var screeError: String?
Expand All @@ -222,20 +225,28 @@ final class ScanModel: ObservableObject {
/// Every repo the scan judged, archivable or not. Named for what it
/// is: `rankCandidates` drops `.unsafe` repos, which is right for a
/// retirement list and wrong as a project's git state.
@Published var repoAssessments: [ArchiveCandidate]?
@Published var repoAssessments: [ArchiveCandidate]? {
didSet { workProvenance.didPublish(.repoAssessments) }
}
/// Repo path to why it could not be read, and the repos the scan's
/// root cap never reached. Both travel per path: a total says
/// something somewhere is untrustworthy, only a path says which row.
@Published var repoScanFailures: [String: String] = [:]
@Published var reposNotScanned: [String] = []
@Published var repoScanFailures: [String: String] = [:] {
didSet { workProvenance.didPublish(.repoScanFailures) }
}
@Published var reposNotScanned: [String] = [] {
didSet { workProvenance.didPublish(.reposNotScanned) }
}
/// Conversations a person opened, and how each fetch went, keyed by
/// the transcript's byte identity rather than its path -- see
/// `loadConversation`. Display-only cache; never consulted by any
/// judgment.
@Published var conversationLoads: [String: ConversationLoadState] = [:]
var conversationLoadTokens: [String: UUID] = [:]
/// The session browser's index, and how fetching it went.
@Published var sessionIndex: SessionIndex?
@Published var sessionIndex: SessionIndex? {
didSet { workProvenance.didPublish(.sessionIndex) }
}
@Published var sessionIndexLoading = false
@Published var sessionIndexError: String?
var sessionIndexGeneration = 0
Expand Down
Loading