From 595166ba9b4ab8ee116d5a1810fe218d48be38fd Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 4 Sep 2026 07:45:32 -0700 Subject: [PATCH] Fold a CLI-created loop's name into one word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #266 taught the backend namer to answer in a single word and #267 folded the two names the app writes itself, but the third way a loop gets named was left alone: `graphcode node create --title` takes whatever it is handed. Every loop a session fans out came back onto the canvas as "Board Visibility" — the briefing asks for a title, a session writes the phrase it would say out loud, and an instruction is all telling can ever be. The fold `sanitize` already did moves into `LoopName.folded`, and the CLI applies it at the boundary — the Swift parser and the remote Python shim both, since each builds its own draft. The briefing now asks for the shape it wants in the flag itself. A name with nothing alphanumeric in it is kept as typed rather than dropped, so no path can end up with a nameless card. The app's creation form is deliberately untouched: a human typing two words into a field means them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GyN8GTgxCHYEnjKohEHps5 --- .../Sources/CLI/GraphcodeCommand.swift | 7 +++++- GraphcodeKit/Sources/Domain/LoopName.swift | 23 +++++++++++++++++++ .../Sources/Domain/SessionBriefing.swift | 5 ++-- .../Sources/Sessions/RemoteGraphAccess.swift | 16 ++++++++++++- .../Clients/TitleSuggestionClient.swift | 8 ++----- graphcode/Tests/GraphcodeCommandTests.swift | 20 ++++++++++++++++ graphcode/Tests/RemoteCLIShimTests.swift | 3 ++- 7 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 GraphcodeKit/Sources/Domain/LoopName.swift diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 13fb807..66f32d0 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -452,7 +452,12 @@ public enum GraphcodeCommand: Equatable, Sendable { ]) let flags = parseFlags(arguments) if flags["help"] != nil { throw HelpRequested() } - guard let title = flags["title"] else { throw ParseError.missingArgument("--title") } + guard let rawTitle = flags["title"] else { throw ParseError.missingArgument("--title") } + // A loop fanning work out types the name it would say out loud — "Board Visibility" — + // and the instruction to write it as one word is only ever an instruction. Folded + // here, at the boundary, so a CLI-created loop is named the way every other one is + // (see `LoopName`). + let title = LoopName.folded(rawTitle) ?? rawTitle guard let rawType = flags["type"] else { throw ParseError.missingArgument("--type") } let loopType: LoopType diff --git a/GraphcodeKit/Sources/Domain/LoopName.swift b/GraphcodeKit/Sources/Domain/LoopName.swift new file mode 100644 index 0000000..1ece8ee --- /dev/null +++ b/GraphcodeKit/Sources/Domain/LoopName.swift @@ -0,0 +1,23 @@ +import Foundation + +/// The one-word shape every loop name on a canvas has. A name is read in a sidebar row +/// and a card header, where one word carries further, so two concepts join in CamelCase +/// — "BoardVisibility" — instead of spending a space on them. +/// +/// The backend namer asks for that shape and folds what it gets back +/// (`TitleSuggestionClient.sanitize`). This is the same fold applied where a name arrives +/// already written: `--title` on the CLI, typed by a loop fanning work out, where telling +/// is all the instruction can do. +public enum LoopName { + /// `nil` when nothing alphanumeric survives, which is the caller's cue to keep whatever + /// it was given rather than create a nameless card. + public static func folded(_ raw: String) -> String? { + let words = + raw + .split(whereSeparator: { $0.isWhitespace }) + .map { $0.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) } + .filter { !$0.isEmpty } + guard !words.isEmpty else { return nil } + return words.map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined() + } +} diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index ef2995d..3b9755d 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -133,7 +133,7 @@ public enum SessionBriefing { in sequence: ```sh - graphcode node create \(projectPath) --title --type goal --goal <what done looks like> + graphcode node create \(projectPath) --title <OneWordName> --type goal --goal <what done looks like> ``` A **node is a loop** — the cards on graphcode's canvas are loops, and the CLI calls @@ -256,7 +256,8 @@ public enum SessionBriefing { If `graphcode` is not on your `PATH`, it is at `\(installedCLIPath)`. Give each loop a title that says what it is for and a goal that says what done looks - like — they are what a human sees in the sidebar. + like — they are what a human sees in the sidebar. A title is one word: two concepts + join in CamelCase, `--title BoardVisibility`, never `--title "Board Visibility"`. ## When not to diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index acaa018..ee94fb9 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -452,6 +452,20 @@ public enum RemoteGraphAccess { print(acknowledgement) + def folded_title(raw): + # The one-word CamelCase shape every loop name has -- LoopName.folded on the Mac. + words = [] + for word in raw.split(): + start, end = 0, len(word) + while start < end and not word[start].isalnum(): + start += 1 + while end > start and not word[end - 1].isalnum(): + end -= 1 + if end > start: + words.append(word[start].upper() + word[start + 1:end]) + return "".join(words) or raw.strip() + + def make_draft(flags): if not flags.get("title"): fail("missing --title") @@ -471,7 +485,7 @@ public enum RemoteGraphAccess { fail("a %s loop needs --%s" % (flags["type"], needed)) draft = { "id": str(uuid.uuid4()).upper(), - "title": flags["title"], + "title": folded_title(flags["title"]), "loopType": loop_type, "pausesBeforeWritesOnly": False, } diff --git a/graphcode/Sources/Clients/TitleSuggestionClient.swift b/graphcode/Sources/Clients/TitleSuggestionClient.swift index b51915a..8bfbacd 100644 --- a/graphcode/Sources/Clients/TitleSuggestionClient.swift +++ b/graphcode/Sources/Clients/TitleSuggestionClient.swift @@ -153,12 +153,8 @@ extension TitleSuggestionClient: DependencyKey { .components(separatedBy: .newlines) .map { $0.trimmingCharacters(in: .whitespaces) } .last { !$0.isEmpty } - let words = (lastLine ?? "").split(separator: " ").prefix(2) - .map { $0.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) } - .filter { !$0.isEmpty } - guard !words.isEmpty else { return nil } - let name = words.map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined() - guard name.count <= 30 else { return nil } + let answer = (lastLine ?? "").split(separator: " ").prefix(2).joined(separator: " ") + guard let name = LoopName.folded(answer), name.count <= 30 else { return nil } return name } } diff --git a/graphcode/Tests/GraphcodeCommandTests.swift b/graphcode/Tests/GraphcodeCommandTests.swift index 3a6369a..aa7bb02 100644 --- a/graphcode/Tests/GraphcodeCommandTests.swift +++ b/graphcode/Tests/GraphcodeCommandTests.swift @@ -459,6 +459,26 @@ extension GraphcodeCommandTests { #expect(draft.title == "Classify") } + @Test + func aTitleWrittenAsSeparateWordsIsFoldedIntoOne() throws { + // What a loop fanning work out actually types. Every other loop name on the canvas + // is one word, and telling the session so is all the briefing can do. + let command = try GraphcodeCommand.parse([ + "node", "create", "/tmp/x", "--title", "Board Visibility", "--type", "goal", + "--goal", "boards ship", + ]) + guard case .createNode(_, let draft, _) = command else { + Issue.record("expected createNode, got \(command)") + return + } + #expect(draft.title == "BoardVisibility") + #expect(LoopName.folded("fix the flaky login test") == "FixTheFlakyLoginTest") + #expect(LoopName.folded("GraphCode templates") == "GraphCodeTemplates") + // Nothing alphanumeric to fold: the caller keeps what it was given rather than + // creating a nameless card. + #expect(LoopName.folded("---") == nil) + } + @Test func compositeIsCreatableFromTheCLIUnderEitherName() throws { // `proactive` too: that is what a composite still serialises as, so it is the word a diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift index 7acbcc9..112ba0d 100644 --- a/graphcode/Tests/RemoteCLIShimTests.swift +++ b/graphcode/Tests/RemoteCLIShimTests.swift @@ -51,7 +51,8 @@ struct RemoteCLIShimTests { return } #expect(path == Self.project) - #expect(draft.title == "Fix issue 7") + // Folded to one word, exactly as `LoopName.folded` does it on the Mac. + #expect(draft.title == "FixIssue7") #expect(draft.loopType == .goalBased) #expect(draft.goal?.summary == "tests pass") #expect(draft.goal?.predicate == "make test")