From 226b6e26be08a498f884534e2a49708f06b47baa Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 14:07:40 +0200 Subject: [PATCH] M9 #4.2: project-scoped chat + shared SFTP parity services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScarfGo now supports the Mac app's project-chat flow end-to-end. Tapping + in Chat opens a sheet with two options: 1. Quick chat — cwd = $HOME (previous default). 2. In project… — pick from the remote Hermes's project registry, spawn hermes acp with cwd = project.path, record the attribution. Shared infrastructure for the SFTP parity (so Mac + ScarfGo use the exact same record types + persistence logic): - SessionProjectMap — moved from scarf/scarf/Core/Models/ to ScarfCore. Public struct. Mac consumer unchanged (imports it via ScarfCore now). - SessionAttributionService — moved from Mac target to ScarfCore. Was already transport-backed, so the port is straight lift-and- shift: made public, added #if canImport(os) guards around the Logger imports for Linux CI. Mac ChatViewModel and ProjectSessions VM still call it the same way. - ProjectContextBlock — new ScarfCore-level primitive that owns the marker-splice logic for the Scarf-managed region of AGENTS.md: - applyBlock(_:to:) — pure text splice with 3-case handling. - writeBlock(_:forProjectAt:context:) — transport-backed write. - renderMinimalBlock(projectName:projectPath:) — iOS-side block composer (no template-manifest or cron-attribution fields — iOS doesn't yet surface those concepts; markers + identity headers match Mac output byte-for-byte so a project scaffolded on iOS round-trips cleanly through the Mac). Mac's ProjectAgentContextService stays in place — still the richer block renderer (template manifest + cron jobs) — but it now forwards beginMarker/endMarker/applyBlock to ProjectContextBlock so both platforms share invariant strings and splice logic. Duplicate implementations were a recipe for drift. ScarfGo side: - Chat/ProjectPickerSheet.swift — two-section sheet (Quick chat / In project…). Loads the project list over SFTP via ProjectDashboardService (already transport-backed, works on iOS). Archived projects hidden (matching Mac sidebar behaviour). - ChatController.resetAndStartInProject(_:) — stops the current session, writes the minimal context block to /AGENTS.md over SFTP, spawns hermes acp with cwd = project.path, records the attribution via SessionAttributionService. Non-fatal on block- write failure (chat still starts). - ChatController.startInternal(...) — refactored to take an optional projectPath + projectName, so the regular start() and the new project path share one ACP setup path. Attribution write happens after newSession returns and the sessionId is known. Project chip in the chat nav bar is deferred — on-the-go users know they just picked a project in the sheet, the chip is polish we can add post-TestFlight. Both schemes build green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore}/Models/SessionProjectMap.swift | 23 +-- .../Services/ProjectContextBlock.swift | 144 ++++++++++++++++++ .../Services/SessionAttributionService.swift | 58 ++++--- scarf/Scarf iOS/Chat/ChatView.swift | 122 ++++++++++++++- scarf/Scarf iOS/Chat/ProjectPickerSheet.swift | 135 ++++++++++++++++ .../Services/ProjectAgentContextService.swift | 49 +----- 6 files changed, 441 insertions(+), 90 deletions(-) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Models/SessionProjectMap.swift (59%) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Services/SessionAttributionService.swift (62%) create mode 100644 scarf/Scarf iOS/Chat/ProjectPickerSheet.swift diff --git a/scarf/scarf/Core/Models/SessionProjectMap.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/SessionProjectMap.swift similarity index 59% rename from scarf/scarf/Core/Models/SessionProjectMap.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/SessionProjectMap.swift index 55442d7..9ccf33c 100644 --- a/scarf/scarf/Core/Models/SessionProjectMap.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/SessionProjectMap.swift @@ -14,21 +14,14 @@ import Foundation /// compatible: if Hermes ever gains a canonical `cwd` column, Scarf /// can prefer that and fall back to this file for pre-upgrade /// sessions. Missing file → empty map (nothing attributed yet). -struct SessionProjectMap: Codable, Sendable { - /// session-id → absolute-project-path. Both strings are opaque - /// from this file's perspective; the service validates project - /// paths against the live registry when building the reverse - /// lookup used by the Sessions tab, so stale entries for - /// removed projects are ignored at read time without needing a - /// write-side cleanup. - var mappings: [String: String] +/// +/// Promoted to ScarfCore in M9 #4.2 so iOS can use the same record +/// type — ScarfGo's project-scoped chat writes here over SFTP. +public struct SessionProjectMap: Codable, Sendable { + public var mappings: [String: String] + public var updatedAt: String? - /// ISO-8601 timestamp of the most recent write. Informational - /// only — not used for any decision logic. Useful when debugging - /// a stale sidecar ("when was this last updated?"). - var updatedAt: String? - - init(mappings: [String: String] = [:], updatedAt: String? = nil) { + public init(mappings: [String: String] = [:], updatedAt: String? = nil) { self.mappings = mappings self.updatedAt = updatedAt } @@ -37,7 +30,7 @@ struct SessionProjectMap: Codable, Sendable { /// `updatedAt` field. Matches the format used elsewhere in /// Scarf (e.g. `TemplateLock.installedAt`) so tooling that /// greps across .json files sees consistent timestamps. - static func nowISO8601() -> String { + public static func nowISO8601() -> String { ISO8601DateFormatter().string(from: Date()) } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift new file mode 100644 index 0000000..6106916 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectContextBlock.swift @@ -0,0 +1,144 @@ +import Foundation +#if canImport(os) +import os +#endif + +/// Pure block-splice logic for the Scarf-managed region of a project's +/// `/AGENTS.md`. Shared by Mac (which wraps it in +/// `ProjectAgentContextService` with template-manifest + cron-aware +/// block rendering) and ScarfGo (which renders a simpler block and +/// writes it over SFTP). +/// +/// The marker contract is a cross-platform invariant — both apps must +/// produce byte-identical markers so a Mac-scaffolded block round-trips +/// through iOS and vice-versa without either side treating the other's +/// content as "missing markers." +public enum ProjectContextBlock { + + /// Load-bearing across releases. Do not change these strings + /// without a coordinated migration — existing project AGENTS.md + /// files on disk carry them. + public static let beginMarker = "" + public static let endMarker = "" + + /// Errors surfaced by writers. Narrow set — most callers just log + /// and continue; a missing project-context block is a polish + /// degradation, not a chat-start blocker. + public enum WriteError: Error, LocalizedError { + case encodingFailed + public var errorDescription: String? { + switch self { + case .encodingFailed: return "Couldn't encode AGENTS.md block as UTF-8" + } + } + } + + /// Splice `block` into `existing`, preserving everything outside + /// the markers. Three cases: + /// 1. `existing` has both markers → replace inclusive region. + /// 2. `existing` has no markers → prepend block + blank line. + /// 3. `existing` has only a begin marker → prepend (don't guess). + public static func applyBlock(_ block: String, to existing: String) -> String { + guard let beginRange = existing.range(of: beginMarker), + let endRange = existing.range( + of: endMarker, + range: beginRange.upperBound../AGENTS.md`, splice in the given block, write + /// back — all via the provided context's transport. Idempotent on + /// identical inputs. + /// + /// Called by ScarfGo's ChatController.startNewSession when the + /// user picks "In project…". Mac's ProjectAgentContextService is + /// a richer wrapper that constructs the block first, but the + /// persistence step uses the same splice logic under the hood. + public static func writeBlock( + _ block: String, + forProjectAt projectPath: String, + context: ServerContext + ) throws { + let transport = context.makeTransport() + let agentsMdPath = projectPath + "/AGENTS.md" + + if !transport.fileExists(projectPath) { + try transport.createDirectory(projectPath) + } + + if !transport.fileExists(agentsMdPath) { + let data = (block + "\n").data(using: .utf8) ?? Data() + try transport.writeFile(agentsMdPath, data: data) + return + } + + let existingData = try transport.readFile(agentsMdPath) + let existing = String(data: existingData, encoding: .utf8) ?? "" + let rewritten = applyBlock(block, to: existing) + guard let outData = rewritten.data(using: .utf8) else { + throw WriteError.encodingFailed + } + guard outData != existingData else { return } + try transport.writeFile(agentsMdPath, data: outData) + } + + /// Render a minimal Scarf-managed block for iOS ScarfGo usage. + /// Omits the template-manifest + cron-job sections that the Mac + /// service fills in — ScarfGo v1 doesn't surface those concepts + /// yet. The marker + identity headers match the Mac output byte- + /// for-byte where the content overlaps, so a project scaffolded + /// on iOS round-trips cleanly through the Mac. + public static func renderMinimalBlock(projectName: String, projectPath: String) -> String { + var lines: [String] = [] + lines.append(beginMarker) + lines.append("## Scarf project context") + lines.append("") + lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._") + lines.append("") + lines.append("You are operating inside a Scarf project named **\"\(projectName)\"**. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.") + lines.append("") + lines.append("- **Project directory:** `\(projectPath)`") + lines.append("- **Dashboard:** `\(projectPath)/.scarf/dashboard.json`") + lines.append("") + lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.") + lines.append(endMarker) + return lines.joined(separator: "\n") + } + + // MARK: - Private + + private static func trimmingRightNewlines(_ s: String) -> String { + var result = s + while let last = result.last, last.isNewline { + result.removeLast() + } + return result + } + + private static func trimmingLeftNewlines(_ s: String) -> String { + var result = s + while let first = result.first, first.isNewline { + result.removeFirst() + } + return result + } +} diff --git a/scarf/scarf/Core/Services/SessionAttributionService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SessionAttributionService.swift similarity index 62% rename from scarf/scarf/Core/Services/SessionAttributionService.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SessionAttributionService.swift index 83ec8d9..499edf2 100644 --- a/scarf/scarf/Core/Services/SessionAttributionService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SessionAttributionService.swift @@ -1,12 +1,12 @@ import Foundation +#if canImport(os) import os -import ScarfCore +#endif /// Owns the sidecar that attributes Hermes session IDs to Scarf -/// project paths. The `cwd` passed to `hermes acp` at session -/// creation is ephemeral from Hermes's perspective (not written to -/// `state.db`), so Scarf keeps this Scarf-owned record parallel to -/// Hermes's session store. +/// project paths. Promoted to ScarfCore in M9 #4.2 so ScarfGo can +/// write project attributions over SFTP — the whole service is +/// transport-based, so Mac and iOS share the same code path. /// /// File: `~/.hermes/scarf/session_project_map.json` (resolved via /// `HermesPathSet.sessionProjectMap`). @@ -16,14 +16,15 @@ import ScarfCore /// disk. Concurrent writers (two Scarf windows on the same /// `~/.hermes`) are safe at the file level — last write wins — /// but the in-memory read in one window may lag until that window -/// reloads. Acceptable for v2.3's scale; revisit if multi-window -/// cross-talk becomes a problem. -struct SessionAttributionService: Sendable { +/// reloads. +public struct SessionAttributionService: Sendable { + #if canImport(os) private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService") + #endif - let context: ServerContext + public let context: ServerContext - nonisolated init(context: ServerContext = .local) { + public nonisolated init(context: ServerContext = .local) { self.context = context } @@ -32,7 +33,7 @@ struct SessionAttributionService: Sendable { /// Load the current sidecar contents. Missing file or unparseable /// JSON returns an empty map — the sidecar is a convenience /// index, not a source of truth for anything load-bearing. - nonisolated func load() -> SessionProjectMap { + public nonisolated func load() -> SessionProjectMap { let path = context.paths.sessionProjectMap let transport = context.makeTransport() guard transport.fileExists(path) else { @@ -42,26 +43,22 @@ struct SessionAttributionService: Sendable { let data = try transport.readFile(path) return try JSONDecoder().decode(SessionProjectMap.self, from: data) } catch { + #if canImport(os) Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map") + #endif return SessionProjectMap() } } /// Look up the project path a given session was attributed to. - /// Returns nil for unattributed sessions (CLI-started, or - /// started before v2.3) — those surface in the global Sessions - /// sidebar unchanged and don't appear in any project's Sessions - /// tab. - nonisolated func projectPath(for sessionID: String) -> String? { + /// Returns nil for unattributed sessions. + public nonisolated func projectPath(for sessionID: String) -> String? { load().mappings[sessionID] } /// Reverse lookup: every session ID attributed to the given - /// project path. Used by the per-project Sessions tab to filter - /// the global session list. Comparison is exact-string; the - /// registry stores absolute paths and we write absolute paths, - /// so no normalisation is needed in practice. - nonisolated func sessionIDs(forProject projectPath: String) -> Set { + /// project path. + public nonisolated func sessionIDs(forProject projectPath: String) -> Set { let map = load() return Set(map.mappings.filter { $0.value == projectPath }.keys) } @@ -69,11 +66,8 @@ struct SessionAttributionService: Sendable { // MARK: - Write /// Record that `sessionID` was created under the given project - /// path. Idempotent — repeated calls for the same pair are no- - /// ops. Replacing an existing mapping (session moved to a - /// different project) is legal but expected to be rare; the - /// caller decides when that's correct. - nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) { + /// path. Idempotent. + public nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) { var map = load() if map.mappings[sessionID] == projectPath { return @@ -83,12 +77,10 @@ struct SessionAttributionService: Sendable { persist(map) } - /// Remove a mapping. Called in v2.3's Sessions-tab code path is - /// minimal — we don't currently prune on session delete because - /// Hermes owns session lifecycle and we don't observe deletes. - /// Exposed for future roadmap items (e.g. explicit "detach - /// from project" action) and tests. - nonisolated func forget(sessionID: String) { + /// Remove a mapping. Exposed for future "detach from project" + /// UIs and tests; today's Mac + iOS call sites don't invoke it + /// because Hermes owns session lifecycle. + public nonisolated func forget(sessionID: String) { var map = load() guard map.mappings.removeValue(forKey: sessionID) != nil else { return } map.updatedAt = SessionProjectMap.nowISO8601() @@ -110,7 +102,9 @@ struct SessionAttributionService: Sendable { let data = try encoder.encode(map) try transport.writeFile(path, data: data) } catch { + #if canImport(os) Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)") + #endif } } } diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index fb6b5f1..47fa4a3 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -23,6 +23,7 @@ struct ChatView: View { @Environment(\.scarfGoCoordinator) private var coordinator @Environment(\.serverContext) private var envContext @State private var controller: ChatController + @State private var showProjectPicker = false init(config: IOSServerConfig, key: SSHKeyBundle) { self.config = config @@ -50,13 +51,24 @@ struct ChatView: View { .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { - Task { await controller.resetAndStartNewSession() } + showProjectPicker = true } label: { Image(systemName: "plus.bubble") } .disabled(controller.state == .connecting) } } + .sheet(isPresented: $showProjectPicker) { + ProjectPickerSheet( + context: config.toServerContext(id: Self.sharedContextID), + onQuickChat: { + Task { await controller.resetAndStartNewSession() } + }, + onProject: { project in + Task { await controller.resetAndStartInProject(project) } + } + ) + } .task { // Dashboard row taps set `pendingResumeSessionID` on the // coordinator before switching to the Chat tab. Honor @@ -453,6 +465,114 @@ final class ChatController { await start() } + /// User tapped "In project… ". Stop, reset, and start + /// with the project's path as cwd. Writes the Scarf-managed + /// AGENTS.md block via ProjectContextBlock BEFORE spawning `hermes + /// acp`, so Hermes sees the project context at boot. Records the + /// returned session id in the attribution sidecar. + func resetAndStartInProject(_ project: ProjectEntry) async { + await stop() + vm.reset() + // Write the context block first. Non-fatal on failure — chat + // still starts, just without the managed block; the user sees + // the error via controller.state if it escalates. + let block = ProjectContextBlock.renderMinimalBlock( + projectName: project.name, + projectPath: project.path + ) + let ctx = context + await Task.detached { + try? ProjectContextBlock.writeBlock( + block, + forProjectAt: project.path, + context: ctx + ) + }.value + await start(projectPath: project.path, projectName: project.name) + } + + /// Inline variant of `start()` that accepts a cwd + attribution + /// hooks. The default `start()` delegates to this with nil project + /// fields, so the ACP code path stays single-sourced. + private func startInternal( + projectPath: String?, + projectName: String? + ) async { + if state == .connecting || state == .ready { return } + state = .connecting + let client = ACPClient.forIOSApp( + context: context, + keyProvider: { + let store = KeychainSSHKeyStore() + guard let key = try await store.load() else { + throw SSHKeyStoreError.backendFailure( + message: "No SSH key in Keychain — re-run onboarding.", + osStatus: nil + ) + } + return key + } + ) + self.client = client + vm.acpStderrProvider = { [weak client] in + await client?.recentStderr ?? "" + } + + do { + try await client.start() + } catch { + state = .failed(error.localizedDescription) + await vm.recordACPFailure(error, client: client) + return + } + + let stream = await client.events + eventTask = Task { [weak self] in + for await event in stream { + guard let self else { break } + await MainActor.run { + self.vm.handleACPEvent(event) + } + } + } + + do { + // Use the project's path as cwd when provided; else the + // remote user's home, matching the pre-M9 default. + let cwd: String + if let projectPath { + cwd = projectPath + } else { + cwd = await context.resolvedUserHome() + } + let sessionId = try await client.newSession(cwd: cwd) + vm.setSessionId(sessionId) + state = .ready + + // If this was a project-scoped session, record the + // attribution so the Mac's per-project Sessions tab picks + // it up. Best-effort — ACP session creation already won, + // a failed attribution write is cosmetic. + if let projectPath { + let ctx = context + Task.detached { + SessionAttributionService(context: ctx) + .attribute(sessionID: sessionId, toProjectPath: projectPath) + } + } + _ = projectName // reserved for future chat-header chip + } catch { + state = .failed(error.localizedDescription) + await vm.recordACPFailure(error, client: client) + await stop() + } + } + + /// Public entry used internally by resetAndStartInProject. + func start(projectPath: String, projectName: String) async { + await startInternal(projectPath: projectPath, projectName: projectName) + } + /// Resume an existing ACP session. Called from ChatView when the /// coordinator carries a `pendingResumeSessionID` (Dashboard row /// tap). If we're currently on a different session, stop first diff --git a/scarf/Scarf iOS/Chat/ProjectPickerSheet.swift b/scarf/Scarf iOS/Chat/ProjectPickerSheet.swift new file mode 100644 index 0000000..53b79ed --- /dev/null +++ b/scarf/Scarf iOS/Chat/ProjectPickerSheet.swift @@ -0,0 +1,135 @@ +import SwiftUI +import ScarfCore + +/// Sheet presented from ChatView's "+" toolbar button. Offers two +/// modes: +/// - **Quick chat** — starts with `cwd = $HOME`, no project attribution. +/// The current default behavior. +/// - **In project…** — lets the user pick a registered project. On +/// confirm, the caller is handed back the project path so it can +/// (1) write the Scarf-managed AGENTS.md block via +/// `ProjectContextBlock.writeBlock` and (2) spawn `hermes acp` with +/// `cwd = project.path`, then attribute the resulting session. +/// +/// The project list is loaded from the remote Hermes's +/// `~/.hermes/scarf/projects.json` via the shared +/// `ProjectDashboardService` (transport-backed, so SFTP works). +struct ProjectPickerSheet: View { + let context: ServerContext + let onQuickChat: () -> Void + let onProject: (ProjectEntry) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var projects: [ProjectEntry] = [] + @State private var isLoading: Bool = true + @State private var loadError: String? + + var body: some View { + NavigationStack { + List { + Section { + Button { + onQuickChat() + dismiss() + } label: { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "bolt.horizontal.circle.fill") + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text("Quick chat") + .font(.body) + .fontWeight(.medium) + .foregroundStyle(.primary) + Text("No project — agent runs in your home directory.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.plain) + .scarfGoCompactListRow() + } + + Section("In project…") { + if isLoading { + HStack { Spacer(); ProgressView(); Spacer() } + .padding(.vertical, 8) + } else if let err = loadError { + Label(err, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.caption) + } else if projects.isEmpty { + Text("No Scarf projects registered yet. Create one in the Mac app's Projects sidebar.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(sortedVisibleProjects) { project in + Button { + onProject(project) + dismiss() + } label: { + HStack(alignment: .center, spacing: 12) { + Image(systemName: "folder.fill") + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(project.name) + .font(.body) + .foregroundStyle(.primary) + Text(project.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .buttonStyle(.plain) + .scarfGoCompactListRow() + } + } + } + } + .scarfGoListDensity() + .navigationTitle("New chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { dismiss() } + } + } + .task { await loadProjects() } + } + .presentationDetents([.height(320), .large]) + .presentationDragIndicator(.visible) + } + + /// Hide archived projects from the picker (they're deliberately + /// out-of-sight on the Mac sidebar; honor that on iOS too). + /// Sort alphabetically for predictability. + private var sortedVisibleProjects: [ProjectEntry] { + projects + .filter { !$0.archived } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + /// Load the project registry over SFTP via the shared + /// `ProjectDashboardService`. Transport-backed, so on iOS this + /// reads `~/.hermes/scarf/projects.json` through the open Citadel + /// connection. A missing/empty registry isn't an error — just + /// means no projects are configured yet. + private func loadProjects() async { + isLoading = true + defer { isLoading = false } + let ctx = context + let loaded: [ProjectEntry] = await Task.detached { + let service = ProjectDashboardService(context: ctx) + return service.loadRegistry().projects + }.value + projects = loaded + } +} diff --git a/scarf/scarf/Core/Services/ProjectAgentContextService.swift b/scarf/scarf/Core/Services/ProjectAgentContextService.swift index 1f39b29..72ebe94 100644 --- a/scarf/scarf/Core/Services/ProjectAgentContextService.swift +++ b/scarf/scarf/Core/Services/ProjectAgentContextService.swift @@ -41,11 +41,10 @@ import ScarfCore struct ProjectAgentContextService: Sendable { private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService") - /// Marker strings. Load-bearing: the format must stay stable - /// across releases so existing project AGENTS.md files continue - /// to be recognized and rewritten cleanly. - static let beginMarker = "" - static let endMarker = "" + /// Marker strings. Delegated to ScarfCore's `ProjectContextBlock` + /// in M9 #4.2 so both Mac and ScarfGo use identical markers. + static let beginMarker = ProjectContextBlock.beginMarker + static let endMarker = ProjectContextBlock.endMarker let context: ServerContext @@ -115,44 +114,10 @@ struct ProjectAgentContextService: Sendable { /// truncate to EOF (as the memory-block installer does) /// because an orphaned begin on this file is more likely /// hand-typed than a corrupt Scarf write. + /// Kept as a thin forwarder so pre-existing callers + tests keep + /// working. The logic lives in ScarfCore now (M9 #4.2). nonisolated static func applyBlock(block: String, to existing: String) -> String { - guard let beginRange = existing.range(of: beginMarker), - let endRange = existing.range( - of: endMarker, - range: beginRange.upperBound..