From cdc91d669df5825c1b11546c88a6195e7c393a13 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 8 Aug 2026 22:20:09 -0400 Subject: [PATCH] =?UTF-8?q?The=20embedded=20guide=20catches=20up=20on=20it?= =?UTF-8?q?s=20own=20=E2=80=94=20creation-time=20parity=20for=20both=20app?= =?UTF-8?q?s,=20an=20open-time=20refresh=20for=20the=20phone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/Storage/BoardWriter.swift | 12 ++++ KanbanMobile/Cloud/BoardSession.swift | 62 +++++++++++++++++++++ KanbanMobileUITests/AgentGuideUITests.swift | 61 ++++++++++++++++++++ KanbanTests/BoardWriterTests.swift | 20 +++++++ project.yml | 12 ++-- 5 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 KanbanMobileUITests/AgentGuideUITests.swift diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 8976167..40852a5 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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 /// 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. + /// - **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 /// 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 @@ -402,6 +413,7 @@ public enum BoardWriter: Sendable { operation: operation ) try seedGitignoreIfAbsent(atBoardRoot: rootURL) + try AgentGuide.install(atBoardRoot: rootURL) } /// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under diff --git a/KanbanMobile/Cloud/BoardSession.swift b/KanbanMobile/Cloud/BoardSession.swift index 38566fd..979a864 100644 --- a/KanbanMobile/Cloud/BoardSession.swift +++ b/KanbanMobile/Cloud/BoardSession.swift @@ -9,6 +9,16 @@ import os /// 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`. /// +/// **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 /// screen: two screens looking at one board must share one snapshot. @MainActor @@ -96,6 +106,7 @@ final class BoardSession { /// Starts the first walk. Idempotent — a screen may call it on every appearance. func open() { guard case .idle = phase else { return } + refreshAgentGuideOnce() 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 = await Task.detached(priority: .utility) { + let coordinated = CoordinatedFileAccess.write(itemAt: root) { resolved -> Result 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 /// Reads one card's comment thread — the phone's counterpart to the Mac card window reading diff --git a/KanbanMobileUITests/AgentGuideUITests.swift b/KanbanMobileUITests/AgentGuideUITests.swift new file mode 100644 index 0000000..29dd9fe --- /dev/null +++ b/KanbanMobileUITests/AgentGuideUITests.swift @@ -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 +} diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index 94f8229..fb5912d 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -822,6 +822,26 @@ struct BoardWriterCreateBoardTests { let document = try FrontmatterDocument.parse(fixture.indexText("Board.kanban")) #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 diff --git a/project.yml b/project.yml index a63565a..bd6612c 100644 --- a/project.yml +++ b/project.yml @@ -141,12 +141,14 @@ targets: buildPhase: resources # The shared storage engine — frontmatter, loader, writer, ranks, integrity. Pure # 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 - # EchoLedger (its bracketed-write seam), and the AGENTS.md guide it writes is a Mac - # feature — agents work where the Mac app runs. + # makes this line possible. **No exclusions as of 2026-08-09**: `AgentGuide` used to sit + # out of this list ("agents work where the Mac app runs"), but the owner's ruling that day + # 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 - excludes: - - AgentGuide.swift # 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 # recording side is pure CryptoKit + Foundation + Synchronization and compiles here; the