Build AgentGuide — versioned CLAUDE.md maintenance
The app-owned agent guide at every board root (DESIGN/08 ▸ The agent guide): version-gated by a first-line marker (v5, superseding the pathfinder's v4 guides on real boards), rewritten when missing or older, byte-for-byte untouched when current or newer. A markerless CLAUDE.md is displaced to CLAUDE.user.md when that name is free — never clobbered — and the guide write is skipped with a log when it isn't. Symlinks, folders, and read-only volumes are skipped in silence; the write rides performWrite's bracket as an app-mediated Writer operation (new WriteOperation.agentGuide), so the echo lands appMediated and the Pro-era committer can attribute it honestly later. Hooked at store acquire (beside the loose-file relocation, after the watcher exists) and on every successful reload — the guide self-heals from foreign deletion or rollback, pre-wiring 06's acknowledged undo bounce. The refresh memo arms before each attempt and clears on a successful write, so a failing write can't hot-loop and a foreign deletion stays healable. First-line-only marker parsing (no Regex); guide content is one swappable literal, finalized under the next card. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -697,6 +697,13 @@ public final class BannerCenter {
|
||||
// failure the user did not provoke is exactly the one they have no other way to learn
|
||||
// about.
|
||||
"Couldn't move '\(filename)' into attachments"
|
||||
case .agentGuide:
|
||||
// **"the agent guide", not "CLAUDE.md"**: the file is the app's, written for agents, and
|
||||
// most users will never have opened it — a filename here would name something they have
|
||||
// no relationship with. It says nothing about the board's own files because none were
|
||||
// touched, and nothing is lost: the board works exactly as well without the guide, which
|
||||
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
|
||||
"Couldn't update the agent guide"
|
||||
case let .toggleTask(title):
|
||||
// The user's word for it, not the file's: they ticked a box. The card is named where
|
||||
// the read that preceded the flip learned its title, so a body write that refused says
|
||||
|
||||
@@ -442,6 +442,14 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
private var attemptedTombstoneMigration: Set<String> = []
|
||||
|
||||
/// The board root's agent-guide picture the last refresh acted on — the third of the same loop
|
||||
/// guard, documented at `refreshAgentGuide()`. `nil` means "nothing has been acted on against
|
||||
/// the current picture", which is both the opening state and what a board whose guide is already
|
||||
/// current resets it to (so the state it holds is never a guide's own text for longer than the
|
||||
/// one reload that wrote it).
|
||||
@ObservationIgnored
|
||||
private var attemptedGuideRefresh: AgentGuide.State?
|
||||
|
||||
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
|
||||
/// applied — the one seam this type keeps, `nil` in production.
|
||||
///
|
||||
@@ -647,8 +655,15 @@ public final class BoardStore {
|
||||
// share one bracket-per-call posture, so neither can see the other's work half-done —
|
||||
// each opens its own bracket and each is re-armed by the reload the other's write
|
||||
// produces.
|
||||
//
|
||||
// The agent guide joins them last, and is the one of the three that runs on *every*
|
||||
// board rather than only on one an older version or an outside writer left work in: it
|
||||
// re-checks a single board-root file and writes only when the version marker says to
|
||||
// (08-agent-integration.md ▸ The agent guide). Running it here rather than at open alone
|
||||
// is what makes it self-healing — see `refreshAgentGuide()`.
|
||||
relocateLooseCardFiles()
|
||||
migrateLegacyTombstones()
|
||||
refreshAgentGuide()
|
||||
|
||||
case let .failure(error):
|
||||
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||
@@ -3305,6 +3320,101 @@ public final class BoardStore {
|
||||
.map(\.element)
|
||||
}
|
||||
|
||||
// MARK: - The agent guide
|
||||
|
||||
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
|
||||
/// it is — the whole of 08-agent-integration.md ▸ The agent guide's scheduling. The rule itself
|
||||
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
|
||||
///
|
||||
/// **Run on every successful reload**, beside the relocation and the tombstone migration, and
|
||||
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
|
||||
/// brackets exist, for the reason stated there). That makes the guide *self-healing* rather than
|
||||
/// merely written-once: a foreign deletion, a downgrade to an older guide, a board restored from
|
||||
/// a template carrying a stale one — each heals on the next reload, without a single new signal.
|
||||
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
|
||||
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
|
||||
/// re-upgrades.
|
||||
///
|
||||
/// **The steady state is a read and a comparison** — one `lstat`, one small file read, one
|
||||
/// first-line parse — and no write at all. Nothing here touches the snapshot: the bytes land, the
|
||||
/// watcher notices, the reload applies, exactly like every other app write.
|
||||
///
|
||||
/// ### The read-only board is skipped, never banner-ed
|
||||
///
|
||||
/// Two gates, because two different things can be true. `performWrite` would refuse under the
|
||||
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture — so
|
||||
/// the lock is checked first, the relocation's own deferral idiom. The writability probe beside
|
||||
/// it covers the case the lock does not: 02-architecture.md's open-time unwritable-root lock is a
|
||||
/// separate card, and until it lands a board on a read-only volume would reach the Writer, fail,
|
||||
/// and post a banner about a file the user never asked for. 02 settles that exact case the other
|
||||
/// way — "the open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken
|
||||
/// precedent" — so it is skipped with a log.
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
|
||||
/// on every reload — so a *failing* guide write would retry forever at the speed of the debounce,
|
||||
/// posting a banner row each time. The guard is the relocation's exactly: an attempt is made only
|
||||
/// when the board root's picture **differs from the one last acted on**. One failure, one row,
|
||||
/// then silence until something on disk actually changes. The same memo is what keeps the two
|
||||
/// skip cases from repeating their log line on every reload of an unchanged board.
|
||||
public func refreshAgentGuide() {
|
||||
let state = AgentGuide.inspect(atBoardRoot: rootURL)
|
||||
let decision = AgentGuide.decide(state)
|
||||
guard decision != .leaveAlone else {
|
||||
// The resting state, and the memo's reset: a board whose guide is current has nothing to
|
||||
// remember having tried.
|
||||
attemptedGuideRefresh = nil
|
||||
return
|
||||
}
|
||||
// Deferred, not abandoned — and deliberately *before* the memo is written, so the refresh a
|
||||
// lock refused is not the one the guard below remembers.
|
||||
guard readOnlyLock == nil else {
|
||||
Self.logger.debug("agent-guide refresh deferred — the board is read-only")
|
||||
return
|
||||
}
|
||||
guard FileManager.default.isWritableFile(atPath: rootURL.path) else {
|
||||
Self.logger.debug("agent-guide refresh skipped — the board's location is not writable")
|
||||
return
|
||||
}
|
||||
guard state != attemptedGuideRefresh else { return }
|
||||
// Armed before anything is attempted, so a write that throws leaves it set — the memo's
|
||||
// whole job is to remember pictures this store has already failed or refused to act on.
|
||||
attemptedGuideRefresh = state
|
||||
|
||||
switch decision {
|
||||
case .leaveAlone:
|
||||
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
|
||||
case .skipUntouchable:
|
||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
|
||||
case .skipUserFilenameTaken:
|
||||
// The ruling's own outcome (08 ▸ Ownership): a user-authored CLAUDE.md that cannot be
|
||||
// rescued keeps its name, and the guide simply does not exist on this board.
|
||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
|
||||
case .write, .displaceThenWrite:
|
||||
let root = rootURL
|
||||
let displace = decision == .displaceThenWrite
|
||||
do {
|
||||
// One bracket over the rescue move *and* the write: two files change, one
|
||||
// app-mediated reload lands, and (in the Pro edition) one honestly-attributed commit
|
||||
// records it.
|
||||
try performWrite { () throws(BoardWriteError) -> Void in
|
||||
try AgentGuide.install(atBoardRoot: root, displacingUserContent: displace)
|
||||
}
|
||||
// **Cleared on success, and this is load-bearing rather than tidy**: the memo keys on
|
||||
// the picture that provoked the write, and a foreign deletion restores that exact
|
||||
// picture ("missing"). A memo left standing would make the deletion the one thing the
|
||||
// self-heal could not heal.
|
||||
attemptedGuideRefresh = nil
|
||||
} catch {
|
||||
// Already the banner's — `performWrite` posts every `BoardWriteError` before it
|
||||
// rethrows — and there is nothing else a courtesy write can do about a failure. The
|
||||
// memo, left armed above, is what keeps it from being posted again every reload.
|
||||
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
// The thin pass-throughs to `transient`, and the only ones.
|
||||
|
||||
@@ -217,6 +217,12 @@ public final class BoardStoreRegistry {
|
||||
// thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk
|
||||
// would otherwise have no reload behind.
|
||||
store.relocateLooseCardFiles()
|
||||
// The agent guide's first firing (08-agent-integration.md ▸ The agent guide), here for the
|
||||
// same reason and with the same timing as the relocation above: a board opened with no
|
||||
// `CLAUDE.md`, or with one an older version wrote, gets the current guide now — as a
|
||||
// bracketed app write with a reload behind it, never as a write into a board nothing is
|
||||
// watching. Every reload thereafter re-checks it from `BoardStore.land`.
|
||||
store.refreshAgentGuide()
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user