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:
@@ -369,6 +369,17 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure
|
/// `index.md`, after it, so the file that makes a folder a board is written first and a failure
|
||||||
/// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
|
/// to seed can never leave a half-made board. Seeding is `seedGitignoreIfAbsent`'s, so a
|
||||||
/// creation into a folder that somehow already carries one leaves it alone.
|
/// creation into a folder that somehow already carries one leaves it alone.
|
||||||
|
/// - **And with a current agent guide** (08-agent-integration.md ▸ The agent guide; ruled
|
||||||
|
/// 2026-08-09, extending the option-C recommendation to every `createBoard` caller): right
|
||||||
|
/// after the `.gitignore` seed, `AgentGuide.install(atBoardRoot:)` runs too, so a board never
|
||||||
|
/// lives even a moment without a current-version `CLAUDE.md` — no dependency on a later Mac
|
||||||
|
/// open to write the first copy. `KanbanMobile`'s `BoardIndexStore.createBoard` calls this same
|
||||||
|
/// method, so a phone-made board gets the identical guarantee for free. Routed through
|
||||||
|
/// `install` rather than a hand-rolled write on purpose: that is what carries over the guide's
|
||||||
|
/// own promises (never-downgrade, the `CLAUDE.user.md` rescue, squatter displacement, and
|
||||||
|
/// `EchoLedger.current?.markHeal(at:)`'s attribution exclusion) to this new call site without
|
||||||
|
/// re-deriving any of them. A failure here is a genuine `BoardWriteError` and throws like any
|
||||||
|
/// other step; there is no new banner or notice class for it.
|
||||||
/// - Parameter operation: what the *caller* was doing, for the banner's sake — `.createBoard` for
|
/// - Parameter operation: what the *caller* was doing, for the banner's sake — `.createBoard` for
|
||||||
/// every gesture that makes a board, and `.mintBoardIndex` for the decision surface's repair of
|
/// every gesture that makes a board, and `.mintBoardIndex` for the decision surface's repair of
|
||||||
/// a board folder that has everything except the file that says it is one
|
/// a board folder that has everything except the file that says it is one
|
||||||
@@ -402,6 +413,7 @@ public enum BoardWriter: Sendable {
|
|||||||
operation: operation
|
operation: operation
|
||||||
)
|
)
|
||||||
try seedGitignoreIfAbsent(atBoardRoot: rootURL)
|
try seedGitignoreIfAbsent(atBoardRoot: rootURL)
|
||||||
|
try AgentGuide.install(atBoardRoot: rootURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
||||||
|
|||||||
@@ -9,6 +9,16 @@ import os
|
|||||||
/// snapshot on screen — and none of its machinery: no FSEvents, no echo verdicts, no heal scheduler,
|
/// snapshot on screen — and none of its machinery: no FSEvents, no echo verdicts, no heal scheduler,
|
||||||
/// no git. The change signal here is the container's metadata query, relayed by `BoardIndexStore`.
|
/// no git. The change signal here is the container's metadata query, relayed by `BoardIndexStore`.
|
||||||
///
|
///
|
||||||
|
/// **One narrow exception**: `open()` fires a single, one-shot `AgentGuide.install(atBoardRoot:)`
|
||||||
|
/// alongside the first walk (`refreshAgentGuideOnce()`) — never repeated on `reload()` or
|
||||||
|
/// `containerDidUpdate()`. This is the owner's 2026-08-09 ruling extending the Mac's open/reload
|
||||||
|
/// guide refresh to the phone, answered as narrowly as that ruling allows: a board still has *no*
|
||||||
|
/// heal scheduler here (nothing re-checks the guide on every foreign change the way
|
||||||
|
/// `BoardStore.runScheduledHeals()` does), only a courtesy check the moment a session is opened.
|
||||||
|
/// `BoardWriter.createBoard` (both platforms' only creation path) already installs a current guide
|
||||||
|
/// at birth, so this exists for the boards that predate that guarantee or were last touched by an
|
||||||
|
/// older build.
|
||||||
|
///
|
||||||
/// Minted and cached by `BoardIndexStore.session(forBoardAt:)`, never constructed directly by a
|
/// Minted and cached by `BoardIndexStore.session(forBoardAt:)`, never constructed directly by a
|
||||||
/// screen: two screens looking at one board must share one snapshot.
|
/// screen: two screens looking at one board must share one snapshot.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -96,6 +106,7 @@ final class BoardSession {
|
|||||||
/// Starts the first walk. Idempotent — a screen may call it on every appearance.
|
/// Starts the first walk. Idempotent — a screen may call it on every appearance.
|
||||||
func open() {
|
func open() {
|
||||||
guard case .idle = phase else { return }
|
guard case .idle = phase else { return }
|
||||||
|
refreshAgentGuideOnce()
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +231,57 @@ final class BoardSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The agent guide
|
||||||
|
|
||||||
|
/// The type doc's "one narrow exception": a single `AgentGuide.install(atBoardRoot:)` per
|
||||||
|
/// session, fired from `open()` and never again — not a heal, not a scheduler, just the phone's
|
||||||
|
/// answer to "should an already-old board catch up the moment somebody opens it".
|
||||||
|
///
|
||||||
|
/// **Fire-and-forget, deliberately not awaited by `open()`.** The guide is a courtesy an agent
|
||||||
|
/// reads later, not something the board screen's first paint depends on, so it runs alongside
|
||||||
|
/// `reload()` rather than gating it — the two detached tasks race, and neither waits on the
|
||||||
|
/// other. It still goes through the same `CoordinatedFileAccess.write` bracket `perform(_:)`
|
||||||
|
/// uses, because the ubiquity daemon is as much a second writer here as it is for any other
|
||||||
|
/// phone write (`CoordinatedFileAccess`'s own doc comment).
|
||||||
|
///
|
||||||
|
/// **Silent, on `AgentGuide`'s own reasoning**: "nothing here is a user-facing event." A
|
||||||
|
/// coordination refusal or a genuine `BoardWriteError` is logged and dropped — never written to
|
||||||
|
/// `lastError` — because that property means *this session's own write failed*, and a courtesy
|
||||||
|
/// guide refresh racing the daemon on session open is not the write a screen showing a spinner or
|
||||||
|
/// a stale-snapshot notice is asking about. `AgentGuide.install` already re-verifies against disk
|
||||||
|
/// before writing anything, so losing a race to a foreign fix — another device's session, an
|
||||||
|
/// agent — is success, not a failure this call ever sees.
|
||||||
|
private func refreshAgentGuideOnce() {
|
||||||
|
let root = rootURL
|
||||||
|
// `Task { }`, not a bare detached call: the I/O itself still runs off the main actor
|
||||||
|
// (`Task.detached`, `perform(_:)`'s own shape), but logging a failure needs `Self.logger`,
|
||||||
|
// which is main-actor-isolated because this whole type is — so the outer task hops back
|
||||||
|
// after `.value` the same way `perform(_:)` does, instead of touching it from inside the
|
||||||
|
// detached closure.
|
||||||
|
Task {
|
||||||
|
let outcome: Result<Void, BoardSessionError> = await Task.detached(priority: .utility) {
|
||||||
|
let coordinated = CoordinatedFileAccess.write(itemAt: root) { resolved -> Result<Void, BoardWriteError> in
|
||||||
|
do throws(BoardWriteError) {
|
||||||
|
try AgentGuide.install(atBoardRoot: resolved)
|
||||||
|
return .success(())
|
||||||
|
} catch {
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch coordinated {
|
||||||
|
case let .failure(failure):
|
||||||
|
return .failure(.coordination(failure))
|
||||||
|
case let .success(inner):
|
||||||
|
return inner.mapError(BoardSessionError.write)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
|
||||||
|
if case let .failure(failure) = outcome {
|
||||||
|
Self.logger.error("agent guide refresh failed: \(failure.description, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - The comment thread
|
// MARK: - The comment thread
|
||||||
|
|
||||||
/// Reads one card's comment thread — the phone's counterpart to the Mac card window reading
|
/// Reads one card's comment thread — the phone's counterpart to the Mac card window reading
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -822,6 +822,26 @@ struct BoardWriterCreateBoardTests {
|
|||||||
let document = try FrontmatterDocument.parse(fixture.indexText("Board.kanban"))
|
let document = try FrontmatterDocument.parse(fixture.indexText("Board.kanban"))
|
||||||
#expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified))
|
#expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **Creation-time parity** (ruled 2026-08-09, extending Option C to every `createBoard` caller):
|
||||||
|
/// a board is born with a current-version `CLAUDE.md`, with no dependency on a later Mac open to
|
||||||
|
/// write the first copy — the gap `AgentGuideStoreTests` doesn't cover, since every fixture there
|
||||||
|
/// hand-writes its tree and calls `store.refreshAgentGuide()` directly rather than going through
|
||||||
|
/// `createBoard`. `KanbanMobile.BoardIndexStore.createBoard` calls this exact method, so this one
|
||||||
|
/// assertion is also the phone's coverage — there is no second code path to test twice.
|
||||||
|
@Test("createBoard installs a current-version agent guide, with no dependency on a later open")
|
||||||
|
func createBoardInstallsTheCurrentAgentGuide() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let root = fixture.url("MyBoard.kanban")
|
||||||
|
|
||||||
|
try BoardWriter.createBoard(at: root, title: "My Board")
|
||||||
|
|
||||||
|
let guideText = try String(decoding: fixture.data("MyBoard.kanban/\(AgentGuide.filename)"), as: UTF8.self)
|
||||||
|
#expect(AgentGuide.installedVersion(of: guideText) == AgentGuide.version)
|
||||||
|
#expect(guideText == AgentGuide.content, "the exact shipped guide, not a hand-rolled write")
|
||||||
|
#expect(!fixture.exists("MyBoard.kanban/\(AgentGuide.userFilename)"), "nothing existed to rescue at a brand-new root")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Create lane / card
|
// MARK: - Create lane / card
|
||||||
|
|||||||
+7
-5
@@ -141,12 +141,14 @@ targets:
|
|||||||
buildPhase: resources
|
buildPhase: resources
|
||||||
# The shared storage engine — frontmatter, loader, writer, ranks, integrity. Pure
|
# The shared storage engine — frontmatter, loader, writer, ranks, integrity. Pure
|
||||||
# Foundation + Yams + swift-markdown; no AppKit anywhere in the directory, which is what
|
# Foundation + Yams + swift-markdown; no AppKit anywhere in the directory, which is what
|
||||||
# makes this line possible. One exclusion: AgentGuide reaches up into LiveStore's
|
# makes this line possible. **No exclusions as of 2026-08-09**: `AgentGuide` used to sit
|
||||||
# EchoLedger (its bracketed-write seam), and the AGENTS.md guide it writes is a Mac
|
# out of this list ("agents work where the Mac app runs"), but the owner's ruling that day
|
||||||
# feature — agents work where the Mac app runs.
|
# extended the guide to every board-creation call site and to a one-shot refresh at
|
||||||
|
# `BoardSession.open()` — both phone-side — so it has to compile here too. It only reaches
|
||||||
|
# up into `LiveStore/EchoLedger.swift`'s unconditional recording API
|
||||||
|
# (`markHeal`/`recordMove`, below), never the `#if os(macOS)`-gated consumer surfaces, so
|
||||||
|
# nothing about "no FSEvents, no git on the phone" changes.
|
||||||
- path: Kanban/Storage
|
- path: Kanban/Storage
|
||||||
excludes:
|
|
||||||
- AgentGuide.swift
|
|
||||||
# The writer layer's one upward dependency: BoardWriter stamps every write into the
|
# The writer layer's one upward dependency: BoardWriter stamps every write into the
|
||||||
# task-local receipt ledger so a store can tell its own echoes from foreign changes. The
|
# task-local receipt ledger so a store can tell its own echoes from foreign changes. The
|
||||||
# recording side is pure CryptoKit + Foundation + Synchronization and compiles here; the
|
# recording side is pure CryptoKit + Foundation + Synchronization and compiles here; the
|
||||||
|
|||||||
Reference in New Issue
Block a user