feat(projects): Scarf-managed project-context block in AGENTS.md

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 `<project>/AGENTS.md`
between `<!-- scarf-project:begin -->` and `<!-- scarf-project:end -->`
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:<id>] …` 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 01:05:15 +02:00
parent e4920538d2
commit 5b1481f33f
3 changed files with 572 additions and 0 deletions
@@ -0,0 +1,293 @@
import Foundation
import os
/// Writes a Scarf-managed marker block into `<project>/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-project:begin -->
/// Scarf-managed content
/// <!-- scarf-project:end -->
/// ```
///
/// 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 = "<!-- scarf-project:begin -->"
static let endMarker = "<!-- scarf-project:end -->"
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..<existing.endIndex
)
else {
// No well-formed Scarf block present prepend.
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedExisting.isEmpty {
return block + "\n"
}
return block + "\n\n" + existing
}
// Full span: from the begin marker through the end marker
// (inclusive). Consumes any trailing whitespace/newlines
// immediately following the end marker so a re-render of a
// shorter block doesn't leave a dangling blank line.
var upperBound = endRange.upperBound
while upperBound < existing.endIndex,
existing[upperBound].isNewline {
upperBound = existing.index(after: upperBound)
}
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
let after = String(existing[upperBound..<existing.endIndex])
// Preserve the leading whitespace / content structure of
// `before` but ensure exactly one blank line separates it
// from the new block when there IS prior content.
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? ""
: before.trimmingRightNewlines() + "\n\n"
// Suffix: a blank line BEFORE the remaining content, ensuring
// the template/user content is visually separated from the
// Scarf block.
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "\n"
: "\n\n" + after.trimmingLeftNewlines()
return prefix + block + suffix
}
// MARK: - Block rendering
/// Build the Markdown block for a given project. Pure function of
/// project state exposed for tests that want to assert on
/// rendered content without touching disk.
nonisolated func renderBlock(for project: ProjectEntry) -> 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 `<project>/.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:<id>] ` 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
}
}
@@ -317,6 +317,25 @@ final class ChatViewModel {
self.acpClient = client self.acpClient = client
let attribution = SessionAttributionService(context: context) 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 Task { @MainActor in
do { do {
// Start ACP process and event loop FIRST // Start ACP process and event loop FIRST
@@ -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 = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
#expect(result.hasPrefix("<!-- scarf-project:begin -->"))
#expect(result.contains("<!-- scarf-project:end -->"))
#expect(result.contains("# My Template"))
#expect(result.contains("Some instructions."))
// Exactly one blank line between block and original content.
#expect(result.contains("<!-- scarf-project:end -->\n\n# My Template"))
}
@Test func applyBlockWritesFreshFileWhenEmpty() {
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
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 = """
<!-- scarf-project:begin -->
old content line 1
old content line 2
<!-- scarf-project:end -->
# Template docs preserved
Template behavior.
"""
let newBlock = "<!-- scarf-project:begin -->\nfresh content\n<!-- scarf-project:end -->"
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 = "<!-- scarf-project:begin -->\nv1\n<!-- scarf-project:end -->"
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 = "<!-- scarf-project:begin -->\nstray text with no end marker\n"
let block = "<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
#expect(result.hasPrefix("<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"))
#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 = """
<!-- scarf-project:begin -->
Old stale content — project was called "Something Else".
<!-- scarf-project:end -->
# 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
}
}