Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion GraphcodeKit/Sources/Templates/StarterTemplates.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,87 @@ 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<UUID> {
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.
public static var firstLaunchPicks: [PromptTemplate] {
[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 <project-path> --title <name> --type goal --goal \
<what done looks like>` — 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 <what to check>"`. 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 \
<project-path> --topic {topic} <plan>` — 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 <project-path> --topic {topic}` so their \
posts reach you, and `graphcode artifactory sync <project-path>` whenever you \
come back, to catch up on what arrived while you were away. A stuck child gets a \
direct message: `graphcode node send <project-path> <id> --follow-up <steer>`.

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.
Expand Down
57 changes: 38 additions & 19 deletions GraphcodeKit/Sources/Templates/TemplateStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> {
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

Expand Down
16 changes: 15 additions & 1 deletion graphcode/Sources/Features/Project/NodeDraftFields.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import ComposableArchitecture
import SwiftUI

Expand Down Expand Up @@ -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)
}
}
Expand Down
38 changes: 20 additions & 18 deletions graphcode/Sources/Features/Project/ProjectFeatureState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
}
}
}
Expand Down
54 changes: 54 additions & 0 deletions graphcode/Tests/StarterTemplateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
42 changes: 29 additions & 13 deletions graphcode/Tests/TemplatePickerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading