From 0d846c634e2a0bd3ebd095e8658c4a4f8b669f2e Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 29 Jul 2026 14:32:31 -0400 Subject: [PATCH] Agent guide v6 - warn against lane-glob moves A real agent incident (2026-07-29): moving cards with mv /* swept the lane's own index.md along with the card folders, overwriting the destination lane's identity file and leaving the source lane index-less (the Implementation board briefly lost its lane titles; restored from its auto-commit history). Both guide generations taught the correct named-folder form but never said why it is load-bearing. v6 adds the one-folder-at-a-time bullet to Moving and reordering: a lane folder holds its own index.md beside its cards, so a glob sweeps the identity file with them. Boards heal to v6 on their next open per the first-line marker rule. The guide tests now derive their expected marker and current/newer versions from AgentGuide.version instead of a hardcoded literal, so the next bump cannot silently break them. 1669 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- Kanban/Storage/AgentGuide.swift | 11 +++++++++-- KanbanTests/AgentGuideTests.swift | 15 ++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Kanban/Storage/AgentGuide.swift b/Kanban/Storage/AgentGuide.swift index e0e2206..0e2e465 100644 --- a/Kanban/Storage/AgentGuide.swift +++ b/Kanban/Storage/AgentGuide.swift @@ -44,8 +44,11 @@ enum AgentGuide { /// The guide the app ships. **v4 was the pathfinder's**, and real boards carry it; v5 is the /// rewrite's guide (lanes, `.trash/`, `attachments/`, `modified-by`, the `CLAUDE.user.md` - /// pointer) and supersedes it on the next open. - static let version = 5 + /// pointer) and supersedes it on the next open. v6 adds the one-folder-at-a-time move warning: + /// a real agent incident (2026-07-29) showed `mv /*` sweeping the lane's own `index.md` + /// along with the cards and destroying the destination lane's identity — the guide now says + /// *why* the named-folder form is load-bearing, not just what to type. + static let version = 6 // MARK: - The version marker @@ -327,6 +330,10 @@ enum AgentGuide { - Move to another lane: `mv / /` — the folder move IS the move. Then set the card's `order` to place it among the destination's cards, update `modified`, and re-stamp `modified-by`. + - Move folders **one at a time, by name** — never `mv /* /`. + A lane folder holds its own `index.md` beside its cards, so a glob + sweeps the lane's identity file along with them and overwrites the + destination lane's. - Reorder within a lane: rewrite only that card's `order`. ## Editing and deleting diff --git a/KanbanTests/AgentGuideTests.swift b/KanbanTests/AgentGuideTests.swift index 5137531..af8ee14 100644 --- a/KanbanTests/AgentGuideTests.swift +++ b/KanbanTests/AgentGuideTests.swift @@ -95,7 +95,7 @@ struct AgentGuideMarkerTests { @Test("The guide the app ships carries the current marker in its first line") func shippedGuideCarriesTheMarker() { let firstLine = String(AgentGuide.content.prefix { !$0.isNewline }) - #expect(firstLine.contains("lanework-agent-guide v5")) + #expect(firstLine.contains("lanework-agent-guide v\(AgentGuide.version)")) #expect(AgentGuide.installedVersion(of: AgentGuide.content) == AgentGuide.version) // Files the app creates end with LF (01-storage-format.md § Encoding and line endings). #expect(AgentGuide.content.hasSuffix("\n")) @@ -152,8 +152,8 @@ struct AgentGuideDecisionTests { /// "Never downgraded" — a newer app version may have written it. @Test("Current and newer are left alone") func currentAndNewerAreLeftAlone() { - #expect(AgentGuide.decide(state(.file(text: guideText(version: 5)))) == .leaveAlone) - #expect(AgentGuide.decide(state(.file(text: guideText(version: 6)))) == .leaveAlone) + #expect(AgentGuide.decide(state(.file(text: guideText(version: AgentGuide.version)))) == .leaveAlone) + #expect(AgentGuide.decide(state(.file(text: guideText(version: AgentGuide.version + 1)))) == .leaveAlone) #expect(AgentGuide.decide(state(.file(text: AgentGuide.content))) == .leaveAlone) } @@ -200,7 +200,7 @@ struct AgentGuideStoreTests { store.refreshAgentGuide() let written = try String(decoding: fixture.data(AgentGuide.filename), as: UTF8.self) - #expect(written.prefix { !$0.isNewline }.contains("lanework-agent-guide v5")) + #expect(written.prefix { !$0.isNewline }.contains("lanework-agent-guide v\(AgentGuide.version)")) #expect(written == AgentGuide.content) #expect(!fixture.exists(AgentGuide.userFilename)) #expect(brackets.begins == 1, "one bracket — one app-mediated reload, one commit") @@ -232,9 +232,10 @@ struct AgentGuideStoreTests { func currentGuideIsNotRewritten() throws { let fixture = try makeBoard() defer { fixture.tearDown() } - // Deliberately *not* the app's own text: a v5 marker is the whole gate, and a file carrying - // one must survive verbatim even when its body differs from what this version would write. - let url = try writeRoot(AgentGuide.filename, Data(guideText(version: 5).utf8), in: fixture) + // Deliberately *not* the app's own text: a current marker is the whole gate, and a file + // carrying one must survive verbatim even when its body differs from what this version + // would write. + let url = try writeRoot(AgentGuide.filename, Data(guideText(version: AgentGuide.version).utf8), in: fixture) let before = try stat(url) let store = try BoardStore(rootURL: fixture.root) let brackets = GuideBracketLog()