The embedded guide catches up on its own — creation-time parity for both apps, an open-time refresh for the phone

The card's frozen spec recommended Option C (install the agent guide at
board creation); the owner's follow-up comment extended that ruling to a
second axis — embedded guidelines should update whenever a board opens if
the on-disk version is older than Lanework's, which the Mac app already
does via BoardStore.refreshAgentGuide()/runScheduledHeals(). This card
implements both halves.

- BoardWriter.createBoard now calls AgentGuide.install(atBoardRoot:)
  right after seedGitignoreIfAbsent, so every board — Mac- or
  phone-created, since KanbanMobile.BoardIndexStore.createBoard calls
  this same method — is born with a current-version CLAUDE.md, with no
  dependency on a later open. Routed through AgentGuide.install itself
  rather than a hand-rolled write, so never-downgrade, the
  CLAUDE.user.md rescue, squatter displacement, and the EchoLedger
  heal-attribution exclusion all carry over unchanged.

- BoardSession (KanbanMobile) gains a private refreshAgentGuideOnce(),
  fired once from open() (already idempotent on the .idle phase),
  fire-and-forget through the same CoordinatedFileAccess.write bracket
  every phone write uses. Deliberately not a heal scheduler — a
  one-shot courtesy check at session open, silent on failure (logged,
  never surfaced to lastError or a banner), matching AgentGuide's own
  "nothing here is a user-facing event" posture. The type's doc comment
  now names this one exception while keeping "no heal scheduler" true.

- project.yml: lifted the KanbanMobile target's AgentGuide.swift build
  exclusion (dating to the original mobile MVP, "agents work where the
  Mac app runs") — both changes above fail to compile on the phone
  without it, since the type simply wasn't in that module. Verified
  safe: AgentGuide.swift imports only Foundation, and its one upward
  dependency touches only EchoLedger's unconditional recording API,
  never the #if os(macOS)-gated consumer surfaces.

Tests: KanbanTests/BoardWriterTests.swift gains
createBoardInstallsTheCurrentAgentGuide, calling createBoard directly
and asserting the guide lands at AgentGuide.version immediately — the
card's own Done-when, and also the phone's creation-time coverage since
it's the same call site. KanbanMobileUITests/AgentGuideUITests.swift
covers the open-time refresh itself, the one piece only reachable
end-to-end from a running KanbanMobile process (no mobile unit-test
target exists): the bundle's fixture board already carries no
CLAUDE.md, so tapping into it and polling disk proves the wiring with
no fixture changes needed.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 22:22:02 -04:00
parent 05ae703989
commit cdc91d669d
5 changed files with 162 additions and 5 deletions
@@ -0,0 +1,61 @@
import XCTest
/// The one narrow mobile behavior `BoardSession` adds beyond `BoardStore`'s own machinery (this
/// card, ruled 2026-08-09): a session opened against a board with no `CLAUDE.md` at all the
/// shape every board predating the "install at creation" fix is left in, and the shape this
/// suite's own fixture is deliberately in gets one written the moment its board screen
/// appears, with no dependency on the board ever touching a Mac.
///
/// This is the one piece of that wiring `KanbanTests` cannot reach: the write itself
/// (`AgentGuide.install`) is the Mac's own well-covered code (`AgentGuideTests.swift`), and
/// `BoardWriterCreateBoardTests.createBoardInstallsTheCurrentAgentGuide` already proves
/// `BoardWriter.createBoard` the one path `KanbanMobile.BoardIndexStore.createBoard` also
/// calls installs a current guide at birth. What only exists inside the `KanbanMobile` module,
/// and so can only be driven end to end from here, is `BoardSession.open()` firing the one-shot
/// `refreshAgentGuideOnce()` for a board that predates that guarantee. `KanbanMobileUITests`
/// black-boxes the app (plain `XCTest`, no `@testable import KanbanMobile`), so this asserts on
/// the marker prefix rather than `AgentGuide.version` a version bump does not need to touch
/// this file, and the KanbanTests suite is what pins the exact version and byte content.
final class AgentGuideUITests: XCTestCase {
/// Boards list tap the fixture board its lane screen appears no need to drill into a
/// lane or a card, which is what keeps this the cheapest possible proof of the wiring rather
/// than a rerun of `navigateToFirstCard`'s own coverage: `BoardScreen`'s
/// `.task { session.open() }` is what fires the refresh, and it fires the moment the lane
/// list itself appears.
@MainActor
func testOpeningABoardWithNoGuideInstallsOne() throws {
let (app, root) = XCUIApplication.launchedWithFixtureBoard()
let boardRow = app.element(labelContaining: RichBoard.title)
XCTAssertTrue(
boardRow.waitForExistence(timeout: XCUIApplication.uiTimeout),
"the \"\(RichBoard.title)\" row never appeared — check the fixture copy or the first scan"
)
boardRow.tap()
XCTAssertTrue(
waitForAgentGuide(under: root),
"no CLAUDE.md carrying the app's marker ever appeared under \(root.path)"
)
}
}
/// `waitForFile(under:containing:)` only ever looks at files named `index.md` (every other test
/// in this bundle is asserting on a card's or a board's own content) wrong shape for a file
/// named `CLAUDE.md`, so this polls the one path a guide install can land on directly instead of
/// walking the tree. Same 0.25s-poll shape as its sibling, for the same reason: the write reaches
/// disk asynchronously, well after `BoardScreen`'s `.task { session.open() }` returns.
private func waitForAgentGuide(under root: URL, timeout: TimeInterval = 15) -> Bool {
let guideURL = root
.appendingPathComponent(RichBoard.packageName, isDirectory: true)
.appendingPathComponent("CLAUDE.md")
let deadline = Date().addingTimeInterval(timeout)
repeat {
if let text = try? String(contentsOf: guideURL, encoding: .utf8), text.contains("lanework-agent-guide v") {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
} while Date() < deadline
return false
}