From 5b1481f33f4ed23256f05edccd55a7b6a23b9861 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 24 Apr 2026 01:05:15 +0200 Subject: [PATCH] feat(projects): Scarf-managed project-context block in AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes has no native "project" concept and the ACP wire protocol drops extra params at `session/new`. But Hermes DOES auto-read AGENTS.md from the session's cwd at startup (research confirmed: priority order `.hermes.md` → `HERMES.md` → AGENTS.md → CLAUDE.md → .cursorrules; 20KB cap; first match wins). So the agent- awareness path is file-based, not protocol-based. This commit adds `ProjectAgentContextService` — a one-job service that writes a Scarf-managed block into `/AGENTS.md` between `` and `` markers. Same pattern as the v2.2 memory-block appendix: bounded, self-declaring, re-generable, safe on hand-authored content outside the markers. ## Block contents - Project name (from registry) - Project directory path - Dashboard.json path - Template id + version (when template-installed) - Configuration field NAMES with type hints — never VALUES. Secrets always render as `field_key (secret — name only, value stored in Keychain)`. Config.json values never appear in the block, so the injected context is safe to drop into any agent regardless of what's in Keychain. - Registered cron jobs attributed to this project (matched via the `[tmpl:] …` prefix convention) - Uninstall manifest reference (when `.scarf/template.lock.json` exists) - A note to the agent: cwd is the project dir, respect template content below the block. ## Integration point `ChatViewModel.startACPSession(resume:projectPath:)` refreshes the block BEFORE `client.start()` — Hermes reads AGENTS.md during session boot, so it has to land on disk first. `try?` with a warning log: a failed refresh doesn't block the chat, the session just starts without the extra context. ## Idempotency + safety - Two consecutive refreshes produce byte-identical output - Hand-edits outside the markers survive every refresh - Empty project dir → AGENTS.md created with just the block - Existing AGENTS.md without markers → block prepended; rest preserved below - Orphaned begin-marker (no end) → treated as "no block present," new block prepended, orphan left in place (likely hand-typed, not a Scarf corruption) ## Tests 13 new tests in ProjectAgentContextServiceTests: - applyBlock pure-text transform: prepend / replace / idempotency / empty input / orphaned-marker fallback - renderBlock content: identity fields, template presence, config field names (and CRITICALLY: no values leak for secret fields) - refresh end-to-end on isolated temp dirs: file creation, user content preservation, idempotency across runs, stale-block rewrite 93/93 Swift tests pass (was 80; +13 new). ## Deferred TERMINAL_CWD env-var plumbing in ACPClient was scoped in the plan but skipped — ACPClient.start() doesn't know the cwd at launch (it's per-session), and plumbing it would restructure the actor's lifecycle. Hermes already receives the cwd via ACP's `session/new` params and uses it for context-file discovery there, so TERMINAL_CWD is belt-and-suspenders we can add later without breaking anything. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/ProjectAgentContextService.swift | 293 ++++++++++++++++++ .../Chat/ViewModels/ChatViewModel.swift | 19 ++ .../ProjectAgentContextServiceTests.swift | 260 ++++++++++++++++ 3 files changed, 572 insertions(+) create mode 100644 scarf/scarf/Core/Services/ProjectAgentContextService.swift create mode 100644 scarf/scarfTests/ProjectAgentContextServiceTests.swift diff --git a/scarf/scarf/Core/Services/ProjectAgentContextService.swift b/scarf/scarf/Core/Services/ProjectAgentContextService.swift new file mode 100644 index 0000000..5d38261 --- /dev/null +++ b/scarf/scarf/Core/Services/ProjectAgentContextService.swift @@ -0,0 +1,293 @@ +import Foundation +import os + +/// Writes a Scarf-managed marker block into `/AGENTS.md` so +/// that Hermes — which auto-reads `AGENTS.md` from the session's cwd +/// at startup — has consistent project identity and metadata in every +/// project-scoped chat. +/// +/// **Why this exists.** Hermes has no native "project" concept and ACP +/// passes only `(cwd, mcpServers)` at session create — extra params +/// are silently dropped on Hermes's side. The documented hook for +/// giving the agent context when cwd is set programmatically is the +/// auto-load of `AGENTS.md` (or `.hermes.md` / `CLAUDE.md` / +/// `.cursorrules`, in that priority) from the cwd. Scarf owns a +/// managed region of the project's AGENTS.md; template-author content +/// lives outside that region and is preserved. +/// +/// **Marker contract.** The region sits between: +/// +/// ``` +/// +/// …Scarf-managed content… +/// +/// ``` +/// +/// Same pattern as the v2.2 memory-block appendix — bounded, self- +/// declaring, safe to re-generate. Everything outside the markers is +/// left byte-identical across refreshes. +/// +/// **Secret-safe.** The block surfaces field NAMES from `config.json` +/// (via the cached manifest's schema) but never VALUES. A rendered +/// block contains no secrets even for a project whose config.json +/// has Keychain-ref URIs. +/// +/// **Refresh timing.** `ChatViewModel.startACPSession(resume:projectPath:)` +/// calls `refresh(for:)` immediately before Hermes opens the session. +/// Hermes reads AGENTS.md during session boot, so the marker block +/// must have landed on disk first. Non-blocking on failure — a +/// failed refresh logs and the chat proceeds without the block. +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 = "" + + let context: ServerContext + + nonisolated init(context: ServerContext = .local) { + self.context = context + } + + // MARK: - Public + + /// Refresh (or create) the Scarf-managed block in the project's + /// AGENTS.md. Reads current project state — template manifest, + /// config schema, registered cron jobs — and produces a block + /// reflecting today's truth. Idempotent: two consecutive calls + /// with no intervening state change produce byte-identical + /// output. + nonisolated func refresh(for project: ProjectEntry) throws { + let block = renderBlock(for: project) + let path = agentsMdPath(for: project) + let transport = context.makeTransport() + + // Ensure the project directory exists — this service is the + // first thing that touches the project dir when the user + // scaffolds a bare project via `+` + starts a chat. Normally + // the dir exists (registered project = dir exists); belt- + // and-suspenders for edge cases. + if !transport.fileExists(project.path) { + try transport.createDirectory(project.path) + } + + if !transport.fileExists(path) { + // Fresh AGENTS.md with just our block + a trailing + // newline so editors render it cleanly. + let data = (block + "\n").data(using: .utf8) ?? Data() + try transport.writeFile(path, data: data) + Self.logger.info("created AGENTS.md with Scarf block for \(project.name, privacy: .public)") + return + } + + // Read existing, splice in the new block. + let existingData = try transport.readFile(path) + let existing = String(data: existingData, encoding: .utf8) ?? "" + let rewritten = Self.applyBlock(block: block, to: existing) + guard let outData = rewritten.data(using: .utf8) else { + throw ProjectAgentContextError.encodingFailed + } + // Skip the write when nothing changed — avoids unnecessary + // file-watcher churn. Matches what disk snapshot shows. + guard outData != existingData else { return } + try transport.writeFile(path, data: outData) + Self.logger.info("refreshed Scarf block in AGENTS.md for \(project.name, privacy: .public)") + } + + // MARK: - Marker splice (testable in isolation) + + /// Core text transform: given an existing file and a freshly- + /// rendered block, return the file with the block spliced in. + /// + /// Three cases handled: + /// 1. Existing file has both markers → replace the inclusive + /// region, preserve everything outside untouched. + /// 2. Existing file has no markers → prepend the block followed + /// by a two-newline separator so it reads as its own section. + /// 3. Existing file has a begin marker but no end → we DON'T try + /// to be clever; treat as "no markers present" and prepend. + /// User intervention or a later refresh can restore shape. + /// The stray begin-marker is left in the file; we don't + /// 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. + 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.. String { + let templateInfo = readTemplateInfo(for: project) + let configFieldsLine = renderConfigFieldsLine(for: project) + let cronLines = renderCronLines(for: project, templateId: templateInfo?.id) + let lockFilePresent = context.makeTransport().fileExists( + project.path + "/.scarf/template.lock.json" + ) + + var lines: [String] = [] + lines.append(Self.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 **\"\(project.name)\"**. Scarf is a macOS GUI for Hermes; the user is working with this project through it. 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:** `\(project.path)`") + lines.append("- **Dashboard:** `\(project.path)/.scarf/dashboard.json`") + + if let tpl = templateInfo { + lines.append("- **Template:** `\(tpl.id)` v\(tpl.version)") + } + lines.append("- **Configuration fields:** \(configFieldsLine)") + + if cronLines.isEmpty { + lines.append("- **Registered cron jobs:** (none attributed to this project)") + } else { + lines.append("- **Registered cron jobs:**") + for line in cronLines { + lines.append(" - \(line)") + } + } + + if lockFilePresent { + lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)") + } + + 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(Self.endMarker) + + return lines.joined(separator: "\n") + } + + // MARK: - Helpers + + nonisolated private func agentsMdPath(for project: ProjectEntry) -> String { + project.path + "/AGENTS.md" + } + + /// Read `/.scarf/manifest.json` for template id + version. + /// Nil when not present (bare project) or when the file is + /// unparseable — the block still renders cleanly without the + /// template line. + nonisolated private func readTemplateInfo(for project: ProjectEntry) -> (id: String, version: String)? { + let manifestPath = project.path + "/.scarf/manifest.json" + let transport = context.makeTransport() + guard transport.fileExists(manifestPath) else { return nil } + guard let data = try? transport.readFile(manifestPath) else { return nil } + guard let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data) else { return nil } + return (id: manifest.id, version: manifest.version) + } + + /// Build the "Configuration fields" bullet's tail. Returns a + /// comma-joined list of backticked field names with inline type + /// hints (`(secret)`), or the literal string "(none)" when the + /// project has no config schema. **Never** includes values. + nonisolated private func renderConfigFieldsLine(for project: ProjectEntry) -> String { + let manifestPath = project.path + "/.scarf/manifest.json" + let transport = context.makeTransport() + guard transport.fileExists(manifestPath), + let data = try? transport.readFile(manifestPath), + let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data), + let schema = manifest.config, + !schema.fields.isEmpty + else { + return "(none)" + } + let fieldList = schema.fields.map { field -> String in + let secretTag = field.type == .secret ? " (secret — name only, value stored in Keychain)" : "" + return "`\(field.key)`\(secretTag)" + } + return fieldList.joined(separator: ", ") + } + + /// Return a list of human-readable cron-job descriptions for jobs + /// attributed to this project via the `[tmpl:] …` name prefix. + /// Empty array when no jobs match (either the project has no + /// template or no jobs carry the tag). + nonisolated private func renderCronLines(for project: ProjectEntry, templateId: String?) -> [String] { + guard let templateId else { return [] } + let prefix = "[tmpl:\(templateId)]" + let jobs = HermesFileService(context: context).loadCronJobs() + return jobs + .filter { $0.name.hasPrefix(prefix) } + .map { job in + let scheduleDesc = job.schedule.display + ?? job.schedule.expression + ?? job.schedule.kind + let pausedDesc = job.enabled ? "enabled" : "paused" + return "`\(job.name)` — schedule `\(scheduleDesc)`, currently \(pausedDesc)" + } + } +} + +enum ProjectAgentContextError: Error { + case encodingFailed +} + +// MARK: - String helpers (file-scoped) + +private extension String { + /// Drop trailing newlines + CRs but preserve other trailing + /// whitespace (tabs, non-breaking spaces) that might be + /// meaningful in some edge case. + func trimmingRightNewlines() -> String { + var result = self + while let last = result.last, last.isNewline { + result.removeLast() + } + return result + } + + /// Symmetric counterpart: strip leading newlines / CRs. + func trimmingLeftNewlines() -> String { + var result = self + while let first = result.first, first.isNewline { + result.removeFirst() + } + return result + } +} diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 539a915..db97793 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -317,6 +317,25 @@ final class ChatViewModel { self.acpClient = client let attribution = SessionAttributionService(context: context) + // If the caller passed a project path, refresh the Scarf- + // managed block in the project's AGENTS.md BEFORE starting + // ACP — Hermes auto-reads AGENTS.md at session boot, so the + // block has to land on disk first. Non-blocking on failure: + // we log and proceed without the block. Safe on bare + // projects (creates AGENTS.md with just the block); safe on + // template-installed projects (splices the block into + // existing AGENTS.md without touching template content). + if let projectPath { + let registry = ProjectDashboardService(context: context).loadRegistry() + if let project = registry.projects.first(where: { $0.path == projectPath }) { + do { + try ProjectAgentContextService(context: context).refresh(for: project) + } catch { + logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)") + } + } + } + Task { @MainActor in do { // Start ACP process and event loop FIRST diff --git a/scarf/scarfTests/ProjectAgentContextServiceTests.swift b/scarf/scarfTests/ProjectAgentContextServiceTests.swift new file mode 100644 index 0000000..e2bf89c --- /dev/null +++ b/scarf/scarfTests/ProjectAgentContextServiceTests.swift @@ -0,0 +1,260 @@ +import Testing +import Foundation +@testable import scarf + +/// Exercises the Scarf-managed AGENTS.md marker block logic added in +/// v2.3. Tests operate on isolated temp directories — no dependency +/// on ~/.hermes contents, no cross-suite lock needed. +@Suite struct ProjectAgentContextServiceTests { + + // MARK: - applyBlock pure-text transform + + @Test func applyBlockPrependsWhenNoMarkersPresent() { + let existing = "# My Template\n\nSome instructions.\n" + let block = "\nhello\n" + let result = ProjectAgentContextService.applyBlock(block: block, to: existing) + #expect(result.hasPrefix("")) + #expect(result.contains("")) + #expect(result.contains("# My Template")) + #expect(result.contains("Some instructions.")) + // Exactly one blank line between block and original content. + #expect(result.contains("\n\n# My Template")) + } + + @Test func applyBlockWritesFreshFileWhenEmpty() { + let block = "\nhello\n" + let result = ProjectAgentContextService.applyBlock(block: block, to: "") + // Empty input → just the block + trailing newline; no weird + // leading whitespace. + #expect(result == block + "\n") + } + + @Test func applyBlockReplacesExistingMarkerRegion() { + let existing = """ + + old content line 1 + old content line 2 + + + # Template docs preserved + + Template behavior. + """ + let newBlock = "\nfresh content\n" + let result = ProjectAgentContextService.applyBlock(block: newBlock, to: existing) + + #expect(result.contains("fresh content")) + // Old content is gone. + #expect(!result.contains("old content line 1")) + #expect(!result.contains("old content line 2")) + // Template content outside markers is preserved. + #expect(result.contains("# Template docs preserved")) + #expect(result.contains("Template behavior.")) + } + + @Test func applyBlockIsIdempotent() { + let existing = "# Project\n\nContent.\n" + let block = "\nv1\n" + let once = ProjectAgentContextService.applyBlock(block: block, to: existing) + let twice = ProjectAgentContextService.applyBlock(block: block, to: once) + #expect(once == twice) + } + + @Test func applyBlockOrphanedBeginMarkerFallsBackToPrepend() { + // Stray begin with no end: treat as "no well-formed block," + // prepend. Leaves the orphan in place — it was probably + // hand-typed, not a corrupt Scarf write. Conservative. + let existing = "\nstray text with no end marker\n" + let block = "\nnew\n" + let result = ProjectAgentContextService.applyBlock(block: block, to: existing) + #expect(result.hasPrefix("\nnew\n")) + #expect(result.contains("stray text with no end marker")) + } + + // MARK: - renderBlock content + + @Test func renderBlockIncludesProjectIdentity() throws { + let dir = try Self.makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let project = ProjectEntry(name: "My Project", path: dir) + let svc = ProjectAgentContextService(context: .local) + let block = svc.renderBlock(for: project) + + #expect(block.contains(ProjectAgentContextService.beginMarker)) + #expect(block.contains(ProjectAgentContextService.endMarker)) + #expect(block.contains("\"My Project\"")) + #expect(block.contains(dir)) + #expect(block.contains("dashboard.json")) + } + + @Test func renderBlockOmitsTemplateSectionForBareProject() throws { + let dir = try Self.makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let project = ProjectEntry(name: "Bare", path: dir) + let svc = ProjectAgentContextService(context: .local) + let block = svc.renderBlock(for: project) + #expect(!block.contains("**Template:**")) + #expect(block.contains("**Configuration fields:** (none)")) + } + + @Test func renderBlockIncludesTemplateWhenManifestPresent() throws { + let dir = try Self.makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let scarfDir = dir + "/.scarf" + try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true) + // Minimal valid v1 manifest — no config schema. + let manifest = """ + { + "schemaVersion": 1, + "id": "author/example", + "name": "Example", + "version": "1.2.3", + "description": "…", + "contents": { "dashboard": true, "agentsMd": true } + } + """ + try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json")) + + let project = ProjectEntry(name: "Example", path: dir) + let svc = ProjectAgentContextService(context: .local) + let block = svc.renderBlock(for: project) + #expect(block.contains("**Template:** `author/example` v1.2.3")) + } + + @Test func renderBlockListsConfigFieldNamesNotValues() throws { + let dir = try Self.makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let scarfDir = dir + "/.scarf" + try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true) + // Schema-bearing manifest with one string field and one secret. + let manifest = """ + { + "schemaVersion": 2, + "id": "x/y", + "name": "Y", + "version": "1.0.0", + "description": "…", + "contents": { "dashboard": true, "agentsMd": true, "config": 2 }, + "config": { + "schema": [ + { "key": "site_url", "type": "string", "label": "Site URL", "required": true }, + { "key": "api_token", "type": "secret", "label": "API Token", "required": true } + ] + } + } + """ + try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json")) + + // A config.json with a "secret" VALUE — the block must NOT + // echo this value. If it does, secrets leak into an agent- + // readable file, which is exactly the thing to avoid. + let configJSON = """ + { + "schemaVersion": 2, + "templateId": "x/y", + "values": { + "site_url": { "type": "string", "value": "https://example.com" }, + "api_token": { "type": "keychainRef", "uri": "keychain://com.scarf.template.x-y/api_token:abc123" } + }, + "updatedAt": "2026-04-24T00:00:00Z" + } + """ + try configJSON.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/config.json")) + + let project = ProjectEntry(name: "Y", path: dir) + let svc = ProjectAgentContextService(context: .local) + let block = svc.renderBlock(for: project) + + // Field names present with type hints. + #expect(block.contains("`site_url`")) + #expect(block.contains("`api_token`")) + #expect(block.contains("(secret — name only, value stored in Keychain)")) + // CRITICAL: no VALUES appear — not the site URL, not the + // keychain ref. The block is safe to drop into an agent + // context. + #expect(!block.contains("https://example.com")) + #expect(!block.contains("keychain://")) + #expect(!block.contains("abc123")) + } + + // MARK: - refresh end-to-end (temp dir on local filesystem) + + @Test func refreshCreatesAGENTSMdWhenMissing() throws { + let dir = try Self.makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let project = ProjectEntry(name: "Fresh", path: dir) + + try ProjectAgentContextService(context: .local).refresh(for: project) + + let agentsMd = dir + "/AGENTS.md" + #expect(FileManager.default.fileExists(atPath: agentsMd)) + let contents = try String(contentsOf: URL(fileURLWithPath: agentsMd)) + #expect(contents.contains(ProjectAgentContextService.beginMarker)) + #expect(contents.contains(ProjectAgentContextService.endMarker)) + #expect(contents.contains("\"Fresh\"")) + } + + @Test func refreshPreservesUserContentBelow() throws { + let dir = try Self.makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let agentsMd = dir + "/AGENTS.md" + let userContent = "# Template\n\nDo the thing.\n" + try userContent.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd)) + + let project = ProjectEntry(name: "Preserved", path: dir) + try ProjectAgentContextService(context: .local).refresh(for: project) + + let after = try String(contentsOf: URL(fileURLWithPath: agentsMd)) + #expect(after.contains(ProjectAgentContextService.beginMarker)) + #expect(after.contains("# Template")) + #expect(after.contains("Do the thing.")) + // Block goes FIRST; user content follows. + let beginIdx = after.range(of: ProjectAgentContextService.beginMarker)!.lowerBound + let userIdx = after.range(of: "# Template")!.lowerBound + #expect(beginIdx < userIdx) + } + + @Test func refreshIsFullyIdempotent() throws { + let dir = try Self.makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let project = ProjectEntry(name: "Twice", path: dir) + let svc = ProjectAgentContextService(context: .local) + try svc.refresh(for: project) + let first = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md")) + try svc.refresh(for: project) + let second = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md")) + #expect(first == second) + } + + @Test func refreshRewritesStaleBlock() throws { + let dir = try Self.makeTempDir() + defer { try? FileManager.default.removeItem(atPath: dir) } + let agentsMd = dir + "/AGENTS.md" + // Pre-seed a stale Scarf block with a different project name + // and a user section below. + let seed = """ + + Old stale content — project was called "Something Else". + + + # Template + """ + try seed.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd)) + + let project = ProjectEntry(name: "Current Name", path: dir) + try ProjectAgentContextService(context: .local).refresh(for: project) + + let after = try String(contentsOf: URL(fileURLWithPath: agentsMd)) + #expect(after.contains("\"Current Name\"")) + #expect(!after.contains("Something Else")) + #expect(after.contains("# Template")) + } + + // MARK: - Helpers + + nonisolated static func makeTempDir() throws -> String { + let dir = NSTemporaryDirectory() + "scarf-project-context-test-" + UUID().uuidString + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + return dir + } +}