diff --git a/GraphcodeKit/Sources/Templates/StarterTemplates.swift b/GraphcodeKit/Sources/Templates/StarterTemplates.swift index 8cbe497e..7db2380b 100644 --- a/GraphcodeKit/Sources/Templates/StarterTemplates.swift +++ b/GraphcodeKit/Sources/Templates/StarterTemplates.swift @@ -32,16 +32,40 @@ public enum StarterTemplates { UUID(uuidString: "5747A57E-0000-4000-8000-\(suffix)") ?? UUID() } + /// In **priority order**, which is the order the ⌘T picker shows them — not + /// alphabetical, because the list is a ladder and alphabetical scrambles it: + /// + /// 1. the one that leads a team, since that is what most work becomes; + /// 2. Goal, the workhorse; + /// 3. Timed; + /// 4. everything else — Main's two pokes, the two Turn rhythms, the composite. public static var all: [PromptTemplate] { [ - whereDoesThisLive, whyDidThisBreak, + leadATeam, getTheBuildGreen, reviewTheDiff, raiseTestCoverage, nightlyDependencyReview, watchTheBuild, + whereDoesThisLive, whyDidThisBreak, portWithReview, pairOnThis, reviewFixVerify, ] } + /// Where a starter sits in the ladder — what the picker sorts the Starters group + /// by. A starter the app no longer ships (a hand-marked file, an old build's) goes + /// last rather than nowhere. + public static func priority(of id: UUID) -> Int { + all.firstIndex { $0.id == id } ?? all.count + } + + /// The starters 0.1.58-beta2 and beta3 shipped, whose seed marker predates ids + /// being recorded. Every starter in this list is treated as already seeded on such + /// an install; anything added since — `leadATeam` first — still arrives. Append + /// here only if a starter shipped in one of those two builds, which nothing more + /// will. + public static var seededBeforeIDsWereRecorded: Set { + Set(all.filter { $0.id != leadATeam.id }.map(\.id)) + } + /// The three offered on an empty canvas — one from each of the first three rungs of /// the commitment ladder, so the pick itself shows the axis. Deliberately not five: /// a row of every type is a taxonomy lesson, and this is a "get started" row. @@ -49,6 +73,46 @@ public enum StarterTemplates { [whereDoesThisLive, getTheBuildGreen, nightlyDependencyReview] } + // MARK: - Leading + + /// The starter at the top of the list, because this is what most work turns into: + /// one loop that understands the goal, splits it, and stays to put the pieces back + /// together. It is a **Main** loop on purpose — `MAIN_LOOP.md` names this as the + /// orchestration path ("you explore, find the work splits three ways, and promote") + /// — and it cuts no worktree, since a coordinator reads and steers while its + /// children write. + /// + /// The Artifactory is the team's inbox: where the plan is posted for loops that + /// don't exist yet, where children post what they finished, and the record of every + /// message that passed between them. The brief keeps it in that role — the task is + /// the goal; the board is how the team talks about it. + static var leadATeam: PromptTemplate { + starter( + id("000000000001"), "Lead a team toward a goal", + """ + Lead the work toward this goal: {goal}. + + Start by understanding the project well enough to say what done looks like and \ + where the work splits. Then split it: give each independent piece its own goal \ + loop — `graphcode node create --title --type goal --goal \ + ` — and give anything that needs checking rather than \ + finishing, like a build, a flaky test or a service, a timed loop: `--type time \ + --prompt "/loop 30m "`. Keep the integration for yourself: the \ + goal is met when the pieces fit together, not when each child says it's done. + + The team talks through the Artifactory — treat it as the inbox for this effort, \ + under the topic {topic}. Post your plan there first — `graphcode artifactory post \ + --topic {topic} ` — so every loop you create reads it before \ + starting. Tell each child to post its result there the same way when it's done. Run `graphcode artifactory watch --topic {topic}` so their \ + posts reach you, and `graphcode artifactory sync ` whenever you \ + come back, to catch up on what arrived while you were away. A stuck child gets a \ + direct message: `graphcode node send --follow-up `. + + Finish by checking the assembled result against the goal, posting a closing note \ + to the same topic — done, not done, next — and stopping. + """) + } + // MARK: - Main // Nothing to fill in, nothing to decide. Both of these end when you close them, // which is the whole type. diff --git a/GraphcodeKit/Sources/Templates/TemplateStorage.swift b/GraphcodeKit/Sources/Templates/TemplateStorage.swift index 9baaff25..055e7913 100644 --- a/GraphcodeKit/Sources/Templates/TemplateStorage.swift +++ b/GraphcodeKit/Sources/Templates/TemplateStorage.swift @@ -89,43 +89,62 @@ public struct TemplateStorage: Sendable { // MARK: - Starters - /// Writes the templates the app ships with into the home folder, **once**. + /// Writes the templates the app ships with into the home folder — each of them + /// **once**. /// /// A fresh install has an empty library, and an empty ⌘T picker teaches nothing — - /// see `StarterTemplates` for what the ten briefs are chosen to demonstrate. They - /// are written as real files so they read, diff and edit like any other template. + /// see `StarterTemplates` for what the briefs are chosen to demonstrate. They are + /// written as real files so they read, diff and edit like any other template. /// - /// Two rules keep this from being annoying: - /// - **Once.** Guarded by `seededMarker` in the home folder, so a starter you - /// deleted stays deleted rather than reappearing at every launch. + /// The marker in the home folder lists the ids this install has already seeded, and + /// that is what keeps this from being annoying: + /// - **Once per starter.** A starter whose id is in the marker is never written + /// again, so one you deleted stays deleted — and a starter added in a later build + /// still arrives, because its id isn't there yet. /// - **Never over anything.** A file already at that name is somebody's, and is - /// left exactly as it is even on the first run. + /// left exactly as it is even on a first run. /// - /// Returns what it actually wrote, which is empty on every launch after the first. + /// Returns what it actually wrote, which is empty on every launch that adds nothing. @discardableResult public func seedStartersIfNeeded(_ starters: [PromptTemplate] = StarterTemplates.all) throws -> [PromptTemplate] { let marker = homeDirectory.appendingPathComponent(Self.seededMarker) - guard !FileManager.default.fileExists(atPath: marker.path) else { return [] } + var seeded = seededIDs(at: marker) + let pending = starters.filter { !seeded.contains($0.id) } + guard !pending.isEmpty else { return [] } try FileManager.default.createDirectory(at: homeDirectory, withIntermediateDirectories: true) var written: [PromptTemplate] = [] - for starter in starters { + for starter in pending { let url = homeDirectory.appendingPathComponent(starter.fileName) - guard !FileManager.default.fileExists(atPath: url.path) else { continue } - var seeded = starter - seeded.origin = .home - try TemplateFileCodec.encode(seeded).write(to: url, atomically: true, encoding: .utf8) - written.append(seeded) + if !FileManager.default.fileExists(atPath: url.path) { + var copy = starter + copy.origin = .home + try TemplateFileCodec.encode(copy).write(to: url, atomically: true, encoding: .utf8) + written.append(copy) + } + seeded.insert(starter.id) } - // The marker is written last and on its own: a run that threw half way through - // should try again, not leave someone with four of the ten. - try Data().write(to: marker, options: .atomic) + // The marker is written last and whole: a run that threw half way through should + // try again, not leave someone with four of the eleven. + try seeded.map(\.uuidString).sorted().joined(separator: "\n") + .write(to: marker, atomically: true, encoding: .utf8) return written } + /// The ids the marker records. An **empty** marker is the one 0.1.58-beta2 and + /// beta3 wrote, before ids were recorded: those installs seeded every starter that + /// existed at the time, so the empty file is read as exactly that set — the ones + /// shipped since are what a later launch still owes them. + private func seededIDs(at marker: URL) -> Set { + guard let text = try? String(contentsOf: marker, encoding: .utf8) else { return [] } + let ids = text.split(separator: "\n").compactMap { UUID(uuidString: String($0)) } + if ids.isEmpty { return StarterTemplates.seededBeforeIDsWereRecorded } + return Set(ids) + } + /// A dotfile, so it never shows up as a template — `read` skips hidden files. - static let seededMarker = ".starters-seeded" + public static let seededMarker = ".starters-seeded" // MARK: - Writing diff --git a/graphcode/Sources/Features/Project/NodeDraftFields.swift b/graphcode/Sources/Features/Project/NodeDraftFields.swift index 6dec1372..079a317f 100644 --- a/graphcode/Sources/Features/Project/NodeDraftFields.swift +++ b/graphcode/Sources/Features/Project/NodeDraftFields.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import SwiftUI @@ -127,11 +128,24 @@ struct DraftProseField: View { var body: some View { TextField(placeholder, text: $text, axis: .vertical) .textFieldStyle(.plain) - .lineLimit(2...4) + .lineLimit(2...8) .font(.system(size: 13)) .focused($isFocused) .draftFieldBox(isFocused: isFocused, minHeight: 54) .onKeyPress(.tab) { onTokenJump?() == true ? .handled : .ignored } + // ⏎ is the dialog's primary action — the button says so — and a brief is + // often more than one paragraph. ⇧⏎ breaks the line, the way every chat + // composer does, so writing two paragraphs doesn't mean creating after the + // first. The insert goes through the field editor so it lands at the caret + // and the binding updates the ordinary way. + .onKeyPress(.return, phases: .down) { press in + guard press.modifiers.contains(.shift), + let editor = NSApp.keyWindow?.firstResponder as? NSTextView + else { return .ignored } + editor.insertNewlineIgnoringFieldEditor(nil) + return .handled + } + .help("⇧⏎ for a new line — ⏎ creates the loop") .claimingFocus(when: takesFocusRequest, focus: $isFocused) } } diff --git a/graphcode/Sources/Features/Project/ProjectFeatureState.swift b/graphcode/Sources/Features/Project/ProjectFeatureState.swift index 476ec02d..5ea1a4f1 100644 --- a/graphcode/Sources/Features/Project/ProjectFeatureState.swift +++ b/graphcode/Sources/Features/Project/ProjectFeatureState.swift @@ -160,30 +160,30 @@ extension ProjectFeature.State { || template.name.lowercased().contains(query) || template.body.lowercased().contains(query) } - // Starters stand apart only while they are the whole library. Once somebody has - // saved a brief of their own, the scaffolding stops outranking their work and - // sorts in with everything else in All projects. - let pinStarters = !templates.library.contains { !$0.isStarter && !$0.origin.isProject } var project: [PromptTemplate] = [] var starters: [PromptTemplate] = [] - var home: [PromptTemplate] = [] + var mine: [PromptTemplate] = [] for template in templates.library where matches(template) { if template.origin.isProject { project.append(template) - } else if pinStarters, template.isStarter { + } else if template.isStarter { starters.append(template) } else { - home.append(template) + mine.append(template) } } - func rows( - _ templates: [PromptTemplate], _ scope: ProjectFeature.TemplatePickerScope - ) -> [ProjectFeature.TemplatePickerRow] { - templates - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - .map { ProjectFeature.TemplatePickerRow(template: $0, scope: scope) } + func byName(_ templates: [PromptTemplate]) -> [PromptTemplate] { + templates.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } - return rows(project, .project) + rows(starters, .starter) + rows(home, .home) + // The shipped ones keep their shipped order: the Starters group is a ladder — + // lead a team, then Goal, then Timed, then the rest — and alphabetical would + // scramble it. Everything a person wrote sorts by name, as before. + let byPriority = starters.sorted { + StarterTemplates.priority(of: $0.id) < StarterTemplates.priority(of: $1.id) + } + return byName(project).map { ProjectFeature.TemplatePickerRow(template: $0, scope: .project) } + + byPriority.map { ProjectFeature.TemplatePickerRow(template: $0, scope: .starter) } + + byName(mine).map { ProjectFeature.TemplatePickerRow(template: $0, scope: .home) } } /// The fields still holding a `{token}`, in the order `⇥` walks them — the order @@ -388,9 +388,11 @@ extension ProjectFeature { case branch } - /// The groups the picker sorts by — a project's committed templates above the home - /// library, always, with the briefs the app ships pinned between them while they are - /// still the only thing here. + /// The three groups, in the order the picker shows them: a project's committed + /// templates first, always — the storage design's rule — then the briefs the app + /// ships, then the ones this person saved. The last two are kept apart for good: + /// scaffolding and somebody's own library are different things, and a list that + /// mixed them once the library grew would make the shipped ones hard to find again. enum TemplatePickerScope: Equatable { case project case starter @@ -400,7 +402,7 @@ extension ProjectFeature { switch self { case .project: return "This project" case .starter: return "Starters" - case .home: return "All projects" + case .home: return "Your templates" } } } diff --git a/graphcode/Tests/StarterTemplateTests.swift b/graphcode/Tests/StarterTemplateTests.swift index 6c0a5916..37b5c96f 100644 --- a/graphcode/Tests/StarterTemplateTests.swift +++ b/graphcode/Tests/StarterTemplateTests.swift @@ -88,6 +88,9 @@ struct StarterTemplateTests { let picks = StarterTemplates.firstLaunchPicks #expect(picks.count == 3) #expect(picks.map { $0.shape } == [nil, .goalBased, .timeBased]) + // The team-leading brief is the top of the picker, not of the canvas: the row + // there is for somebody's first loop, and a coordinator is not that. + #expect(!picks.contains { $0.id == StarterTemplates.all[0].id }) // And they are really in the shipped set, not a fourth thing nobody can find again. let shipped = StarterTemplates.all.map(\.id) for pick in picks { @@ -107,8 +110,59 @@ struct StarterTemplateTests { } } + /// The brief at the top is about the task; the Artifactory appears in it as the + /// team's inbox, and every command it names is one the CLI actually has. + @Test + func theTeamLeadingStarterNamesRealCommands() throws { + let lead = try #require(StarterTemplates.all.first) + #expect(lead.name == "Lead a team toward a goal") + #expect(lead.shape == nil) + #expect(lead.settings == nil) + #expect(lead.tokens == ["goal", "topic"]) + for command in [ + "graphcode node create", "--type goal", "--type time", "/loop 30m", + "graphcode artifactory watch", "graphcode artifactory sync", "graphcode artifactory post", + "graphcode node send", "--follow-up", + ] { + #expect(lead.body.contains(command), "missing \(command)") + } + // Task first: the goal is the first thing the brief says. + #expect(lead.body.hasPrefix("Lead the work toward this goal: {goal}.")) + } + // MARK: - Seeding + /// An install that already has beta2's ten still gets a starter shipped later — + /// the marker records ids, so a new id is owed and an old one never re-arrives. + @Test + func aStarterShippedLaterStillArrivesOnAnOlderInstall() throws { + let original = Array(StarterTemplates.all.dropFirst()) // what beta2 shipped + try storage.seedStartersIfNeeded(original) + #expect(storage.load(projectPath: nil).count == original.count) + // …and one of those was deleted before the update. + let deleted = try #require(storage.load(projectPath: nil).last) + try storage.delete(deleted) + + let arrived = try storage.seedStartersIfNeeded(StarterTemplates.all) + #expect(arrived.map(\.name) == ["Lead a team toward a goal"]) + let names = storage.load(projectPath: nil).map(\.name) + #expect(names.contains("Lead a team toward a goal")) + #expect(!names.contains(deleted.name)) + } + + /// beta2 and beta3 wrote an empty marker. That install seeded everything that + /// existed then, so the empty file has to read as exactly that set — the one + /// starter added since is what it is still owed, and nothing it deleted returns. + @Test + func anEmptyLegacyMarkerMeansTheFirstBatchWasSeeded() throws { + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + try Data().write(to: home.appendingPathComponent(TemplateStorage.seededMarker)) + + let arrived = try storage.seedStartersIfNeeded() + #expect(arrived.map(\.name) == ["Lead a team toward a goal"]) + #expect(storage.load(projectPath: nil).count == 1) + } + @Test func seedingWritesEveryStarterOnce() throws { let written = try storage.seedStartersIfNeeded() diff --git a/graphcode/Tests/TemplatePickerTests.swift b/graphcode/Tests/TemplatePickerTests.swift index c7a3c857..2ee6a7ea 100644 --- a/graphcode/Tests/TemplatePickerTests.swift +++ b/graphcode/Tests/TemplatePickerTests.swift @@ -176,27 +176,43 @@ struct TemplatePickerTests { #expect(!store.state.showingNewNodeForm) } - /// Starters stand apart while they are the whole library — the scaffolding is what - /// a new person needs at the top of the list. Once somebody has saved a brief of - /// their own, it stops outranking their work and sorts in with the rest. + /// Starters and a person's own templates are two sections for good — scaffolding + /// and a library are different things — and the starters keep their shipped order, + /// because the group is a ladder: lead a team, then Goal, then Timed, then the rest. @Test @MainActor - func startersArePinnedUntilYouHaveOneOfYourOwn() async { - var starter = homeTemplate("Get the build green", body: "Fix the build.", shape: .goalBased) - starter.isStarter = true - let library = [starter] + func startersKeepTheirLadderOrderAndYourOwnSitBelow() async { + // Deliberately handed over scrambled and alphabetically hostile. + let shipped = StarterTemplates.all + let scrambled = Array(shipped.reversed()) + let mine = homeTemplate("Aardvark brief", body: "Would sort first if names decided.") + let library = scrambled + [mine] let store = makeStore(library) store.exhaustivity = .off await store.send(.templatesButtonTapped) await store.send(.templateLibraryChanged(library)) - #expect(store.state.templatePickerRows.map(\.scope) == [.starter]) - #expect(ProjectFeature.TemplatePickerScope.starter.displayName == "Starters") + let rows = store.state.templatePickerRows + #expect(rows.map(\.scope) == Array(repeating: .starter, count: shipped.count) + [.home]) + #expect(rows.prefix(shipped.count).map(\.template.name) == shipped.map(\.name)) + #expect(rows.first?.template.name == "Lead a team toward a goal") + #expect(rows.last?.template.name == "Aardvark brief") + #expect(ProjectFeature.TemplatePickerScope.home.displayName == "Your templates") + } - // The moment there is a template of their own, the group folds away. - let mine = homeTemplate("My brief", body: "Something I wrote.") - await store.send(.templateLibraryChanged([starter, mine])) - #expect(store.state.templatePickerRows.map(\.scope) == [.home, .home]) + /// The ladder, spelled out: the team-leading brief first, then every Goal, then + /// every Timed, then the rest. + @Test + func theStarterLadderIsLeadThenGoalThenTimed() { + let shapes = StarterTemplates.all.map { $0.shape } + #expect(StarterTemplates.all[0].name == "Lead a team toward a goal") + #expect(shapes[0] == nil) + let goals = shapes.dropFirst().prefix { $0 == .goalBased } + #expect(goals.count == 3) + let timed = shapes.dropFirst(1 + goals.count).prefix { $0 == .timeBased } + #expect(timed.count == 2) + #expect(StarterTemplates.priority(of: StarterTemplates.all[0].id) == 0) + #expect(StarterTemplates.priority(of: UUID()) == StarterTemplates.all.count) } /// A project's committed templates outrank the shipped ones, always — the rule the