From c670a40366b2bb84e8a3c97cf468a00ca76343ab Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:17:19 -0700 Subject: [PATCH 1/6] Start the Nod graph layer stream Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi From d91daaf6cd0b37e116696a545722b5da9fc785c2 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:26:04 -0700 Subject: [PATCH 2/6] Add Nod graph logic: plan to composite, fork, graph verbs, inbound mail Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- GraphcodeKit/Sources/Domain/LoopGraph.swift | 1 + GraphcodeKit/Sources/Domain/LoopLineage.swift | 39 +++ GraphcodeKit/Sources/Domain/LoopNode.swift | 6 +- .../Sources/Domain/NodCompositePlan.swift | 181 +++++++++++++ .../Sources/Domain/NodEditablePlan.swift | 63 +++++ GraphcodeKit/Sources/Domain/NodFork.swift | 72 +++++ .../Sources/Domain/NodGraphContext.swift | 56 ++++ .../Sources/Domain/NodGraphVerbs.swift | 256 ++++++++++++++++++ GraphcodeKit/Sources/Domain/NodProtocol.swift | 58 +++- GraphcodeKit/Sources/Domain/NodeDraft.swift | 9 +- 10 files changed, 737 insertions(+), 4 deletions(-) create mode 100644 GraphcodeKit/Sources/Domain/LoopLineage.swift create mode 100644 GraphcodeKit/Sources/Domain/NodCompositePlan.swift create mode 100644 GraphcodeKit/Sources/Domain/NodEditablePlan.swift create mode 100644 GraphcodeKit/Sources/Domain/NodFork.swift create mode 100644 GraphcodeKit/Sources/Domain/NodGraphContext.swift create mode 100644 GraphcodeKit/Sources/Domain/NodGraphVerbs.swift diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index d1c59abd..416f66ac 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -157,6 +157,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { // treatment, for the same reason. subGraph: node.subGraph?.reIdentified(), pilotState: node.pilotState, + lineage: node.lineage, state: node.loopType == .goalBased ? .running : .idle) copy.nodes.append(fresh) } diff --git a/GraphcodeKit/Sources/Domain/LoopLineage.swift b/GraphcodeKit/Sources/Domain/LoopLineage.swift new file mode 100644 index 00000000..578ed302 --- /dev/null +++ b/GraphcodeKit/Sources/Domain/LoopLineage.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Where a loop came from when it was branched off another one rather than created fresh. +/// +/// Not an edge: a fork runs beside its source, it does not wait on it or report to it, and +/// an unknown `EdgeKind` would fail an older app's whole graph decode. The canvas draws a +/// `.fork` lineage as a dotted "forked from" line instead. +public struct LoopLineage: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { + /// A sibling started from one message of another loop's conversation, in its own + /// worktree, so two approaches race. + case fork + /// A child of a composite made from a Nod plan. + case compositeChild + } + + public var kind: Kind + public var sourceNodeID: UUID + /// The `NodBrief` the loop starts from, if it has one. + public var briefPath: String? + + public init(kind: Kind, sourceNodeID: UUID, briefPath: String? = nil) { + self.kind = kind + self.sourceNodeID = sourceNodeID + self.briefPath = briefPath + } +} + +extension LoopGraph { + /// Every fork in this graph as (source, fork) pairs whose source is still in the graph. + public var forkLinks: [(from: UUID, to: UUID)] { + nodes.compactMap { node in + guard let lineage = node.lineage, lineage.kind == .fork, + nodes[id: lineage.sourceNodeID] != nil + else { return nil } + return (lineage.sourceNodeID, node.id) + } + } +} diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 14f7da2d..6f6d1a91 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -208,6 +208,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// When the goal was last replaced; `nil` means it is still the one the loop was created /// with. A backend verdict recorded before this belongs to an earlier goal. public var goalSetAt: Date? + public var lineage: LoopLineage? public var state: LoopState public var createdAt: Date @@ -239,6 +240,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { lastMailroomRead: Int? = nil, mailroomWatch: MailroomWatch? = nil, stallReason: String? = nil, + lineage: LoopLineage? = nil, state: LoopState = .idle, createdAt: Date = Date() ) { @@ -269,6 +271,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.lastMailroomRead = lastMailroomRead self.mailroomWatch = mailroomWatch self.stallReason = stallReason + self.lineage = lineage self.state = state self.createdAt = createdAt } @@ -587,7 +590,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds, stallReason, attachments case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure, resolution - case pendingCompletion, goalSetAt + case pendingCompletion, goalSetAt, lineage } /// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph` @@ -654,6 +657,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { pendingCompletion = try? container.decodeIfPresent(LoopResolution.self, forKey: .pendingCompletion) goalSetAt = try? container.decodeIfPresent(Date.self, forKey: .goalSetAt) + lineage = try? container.decodeIfPresent(LoopLineage.self, forKey: .lineage) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } diff --git a/GraphcodeKit/Sources/Domain/NodCompositePlan.swift b/GraphcodeKit/Sources/Domain/NodCompositePlan.swift new file mode 100644 index 00000000..0456550a --- /dev/null +++ b/GraphcodeKit/Sources/Domain/NodCompositePlan.swift @@ -0,0 +1,181 @@ +import Foundation + +/// Plan → Composite: a Nod plan run as a composite whose children each take one area of +/// the code. +/// +/// Steps are grouped by the area they touch, steps that share an area land in one child, +/// and a step that touches two areas joins them. The done-check step is not a child: it +/// becomes the composite's check. Every child inherits the planning conversation as its +/// brief, so none of them starts cold. +public struct NodCompositePlan: Equatable, Sendable { + public struct Group: Equatable, Sendable { + /// The area's name, also the child's title. + public var area: String + public var steps: [NodPlanStep] + } + + public var title: String + public var groups: [Group] + public var doneChecks: [NodPlanStep] + + /// Directory names that say where code lives rather than what it is about. + static let genericRoots: Set = [ + "sources", "source", "src", "lib", "libs", "tests", "test", "spec", "specs", + "packages", "pkg", "internal", + ] + + public init(title: String, steps: [NodPlanStep]) { + self.title = title + doneChecks = steps.filter(\.doneCheck) + groups = Self.group(steps.filter { !$0.doneCheck }) + } + + /// The area a repository-relative path belongs to: its first directory that is not a + /// generic root, with a `…Tests` suffix folded onto the module it tests. A file at the + /// root, or under generic roots only, has the empty area. + public static func area(ofFile path: String) -> String { + var directories = path.split(separator: "/").dropLast().map(String.init) + while let first = directories.first, + first == "." || genericRoots.contains(first.lowercased()) + { + directories.removeFirst() + } + guard var area = directories.first else { return "" } + for suffix in ["Tests", "Test", "-tests", "_tests"] + where area.hasSuffix(suffix) && area.count > suffix.count { + area.removeLast(suffix.count) + break + } + return area + } + + /// Steps without files follow the step before them, since a plan reads top to bottom; + /// a leading step without files joins the first step that has some. + static func group(_ steps: [NodPlanStep]) -> [Group] { + guard !steps.isEmpty else { return [] } + var areasByStep = steps.map { Set($0.files.map(area(ofFile:))) } + if let firstWithFiles = areasByStep.firstIndex(where: { !$0.isEmpty }) { + for index in areasByStep.indices where areasByStep[index].isEmpty { + areasByStep[index] = index < firstWithFiles + ? areasByStep[firstWithFiles] : areasByStep[index - 1] + } + } else { + areasByStep = steps.map { _ in [""] } + } + + var parent = Array(steps.indices) + func root(_ index: Int) -> Int { + var index = index + while parent[index] != index { index = parent[index] } + return index + } + var firstStepForArea: [String: Int] = [:] + for (index, areas) in areasByStep.enumerated() { + for area in areas { + if let other = firstStepForArea[area] { + let (a, b) = (root(index), root(other)) + if a != b { parent[max(a, b)] = min(a, b) } + } else { + firstStepForArea[area] = index + } + } + } + + var order: [Int] = [] + var members: [Int: [Int]] = [:] + for index in steps.indices { + let group = root(index) + if members[group] == nil { order.append(group) } + members[group, default: []].append(index) + } + return order.map { group in + let indices = members[group] ?? [] + let areas = indices.flatMap { areasByStep[$0] } + var seen = Set() + let names = areas.filter { !$0.isEmpty && seen.insert($0).inserted } + return Group(area: names.first ?? "", steps: indices.map { steps[$0] }) + } + } + + /// The composite's check: what the done-check steps say, or nil without one. + public var check: String? { + let text = doneChecks.map(\.text).joined(separator: "\n") + return text.isEmpty ? nil : text + } + + /// The brief each child starts from: the whole plan with its own steps marked, and the + /// planning loop's transcript attached. + public func brief(for group: Group, plannedIn sourceNodeID: UUID) -> NodBrief { + let mine = Set(group.steps.map(\.id)) + var lines = ["You are one loop of a composite running the plan \"\(title)\".", ""] + var number = 0 + for step in groups.flatMap(\.steps) + doneChecks { + number += 1 + let marker = mine.contains(step.id) ? "→" : step.doneCheck ? "✓" : " " + let files = step.files.isEmpty ? "" : " (\(step.files.joined(separator: ", ")))" + lines.append("\(marker) \(number). \(step.text)\(files)") + } + lines.append("") + lines.append("→ marks your steps. Other loops take the rest; ✓ is how the composite is checked.") + if group.steps.contains(where: \.editedByHuman) { + lines.append("Steps a human rewrote are theirs: do them as written.") + } + lines.append("The planning conversation is attached for context.") + return NodBrief( + kind: .compositeChild, fromNodeID: sourceNodeID, text: lines.joined(separator: "\n"), + attachments: [NodAttachment(kind: .loopTranscript, reference: sourceNodeID.uuidString)]) + } + + /// The composite to create, plus the brief each child needs written at the path its + /// lineage names. `briefPath` maps a child's draft id to where its brief will live. + public func makeComposite( + plannedIn source: LoopNode, briefPath: (UUID) -> String + ) -> (draft: NodeDraft, briefs: [(path: String, brief: NodBrief)]) { + var subGraph = LoopGraph( + project: ProjectRef(path: "\(compositeTitle)-subgraph", name: compositeTitle)) + var briefs: [(String, NodBrief)] = [] + for (index, group) in groups.enumerated() { + let id = UUID() + let path = briefPath(id) + briefs.append((path, brief(for: group, plannedIn: source.id))) + let goal = group.steps.map { "- \($0.text)" }.joined(separator: "\n") + subGraph.nodes.append( + LoopNode( + id: id, + title: Self.childTitle(area: group.area, index: index, plan: title), + loopType: .goalBased, + goal: GoalSpec(summary: "Done when these steps are complete:\n\(goal)"), + backend: .nod, + worktreeBinding: source.worktreeBinding, + lineage: LoopLineage(kind: .compositeChild, sourceNodeID: source.id, briefPath: path), + state: .running)) + } + let draft = NodeDraft( + title: compositeTitle, + loopType: .composite, + checkDescription: check, + worktree: source.worktreeBinding, + subGraph: subGraph, + createdBy: source.id) + return (draft, briefs) + } + + /// Composite titles are one word, like every loop title. + var compositeTitle: String { LoopTitle.oneWord(title, fallback: "Plan") } + + static func childTitle(area: String, index: Int, plan: String) -> String { + let word = LoopTitle.oneWord(area, fallback: "") + return word.isEmpty ? "\(LoopTitle.oneWord(plan, fallback: "Plan"))\(index + 1)" : word + } +} + +/// CamelCase one-word titles, the shape the sidebar expects. +enum LoopTitle { + static func oneWord(_ text: String, fallback: String) -> String { + let words = text.split(whereSeparator: { !$0.isLetter && !$0.isNumber }) + .prefix(4) + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + let joined = words.joined() + return joined.isEmpty ? fallback : joined + } +} diff --git a/GraphcodeKit/Sources/Domain/NodEditablePlan.swift b/GraphcodeKit/Sources/Domain/NodEditablePlan.swift new file mode 100644 index 00000000..9ff564bf --- /dev/null +++ b/GraphcodeKit/Sources/Domain/NodEditablePlan.swift @@ -0,0 +1,63 @@ +import Foundation + +/// The plan card's editable copy of a `planProposed`. Nod sees a human's edits as edits: +/// a rewritten or added step is marked `editedByHuman`, so Nod does not argue it back. +public struct NodEditablePlan: Equatable, Sendable { + public var planID: String + public var title: String + public var steps: [NodPlanStep] + + public init(planID: String, title: String, steps: [NodPlanStep]) { + self.planID = planID + self.title = title + self.steps = steps + } + + public init(_ proposed: NodEvent.PlanProposed) { + self.init(planID: proposed.planID, title: proposed.title, steps: proposed.steps) + } + + public mutating func move(fromOffsets source: IndexSet, toOffset destination: Int) { + let moving = source.sorted().map { steps[$0] } + let before = source.filter { $0 < destination }.count + for index in source.sorted(by: >) { steps.remove(at: index) } + steps.insert(contentsOf: moving, at: destination - before) + } + + /// Unchanged text is not an edit, so clicking into a step and out again leaves it Nod's. + public mutating func rewrite(stepID: String, to text: String) { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let index = steps.firstIndex(where: { $0.id == stepID }), !text.isEmpty, + steps[index].text != text + else { return } + steps[index].text = text + steps[index].editedByHuman = true + } + + public mutating func remove(stepID: String) { + steps.removeAll { $0.id == stepID } + } + + public mutating func add(_ text: String) { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + let ids = Set(steps.map(\.id)) + var number = steps.count + 1 + while ids.contains("h\(number)") { number += 1 } + steps.append(NodPlanStep(id: "h\(number)", text: text, editedByHuman: true)) + } + + public mutating func toggleDoneCheck(stepID: String) { + guard let index = steps.firstIndex(where: { $0.id == stepID }) else { return } + steps[index].doneCheck.toggle() + } + + /// How many loops Run as Composite would make — the button's count. + public var compositeLoopCount: Int { + NodCompositePlan(title: title, steps: steps).groups.count + } + + public func runCommand(_ mode: NodCommand.RunPlan.Mode) -> NodCommand { + .runPlan(NodCommand.RunPlan(planID: planID, steps: steps, mode: mode)) + } +} diff --git a/GraphcodeKit/Sources/Domain/NodFork.swift b/GraphcodeKit/Sources/Domain/NodFork.swift new file mode 100644 index 00000000..8c49fc2e --- /dev/null +++ b/GraphcodeKit/Sources/Domain/NodFork.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Fork as a new sibling loop: same type and goal as the source, its own worktree on a +/// branch cut from the source's, and the source's conversation up to one message as its +/// brief. The two then run at once, joined on the canvas by a dotted "forked from" line. +public struct NodFork: Equatable, Sendable { + /// The worktree the app creates before sending the draft, mirroring the new-branch + /// path the node form uses: `/-`. + public struct WorktreeRequest: Equatable, Sendable { + public var repositoryPath: String + public var worktreePath: String + public var branch: String + /// What the branch starts from: the source's branch, so the fork sees its edits. + public var startPoint: String? + } + + public var draft: NodeDraft + public var brief: NodBrief + public var worktree: WorktreeRequest + + /// `conversationID` is the source's engine conversation (`conversation.json`), when + /// the app has it; the runtime forks that conversation after `messageID`. + public init( + of source: LoopNode, in graph: LoopGraph, atMessage messageID: String, + conversationID: String? = nil, approach: String? = nil, briefPath: String + ) { + let siblings = graph.nodes.filter { + $0.lineage?.kind == .fork && $0.lineage?.sourceNodeID == source.id + } + let number = siblings.count + 2 + let title = "\(source.title)\(number)" + let repository = source.worktreeBinding?.repositoryPath ?? graph.project.path + let baseBranch = source.worktreeBinding?.branch + let branchStem = baseBranch.flatMap { $0.isEmpty ? nil : $0 } ?? "nod/\(source.title)" + let branch = "\(branchStem)-fork\(number)" + let parentDirectory = (repository as NSString).deletingLastPathComponent + let repositoryName = (repository as NSString).lastPathComponent + let safeBranch = branch.replacingOccurrences(of: "/", with: "-") + worktree = WorktreeRequest( + repositoryPath: repository, + worktreePath: (parentDirectory as NSString) + .appendingPathComponent("\(repositoryName)-\(safeBranch)"), + branch: branch, + startPoint: baseBranch.flatMap { $0.isEmpty ? nil : $0 }) + + var text = "You are a fork of \(source.title), branched from its conversation." + if let approach = approach?.trimmingCharacters(in: .whitespacesAndNewlines), + !approach.isEmpty + { + text += " Take this approach: \(approach)" + } else { + text += " Try the approach it did not take." + } + text += " You work in your own worktree on \(branch); \(source.title) carries on in its own." + brief = NodBrief( + kind: .fork, fromNodeID: source.id, text: text, + fork: NodBrief.ForkPoint(conversationID: conversationID, messageID: messageID)) + + draft = NodeDraft( + title: title, + loopType: source.loopType == .composite ? .sketch : source.loopType, + checkDescription: source.checkDescription, + triggerPrompt: source.triggerPrompt, + heartbeatIntervalSeconds: source.heartbeatIntervalSeconds, + firstInstruction: source.firstInstruction, + pausesBeforeWritesOnly: source.pausesBeforeWritesOnly, + goal: source.goal, + backend: .nod, + modelTier: source.modelTier, + lineage: LoopLineage(kind: .fork, sourceNodeID: source.id, briefPath: briefPath)) + } +} diff --git a/GraphcodeKit/Sources/Domain/NodGraphContext.swift b/GraphcodeKit/Sources/Domain/NodGraphContext.swift new file mode 100644 index 00000000..f5c7a0a1 --- /dev/null +++ b/GraphcodeKit/Sources/Domain/NodGraphContext.swift @@ -0,0 +1,56 @@ +import Foundation + +/// The neighbours a Nod conversation can see, as the chat pane's context strip shows them: +/// who hands off to this loop, who it hands off to, and who it talks to beside the chain. +public struct NodGraphContext: Equatable, Sendable { + public struct Neighbour: Equatable, Identifiable, Sendable { + public var id: UUID + public var title: String + public var loopType: LoopType + public var state: LoopState + + public init(_ node: LoopNode) { + id = node.id + title = node.title + loopType = node.loopType + state = node.displayState + } + } + + public var nodeID: UUID + public var title: String + /// Upstream: sources of handoff edges into this loop. + public var from: [Neighbour] + /// Downstream: targets of handoff edges out of this loop. + public var to: [Neighbour] + /// Message peers in either direction, and forks of or from this loop. + public var beside: [Neighbour] + + /// A loop with no neighbours gets no strip at all. + public var isEmpty: Bool { from.isEmpty && to.isEmpty && beside.isEmpty } + + public init(nodeID: UUID, in graph: LoopGraph) { + self.nodeID = nodeID + title = graph.nodes[id: nodeID]?.title ?? "" + func neighbours(_ ids: [UUID]) -> [Neighbour] { + var seen = Set() + return ids.compactMap { id in + guard id != nodeID, seen.insert(id).inserted, let node = graph.nodes[id: id] else { + return nil + } + return Neighbour(node) + } + } + let sequencing = graph.edges.filter { $0.kind != .message } + from = neighbours(sequencing.filter { $0.to == nodeID }.map(\.from)) + to = neighbours(sequencing.filter { $0.from == nodeID }.map(\.to)) + let peers = graph.edges.filter { $0.kind == .message }.compactMap { edge -> UUID? in + edge.from == nodeID ? edge.to : edge.to == nodeID ? edge.from : nil + } + let forks = graph.forkLinks.compactMap { link -> UUID? in + link.from == nodeID ? link.to : link.to == nodeID ? link.from : nil + } + let chain = Set(from.map(\.id)).union(to.map(\.id)) + beside = neighbours(peers + forks).filter { !chain.contains($0.id) } + } +} diff --git a/GraphcodeKit/Sources/Domain/NodGraphVerbs.swift b/GraphcodeKit/Sources/Domain/NodGraphVerbs.swift new file mode 100644 index 00000000..f02ff892 --- /dev/null +++ b/GraphcodeKit/Sources/Domain/NodGraphVerbs.swift @@ -0,0 +1,256 @@ +import Foundation + +/// The composer's graph verbs — the slash commands that act on other loops rather than on +/// this conversation — parsed into the `GraphCommand`s they send. +public enum NodGraphVerb: Equatable, Sendable { + /// `/handoff [@Loop] [brief…]`: pass a brief downstream, or to the named loop. + case handoff(target: String?, brief: String) + /// `/ask @Loop `: message a sibling. + case ask(target: String, text: String) + /// `/promote goal ` · `/promote turn [writes]` · `/promote timed `. + case promote(SketchPromotion) + + public enum ParseError: Error, Equatable, Sendable { + case notAGraphVerb + case usage(String) + } + + public static let handoffUsage = "/handoff [@Loop] " + public static let askUsage = "/ask @Loop " + public static let promoteUsage = "/promote goal | turn [writes] | timed " + + public static func parse(_ line: String) -> Result { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + var words = trimmed.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let verb = words.first else { return .failure(.notAGraphVerb) } + let rest = words.count > 1 ? String(words.removeLast()) : "" + switch verb { + case "/handoff": + let (target, brief) = splitMention(rest) + return .success(.handoff(target: target, brief: brief)) + case "/ask": + let (target, text) = splitMention(rest) + guard let target, !text.isEmpty else { return .failure(.usage(askUsage)) } + return .success(.ask(target: target, text: text)) + case "/promote": + let parts = rest.split(separator: " ", maxSplits: 1).map(String.init) + let argument = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespaces) : "" + switch parts.first?.lowercased() { + case "goal" where !argument.isEmpty: + return .success(.promote(.goal(GoalSpec(summary: argument)))) + case "turn": + return .success(.promote(.turn(pausesBeforeWritesOnly: argument.lowercased() == "writes"))) + case "timed" where !argument.isEmpty, "time" where !argument.isEmpty: + return .success(.promote(.timed(triggerPrompt: argument))) + default: + return .failure(.usage(promoteUsage)) + } + default: + return .failure(.notAGraphVerb) + } + } + + private static func splitMention(_ text: String) -> (String?, String) { + guard text.hasPrefix("@") else { return (nil, text) } + let parts = text.dropFirst().split(separator: " ", maxSplits: 1).map(String.init) + let body = parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespaces) : "" + return (parts.first, body) + } + + public enum ResolveError: Error, Equatable, Sendable { + case unknownLoop(String) + case noDownstream + case emptyBrief + case notASketch + } + + /// The commands this verb sends from `nodeID`. Sent by a human from Nod's composer, so + /// they are attributed to the loop but not gated by `messagesOtherLoops`, which governs + /// what Nod does on its own. + public func commands(from nodeID: UUID, in graph: LoopGraph) -> Result< + [GraphCommand], ResolveError + > { + switch self { + case .handoff(let target, let brief): + let targets: [LoopNode] + if let target { + guard let node = NodGraphVerb.node(named: target, in: graph, excluding: nodeID) else { + return .failure(.unknownLoop(target)) + } + targets = [node] + } else { + targets = NodGraphVerb.downstream(of: nodeID, in: graph) + guard !targets.isEmpty else { return .failure(.noDownstream) } + } + guard !brief.isEmpty else { return .failure(.emptyBrief) } + return .success(NodGraphVerb.handoffCommands(from: nodeID, to: targets, brief: brief)) + case .ask(let target, let text): + guard let node = NodGraphVerb.node(named: target, in: graph, excluding: nodeID) else { + return .failure(.unknownLoop(target)) + } + return .success([.messageNode(node.id, text: text, from: nodeID, followUp: true)]) + case .promote(let promotion): + guard let node = graph.nodes[id: nodeID], + node.loopType == .sketch || node.loopType.retypeTarget == promotion.targetType + else { return .failure(.notASketch) } + return .success([.promoteNode(nodeID, promotion: promotion, promotedBy: nil)]) + } + } + + /// A handoff brief starts with this word so a receiving Nod renders it as a handoff and a + /// CLI reads it as one. + public static let handoffPrefix = "Handoff: " + + static func handoffCommands(from nodeID: UUID, to targets: [LoopNode], brief: String) + -> [GraphCommand] + { + targets.map { + .messageNode($0.id, text: handoffPrefix + brief, from: nodeID, followUp: true) + } + } + + static func downstream(of nodeID: UUID, in graph: LoopGraph) -> [LoopNode] { + graph.edges.filter { $0.from == nodeID && $0.kind == .handoff } + .compactMap { graph.nodes[id: $0.to] } + } + + /// Case-insensitive, exact before prefix — `@bill` finds Billing UI when nothing else + /// starts that way. + static func node(named name: String, in graph: LoopGraph, excluding nodeID: UUID) + -> LoopNode? + { + let wanted = name.lowercased() + let candidates = graph.nodes.filter { $0.id != nodeID } + if let exact = candidates.first(where: { $0.title.lowercased() == wanted }) { return exact } + let prefixed = candidates.filter { $0.title.lowercased().hasPrefix(wanted) } + return prefixed.count == 1 ? prefixed.first : nil + } +} + +// MARK: - Inbound + +/// Mail or a handoff that arrived in a Nod conversation from another loop. +/// +/// The daemon types every message as `[graphcode] : ` (and a bare handoff as +/// `[graphcode] finished.`), and the runtime echoes it as a `userMessage`. This is +/// what turns that back into who sent what. +public struct NodInboundMail: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case mail + case handoff + } + + public var messageID: String + public var kind: Kind + public var senderTitle: String + public var sender: NodGraphContext.Neighbour? + public var body: String + + public var isQuestion: Bool { body.contains("?") } + + static let prefix = "[graphcode] " + + /// Nil for a message a human typed, or a `[graphcode]` notice no loop sent. + public static func classify( + _ message: NodEvent.UserMessage, nodeID: UUID, in graph: LoopGraph + ) -> NodInboundMail? { + var senderTitle: String + var body: String + var senderNode: LoopNode? + if let fromNodeID = message.fromNodeID { + senderNode = graph.nodes[id: fromNodeID] + senderTitle = senderNode?.title ?? "" + body = message.text + if message.text.hasPrefix(prefix) { + let parsed = parse(message.text, in: graph) + body = parsed?.body ?? body + } + } else { + guard let parsed = parse(message.text, in: graph) else { return nil } + senderNode = parsed.node + senderTitle = parsed.title + body = parsed.body + } + let isHandoff: Bool + if body.hasPrefix(NodGraphVerb.handoffPrefix) { + body.removeFirst(NodGraphVerb.handoffPrefix.count) + isHandoff = true + } else if let senderNode { + isHandoff = graph.edges.contains { + $0.from == senderNode.id && $0.to == nodeID && $0.kind == .handoff + } + } else { + isHandoff = false + } + return NodInboundMail( + messageID: message.id, kind: isHandoff ? .handoff : .mail, senderTitle: senderTitle, + sender: senderNode.map(NodGraphContext.Neighbour.init), body: body) + } + + /// Titles may contain `: `, so the longest title that matches wins. + private static func parse(_ text: String, in graph: LoopGraph) + -> (title: String, node: LoopNode?, body: String)? + { + guard text.hasPrefix(prefix) else { return nil } + let rest = String(text.dropFirst(prefix.count)) + let byLength = graph.nodesAtAnyDepth.sorted { $0.title.count > $1.title.count } + for node in byLength { + if rest.hasPrefix(node.title + ": ") { + return (node.title, node, String(rest.dropFirst(node.title.count + 2))) + } + if rest == node.title + " finished." { + return (node.title, node, "") + } + } + return nil + } + + /// The reply Nod drafted for this message, if any — the newest one wins. + public func draft(in events: [NodEvent]) -> NodEvent.MailDraft? { + events.reversed().lazy.compactMap { event -> NodEvent.MailDraft? in + guard case .mailDraft(let draft) = event, draft.inReplyTo == messageID else { return nil } + return draft + }.first + } + + /// "Answer myself": the human's reply, sent as this loop without Nod. + public func answerCommand(from nodeID: UUID, text: String) -> GraphCommand? { + guard let sender else { return nil } + return .messageNode(sender.id, text: text, from: nodeID, followUp: true) + } +} + +// MARK: - Handoff offer + +/// "Goal holds — hand off to Release notes with a summary of what changed?" +public struct NodHandoffOffer: Equatable, Sendable { + public var nodeID: UUID + public var targets: [NodGraphContext.Neighbour] + /// The evaluator's evidence, one clause per item: `4 of 4 paid routes capped`. + public var evidence: [String] + public var suggestedBrief: String + + /// Offered only when the goal holds and something is downstream to receive it. + public static func make( + check: NodEvent.GoalCheck, nodeID: UUID, in graph: LoopGraph, summaries: [String] = [] + ) -> NodHandoffOffer? { + guard check.met else { return nil } + let targets = NodGraphVerb.downstream(of: nodeID, in: graph) + guard !targets.isEmpty else { return nil } + let evidence = check.clauses.map { $0.evidence ?? $0.text } + let brief = summaries.filter { !$0.isEmpty }.joined(separator: "\n") + return NodHandoffOffer( + nodeID: nodeID, targets: targets.map(NodGraphContext.Neighbour.init), + evidence: evidence, + suggestedBrief: brief.isEmpty ? check.clauses.map(\.text).joined(separator: "\n") : brief) + } + + /// Hands the brief to each downstream loop, then reports this loop's goal met with the + /// brief as its result, which fires its handoff edges. + public func commands(brief: String) -> [GraphCommand] { + let text = brief.trimmingCharacters(in: .whitespacesAndNewlines) + let targets = targets.map { LoopNode(id: $0.id, title: $0.title) } + return NodGraphVerb.handoffCommands(from: nodeID, to: targets, brief: text) + + [.completeNode(nodeID, result: text.split(separator: "\n").first.map(String.init), from: nil)] + } +} diff --git a/GraphcodeKit/Sources/Domain/NodProtocol.swift b/GraphcodeKit/Sources/Domain/NodProtocol.swift index 782a0aab..bd077619 100644 --- a/GraphcodeKit/Sources/Domain/NodProtocol.swift +++ b/GraphcodeKit/Sources/Domain/NodProtocol.swift @@ -86,16 +86,72 @@ public struct NodPlanStep: Codable, Equatable, Sendable { public var size: Size? /// Set once a human has rewritten the step, so Nod treats it as theirs. public var editedByHuman: Bool + /// The step that verifies the others. Run as Composite makes it the composite's check + /// rather than a child loop. + public var doneCheck: Bool public init( id: String, text: String, files: [String] = [], size: Size? = nil, - editedByHuman: Bool = false + editedByHuman: Bool = false, doneCheck: Bool = false ) { self.id = id self.text = text self.files = files self.size = size self.editedByHuman = editedByHuman + self.doneCheck = doneCheck + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + text = try container.decode(String.self, forKey: .text) + files = try container.decodeIfPresent([String].self, forKey: .files) ?? [] + size = try? container.decodeIfPresent(Size.self, forKey: .size) + editedByHuman = try container.decodeIfPresent(Bool.self, forKey: .editedByHuman) ?? false + doneCheck = try container.decodeIfPresent(Bool.self, forKey: .doneCheck) ?? false + } +} + +/// What a loop inherits from the loop it came from — a composite child from the planning +/// conversation, a fork from the message it branched at. Written as JSON by the app before +/// the loop is created (`LoopLineage.briefPath`); the launcher passes `--inherit ` on +/// a fresh start, never on a resume, and the runtime sends it as turn 1 with origin +/// `handoff`. +public struct NodBrief: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { + case compositeChild + case fork + } + + /// Where a fork branches: the parent's engine conversation, cut after `messageID`. + public struct ForkPoint: Codable, Equatable, Sendable { + public var conversationID: String? + public var messageID: String + + public init(conversationID: String? = nil, messageID: String) { + self.conversationID = conversationID + self.messageID = messageID + } + } + + public var v: Int + public var kind: Kind + public var fromNodeID: UUID + public var text: String + public var attachments: [NodAttachment] + public var fork: ForkPoint? + + public init( + kind: Kind, fromNodeID: UUID, text: String, attachments: [NodAttachment] = [], + fork: ForkPoint? = nil + ) { + self.v = NodProtocol.version + self.kind = kind + self.fromNodeID = fromNodeID + self.text = text + self.attachments = attachments + self.fork = fork } } diff --git a/GraphcodeKit/Sources/Domain/NodeDraft.swift b/GraphcodeKit/Sources/Domain/NodeDraft.swift index a5f45d12..ab2e2334 100644 --- a/GraphcodeKit/Sources/Domain/NodeDraft.swift +++ b/GraphcodeKit/Sources/Domain/NodeDraft.swift @@ -77,6 +77,7 @@ public struct NodeDraft: Codable, Equatable, Sendable { /// `TemplateFollow`. The text itself travels in the type's own field as /// the creation-time snapshot; this only says what to re-read next run. public var templateFollow: TemplateFollow? + public var lineage: LoopLineage? public init( id: UUID = UUID(), @@ -95,7 +96,8 @@ public struct NodeDraft: Codable, Equatable, Sendable { createdBy: UUID? = nil, attachments: [PromptAttachment] = [], createdFromTemplateID: UUID? = nil, - templateFollow: TemplateFollow? = nil + templateFollow: TemplateFollow? = nil, + lineage: LoopLineage? = nil ) { self.id = id self.title = title @@ -114,6 +116,7 @@ public struct NodeDraft: Codable, Equatable, Sendable { self.attachments = attachments self.createdFromTemplateID = createdFromTemplateID self.templateFollow = templateFollow + self.lineage = lineage } /// docs/08-quality-and-token-budgets.md wants the cheap-to-ignore version of each @@ -223,6 +226,7 @@ public struct NodeDraft: Codable, Equatable, Sendable { createdBy: createdBy, createdFromTemplateID: createdFromTemplateID, templateFollow: loopType == .timeBased || loopType == .composite ? templateFollow : nil, + lineage: lineage, state: loopType == .goalBased ? .running : .idle) } } @@ -232,7 +236,7 @@ extension NodeDraft { case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier case worktree, subGraph, createdBy, firstInstruction, pausesBeforeWritesOnly case heartbeatIntervalSeconds, attachments - case createdFromTemplateID, templateFollow + case createdFromTemplateID, templateFollow, lineage } /// `id` is `decodeIfPresent` because drafts also arrive over the wire from a CLI that @@ -267,5 +271,6 @@ extension NodeDraft { try container.decodeIfPresent(UUID.self, forKey: .createdFromTemplateID) templateFollow = try container.decodeIfPresent(TemplateFollow.self, forKey: .templateFollow) + lineage = try? container.decodeIfPresent(LoopLineage.self, forKey: .lineage) } } From 7bdb150ffb0800b4f1955b57b6ff4871c9a7ec38 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:31:21 -0700 Subject: [PATCH 3/6] Add the graphcode MCP server and Nod graph layer tests Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- NodRuntime/PROTOCOL.md | 42 +- NodRuntime/src/mcp/daemon.ts | 179 +++++++++ NodRuntime/src/mcp/graph.ts | 160 ++++++++ NodRuntime/src/mcp/index.ts | 12 + NodRuntime/src/mcp/mcp.test.ts | 277 +++++++++++++ NodRuntime/src/mcp/tools.ts | 282 ++++++++++++++ graphcode/Tests/NodGraphLayerTests.swift | 470 +++++++++++++++++++++++ 7 files changed, 1421 insertions(+), 1 deletion(-) create mode 100644 NodRuntime/src/mcp/daemon.ts create mode 100644 NodRuntime/src/mcp/graph.ts create mode 100644 NodRuntime/src/mcp/index.ts create mode 100644 NodRuntime/src/mcp/mcp.test.ts create mode 100644 NodRuntime/src/mcp/tools.ts create mode 100644 graphcode/Tests/NodGraphLayerTests.swift diff --git a/NodRuntime/PROTOCOL.md b/NodRuntime/PROTOCOL.md index 7767833a..2aaaaa8d 100644 --- a/NodRuntime/PROTOCOL.md +++ b/NodRuntime/PROTOCOL.md @@ -27,7 +27,7 @@ Every line carries `v`, `seq` (strictly increasing from 1) and `at`. | `goalCheck` | `turn`, `evaluatorModel`, `clauses[{text, met, evidence?}]`, `met` | run each time Nod tries to stop | | `turnEnded` | `turn`, `filesChanged`, `added`, `removed`, `summary?` | | | `usage` | `inputTokens`, `outputTokens`, `costUSD?`, `premiumRequests?`, `contextUsed` (0…1) | | -| `planProposed` | `planID`, `title`, `steps[{id, text, files[], size?, editedByHuman}]` | | +| `planProposed` | `planID`, `title`, `steps[{id, text, files[], size?, editedByHuman, doneCheck}]` | `files` are repository-relative, so Run as Composite can group steps by area; `doneCheck` (absent = false) marks the step that verifies the others | | `mailDraft` | `draftID`, `toNodeID`, `inReplyTo?`, `text` | sent only on `sendDraft` | | `compacted` | `fromTurn`, `throughTurn` | | | `activity` | `line` | the canvas card's live line | @@ -50,6 +50,46 @@ Every line carries `v`, `seq` (strictly increasing from 1) and `at`. The runtime answers each command line with `{"ok":true}` or `{"ok":false,"error":"…"}`. +## Inherited briefs — `--inherit ` + +A composite child made from a plan, and a fork, start from another loop's conversation. The +app writes a `NodBrief` to `/nod/briefs/.json` before creating the loop +and records the path in `LoopNode.lineage.briefPath`. The launcher passes +`--inherit ` on a fresh start only, never with `--resume`. The runtime sends the +brief as turn 1 with origin `handoff`. + +| field | notes | +|---|---| +| `v` | protocol version | +| `kind` | `compositeChild` or `fork` | +| `fromNodeID` | the loop it came from | +| `text` | the brief itself | +| `attachments[]` | `NodAttachment`s, usually the source's `loopTranscript` | +| `fork?` | `{conversationID?, messageID}`: resume the source's engine conversation forked after `messageID` (Claude Agent SDK `resume` + `forkSession`), instead of summarising it | + +## The graphcode MCP server + +`src/mcp/` is the built-in server Nod always mounts (`serverName` = `graphcode`). +`createGraphcodeTools(ctx)` returns engine-neutral tool definitions for the runtime to +adapt. It reads and writes through graphcoded's socket (`$GRAPHCODE_SOCKET`, else +`$GRAPHCODE_SUPPORT_DIR/graphcoded.sock`), the same RPC the CLI speaks. It never reads +graph files. `ctx.projectPath` comes from `$NOD_PROJECT_PATH`. + +| tool | does | +|---|---| +| `siblings` | other loops at this loop's level: type, state, brief, live line, relation (`from` `to` `beside` `none`) | +| `edges` | edges touching this loop, or all of them | +| `handoff_briefs` | upstream sources: what each was handed, its result, what the edge carries | +| `mailroom`, `mailroom_read` | the board, read-only; the read cursor never moves | +| `ask` | message a loop, optionally `inReplyTo` a message id | +| `handoff` | send `Handoff: ` downstream, or to a named loop | + +`ask` and `handoff` follow `NodSettings.messagesOtherLoops`, read on every call. +`draftForMe` emits `mailDraft` and sends nothing. `send` sends `messageNode` as this loop. +`never` refuses. The runtime answers `sendDraft` with `sendDraft(ctx, {toNodeID, text})`. +`classifyInbound(line, graph, nodeID)` maps a typed `[graphcode] : …` line to the +sending loop, so a drafted reply goes to the right place. + ## Example ```json diff --git a/NodRuntime/src/mcp/daemon.ts b/NodRuntime/src/mcp/daemon.ts new file mode 100644 index 00000000..e001f072 --- /dev/null +++ b/NodRuntime/src/mcp/daemon.ts @@ -0,0 +1,179 @@ +import { connect, type Socket } from "node:net"; +import { homedir } from "node:os"; +import { isAbsolute, join } from "node:path"; + +/** + * A client for graphcoded's socket — the same RPC the `graphcode` CLI and the remote shim + * speak. graphcoded owns the graph, so reads come from its snapshot and writes go through + * its permission and mailroom paths rather than around them. + * + * Frames are a 4-byte big-endian length then JSON, both ways. Commands use Swift's + * synthesized enum coding: `{"caseName": {"label": value, "_0": unlabelled}}`. + */ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +export type DaemonEvent = Record; + +export function socketPath(env: Record = process.env): string { + if (env.GRAPHCODE_SOCKET) return expandHome(env.GRAPHCODE_SOCKET); + const configured = env.GRAPHCODE_SUPPORT_DIR?.trim(); + const support = !configured + ? join(homedir(), ".graphcode") + : isAbsolute(expandHome(configured)) + ? expandHome(configured) + : join(homedir(), configured); + return join(support, "graphcoded.sock"); +} + +function expandHome(path: string): string { + return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path; +} + +export function encodeFrame(value: Json): Buffer { + const body = Buffer.from(JSON.stringify(value), "utf8"); + const header = Buffer.alloc(4); + header.writeUInt32BE(body.length, 0); + return Buffer.concat([header, body]); +} + +/** Splits a byte stream into frames, keeping a torn tail for the next chunk. */ +export class FrameReader { + private buffer = Buffer.alloc(0); + + push(chunk: Buffer): DaemonEvent[] { + this.buffer = Buffer.concat([this.buffer, chunk]); + const frames: DaemonEvent[] = []; + while (this.buffer.length >= 4) { + const length = this.buffer.readUInt32BE(0); + if (this.buffer.length < 4 + length) break; + const body = this.buffer.subarray(4, 4 + length).toString("utf8"); + this.buffer = this.buffer.subarray(4 + length); + frames.push(JSON.parse(body) as DaemonEvent); + } + return frames; + } +} + +export class DaemonError extends Error {} + +/** One conversation with the daemon: send a command, wait for an event it answers with. */ +export class DaemonConnection { + private reader = new FrameReader(); + private queue: DaemonEvent[] = []; + private waiters: Array<() => void> = []; + private closed: Error | null = null; + + private constructor(private socket: Socket) { + socket.on("data", (chunk: Buffer) => { + this.queue.push(...this.reader.push(chunk)); + this.wake(); + }); + socket.on("error", (error) => this.close(error)); + socket.on("close", () => this.close(new DaemonError("graphcoded closed the connection"))); + } + + static open(path: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect(path); + const timer = setTimeout(() => { + socket.destroy(); + reject(new DaemonError(`graphcoded did not answer at ${path}`)); + }, timeoutMs); + socket.once("connect", () => { + clearTimeout(timer); + resolve(new DaemonConnection(socket)); + }); + socket.once("error", (error) => { + clearTimeout(timer); + reject(new DaemonError(`graphcoded is not reachable at ${path}: ${error.message}`)); + }); + }); + } + + send(command: Json): void { + this.socket.write(encodeFrame(command)); + } + + /** The first event carrying one of `keys`; unsolicited events in between are skipped. */ + async waitFor(keys: string[], timeoutMs: number): Promise<[string, Json]> { + const deadline = Date.now() + timeoutMs; + for (;;) { + while (this.queue.length > 0) { + const event = this.queue.shift()!; + const key = keys.find((k) => k in event); + if (key) return [key, event[key]]; + } + if (this.closed) throw this.closed; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new DaemonError(`graphcoded sent no ${keys.join(" or ")}`); + await new Promise((resolve) => { + const timer = setTimeout(resolve, remaining); + this.waiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + } + } + + close(error: Error = new DaemonError("closed")): void { + if (!this.closed) this.closed = error; + this.socket.destroy(); + this.wake(); + } + + private wake(): void { + const waiters = this.waiters; + this.waiters = []; + for (const wake of waiters) wake(); + } +} + +/** The calls the graphcode MCP server makes. A fresh connection per call, as the CLI does. */ +export interface GraphDaemon { + snapshot(projectPath: string): Promise; + mailbox(projectPath: string, query: Json): Promise; + graphCommand(projectPath: string, command: Json): Promise; +} + +export function daemonClient(path = socketPath(), timeoutMs = 10_000): GraphDaemon { + async function withConnection(body: (connection: DaemonConnection) => Promise): Promise { + const connection = await DaemonConnection.open(path, timeoutMs); + try { + return await body(connection); + } finally { + connection.close(); + } + } + + async function openProject(connection: DaemonConnection, projectPath: string): Promise { + connection.send({ openProject: { path: projectPath } }); + const [key, value] = await connection.waitFor(["graphChanged", "errorOccurred"], timeoutMs); + if (key === "errorOccurred") throw new DaemonError(unlabelled(value)); + return (value as { _0: Json })._0; + } + + return { + snapshot: (projectPath) => withConnection((connection) => openProject(connection, projectPath)), + mailbox: (projectPath, query) => + withConnection(async (connection) => { + connection.send({ mailbox: { projectPath, query } }); + const [key, value] = await connection.waitFor(["mailbox", "errorOccurred"], timeoutMs); + if (key === "errorOccurred") throw new DaemonError(unlabelled(value)); + const wrapped = value as { mailbox?: Json; _0?: Json }; + return wrapped.mailbox ?? wrapped._0 ?? value; + }), + graphCommand: (projectPath, command) => + withConnection(async (connection) => { + await openProject(connection, projectPath); + connection.send({ graphCommand: { projectPath, command } }); + // The daemon judges delivery before it broadcasts, so the first answer is the verdict. + const [key, value] = await connection.waitFor(["graphChanged", "errorOccurred"], timeoutMs); + if (key === "errorOccurred") throw new DaemonError(unlabelled(value)); + }), + }; +} + +function unlabelled(value: Json): string { + const message = (value as { _0?: Json } | null)?._0; + return typeof message === "string" ? message : JSON.stringify(value); +} diff --git a/NodRuntime/src/mcp/graph.ts b/NodRuntime/src/mcp/graph.ts new file mode 100644 index 00000000..44d6af74 --- /dev/null +++ b/NodRuntime/src/mcp/graph.ts @@ -0,0 +1,160 @@ +/** + * Reading a graphcoded `LoopGraph` snapshot from Nod's seat in it. Mirrors the Swift side + * (`NodGraphContext`, `NodInboundMail`, `NodGraphVerb`) so the chat pane and the agent + * agree on who is upstream, downstream and beside. + */ + +export interface WireNode { + id: string; + title: string; + loopType: string; + state: unknown; + backend?: string; + goal?: { summary?: string }; + firstInstruction?: string; + triggerPrompt?: string; + checkDescription?: string; + activity?: string; + resolution?: { basis?: unknown; detail?: string }; + lineage?: { kind: string; sourceNodeID: string; briefPath?: string }; + subGraph?: WireGraph; +} + +export interface WireEdge { + id: string; + from: string; + to: string; + kind: "handoff" | "message" | "spawn" | string; + condition?: unknown; + payloadTransform?: unknown; + fireCount?: number; +} + +export interface WireGraph { + nodes: WireNode[]; + edges: WireEdge[]; +} + +export type Relation = "from" | "to" | "beside" | "none"; + +const same = (a: string | undefined, b: string | undefined) => + !!a && !!b && a.toLowerCase() === b.toLowerCase(); + +/** Swift encodes enum states as `{"running": {}}`. */ +export function stateName(state: unknown): string { + if (typeof state === "string") return state; + if (state && typeof state === "object") return Object.keys(state)[0] ?? "unknown"; + return "unknown"; +} + +/** The graph level holding `nodeID` — the root, or a composite's sub-graph. */ +export function levelOf(graph: WireGraph, nodeID: string): WireGraph | undefined { + if (graph.nodes.some((node) => same(node.id, nodeID))) return graph; + for (const node of graph.nodes) { + const found = node.subGraph && levelOf(node.subGraph, nodeID); + if (found) return found; + } + return undefined; +} + +export function allNodes(graph: WireGraph): WireNode[] { + return graph.nodes.flatMap((node) => [node, ...(node.subGraph ? allNodes(node.subGraph) : [])]); +} + +export function oneLine(text: string | undefined, limit = 160): string | undefined { + const line = text?.split("\n").find((l) => l.trim().length > 0)?.trim(); + if (!line) return undefined; + return line.length > limit ? `${line.slice(0, limit - 1)}…` : line; +} + +export function brief(node: WireNode): string | undefined { + return oneLine(node.goal?.summary ?? node.firstInstruction ?? node.triggerPrompt ?? node.checkDescription); +} + +export function relationOf(level: WireGraph, nodeID: string, otherID: string): Relation { + for (const edge of level.edges) { + const sequencing = edge.kind !== "message"; + if (same(edge.from, otherID) && same(edge.to, nodeID)) return sequencing ? "from" : "beside"; + if (same(edge.from, nodeID) && same(edge.to, otherID)) return sequencing ? "to" : "beside"; + } + const other = level.nodes.find((n) => same(n.id, otherID)); + const me = level.nodes.find((n) => same(n.id, nodeID)); + const forked = + (other?.lineage?.kind === "fork" && same(other.lineage.sourceNodeID, nodeID)) || + (me?.lineage?.kind === "fork" && same(me.lineage.sourceNodeID, otherID)); + return forked ? "beside" : "none"; +} + +export function downstream(graph: WireGraph, nodeID: string): WireNode[] { + const level = levelOf(graph, nodeID); + if (!level) return []; + return level.edges + .filter((edge) => edge.kind === "handoff" && same(edge.from, nodeID)) + .map((edge) => level.nodes.find((node) => same(node.id, edge.to))) + .filter((node): node is WireNode => !!node); +} + +/** Exact title (any case), then a unique prefix, then an id — `@bill` finds BillingUI. */ +export function resolveLoop(graph: WireGraph, name: string, excluding: string): WireNode | undefined { + const wanted = name.replace(/^@/, "").toLowerCase(); + const candidates = allNodes(graph).filter((node) => !same(node.id, excluding)); + const exact = candidates.find((node) => node.title.toLowerCase() === wanted); + if (exact) return exact; + const prefixed = candidates.filter((node) => node.title.toLowerCase().startsWith(wanted)); + if (prefixed.length === 1) return prefixed[0]; + return candidates.find((node) => node.id.toLowerCase() === wanted); +} + +export const handoffPrefix = "Handoff: "; +const notice = "[graphcode] "; + +export interface InboundMail { + kind: "mail" | "handoff"; + senderID: string; + senderTitle: string; + body: string; +} + +/** + * Who a typed `[graphcode] : ` line came from, so a reply can be drafted to + * the right loop. Nil for a human's line or a notice no loop sent. + */ +export function classifyInbound(text: string, graph: WireGraph, nodeID: string): InboundMail | undefined { + if (!text.startsWith(notice)) return undefined; + const rest = text.slice(notice.length); + const byLength = [...allNodes(graph)].sort((a, b) => b.title.length - a.title.length); + for (const node of byLength) { + let body: string | undefined; + if (rest.startsWith(`${node.title}: `)) body = rest.slice(node.title.length + 2); + else if (rest === `${node.title} finished.`) body = ""; + if (body === undefined) continue; + let kind: InboundMail["kind"] = "mail"; + if (body.startsWith(handoffPrefix)) { + body = body.slice(handoffPrefix.length); + kind = "handoff"; + } else { + const level = levelOf(graph, nodeID); + if (level?.edges.some((e) => e.kind === "handoff" && same(e.from, node.id) && same(e.to, nodeID))) { + kind = "handoff"; + } + } + return { kind, senderID: node.id, senderTitle: node.title, body }; + } + return undefined; +} + +/** What an inbound handoff edge carries, in words. */ +export function payloadDescription(transform: unknown): string { + if (!transform || typeof transform !== "object") return "a note that the source finished"; + const [kind, value] = Object.entries(transform as Record)[0] ?? []; + const inner = (value as { _0?: unknown } | undefined)?._0; + if (kind === "template" && typeof inner === "string") return `the brief: ${inner}`; + if (kind === "script") return "the output of a script run when the source finishes"; + return "a note that the source finished"; +} + +/** Foundation encodes dates as seconds since 2001-01-01. */ +export function referenceDate(seconds: unknown): string | undefined { + if (typeof seconds !== "number") return undefined; + return new Date((seconds + 978_307_200) * 1000).toISOString(); +} diff --git a/NodRuntime/src/mcp/index.ts b/NodRuntime/src/mcp/index.ts new file mode 100644 index 00000000..539946d8 --- /dev/null +++ b/NodRuntime/src/mcp/index.ts @@ -0,0 +1,12 @@ +export { daemonClient, socketPath, DaemonError, type GraphDaemon } from "./daemon"; +export { classifyInbound, type InboundMail, type WireGraph } from "./graph"; +export { + createGraphcodeTools, + sendDraft, + serverName, + type GraphcodeTool, + type GraphcodeToolContext, + type MailDraftEvent, + type MessagePolicy, + type ToolResult, +} from "./tools"; diff --git a/NodRuntime/src/mcp/mcp.test.ts b/NodRuntime/src/mcp/mcp.test.ts new file mode 100644 index 00000000..a50dd3c2 --- /dev/null +++ b/NodRuntime/src/mcp/mcp.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { daemonClient, encodeFrame, FrameReader, socketPath, type GraphDaemon, type Json } from "./daemon"; +import { classifyInbound, levelOf, relationOf, resolveLoop, type WireGraph } from "./graph"; +import { createGraphcodeTools, sendDraft, type MailDraftEvent, type MessagePolicy } from "./tools"; + +const ME = "11111111-1111-1111-1111-111111111111"; +const PRICING = "22222222-2222-2222-2222-222222222222"; +const NOTES = "33333333-3333-3333-3333-333333333333"; +const BILLING = "44444444-4444-4444-4444-444444444444"; +const FORK = "55555555-5555-5555-5555-555555555555"; +const CHILD = "66666666-6666-6666-6666-666666666666"; + +const graph: WireGraph = { + nodes: [ + { id: ME, title: "Monetization", loopType: "goalBased", state: { running: {} }, goal: { summary: "every paid route is capped" }, activity: "Running swift test" }, + { + id: PRICING, title: "Pricing", loopType: "goalBased", state: { succeeded: {} }, + goal: { summary: "Decide the free tier" }, resolution: { detail: "50 exports a month, 402 over the cap" }, + }, + { id: NOTES, title: "ReleaseNotes", loopType: "turnBased", state: { idle: {} }, firstInstruction: "Write the notes" }, + { id: BILLING, title: "BillingUI", loopType: "turnBased", state: { running: {} } }, + { id: FORK, title: "Monetization2", loopType: "goalBased", state: { running: {} }, lineage: { kind: "fork", sourceNodeID: ME } }, + { + id: "77777777-7777-7777-7777-777777777777", title: "Caps", loopType: "proactive", state: { running: {} }, + subGraph: { nodes: [{ id: CHILD, title: "Server", loopType: "goalBased", state: { running: {} } }], edges: [] }, + }, + ], + edges: [ + { id: "e1", from: PRICING, to: ME, kind: "handoff", condition: "always", payloadTransform: { template: { _0: "Free tier is 50" } }, fireCount: 1 }, + { id: "e2", from: ME, to: NOTES, kind: "handoff", condition: "always", payloadTransform: { none: {} }, fireCount: 0 }, + { id: "e3", from: BILLING, to: ME, kind: "message", condition: "always", fireCount: 0 }, + ], +}; + +class FakeDaemon implements GraphDaemon { + commands: Json[] = []; + queries: Json[] = []; + posts: Array> = [ + { id: 1, at: 0, author: "Pricing", topic: "nod", body: "free tier decided", kind: "notice" }, + { id: 2, at: 60, author: "BillingUI", body: "banner wip", kind: "notice" }, + ]; + async snapshot() { + return graph as unknown as Json; + } + async mailbox(_: string, query: Json) { + this.queries.push(query); + const selection = (query as { selection: Record }).selection; + if ("post" in selection) { + const id = (selection.post as { id: number }).id; + return { posts: this.posts.filter((p) => p.id === id) } as Json; + } + return { posts: this.posts } as Json; + } + async graphCommand(_: string, command: Json) { + this.commands.push(command); + } +} + +function tools(policy: MessagePolicy, daemon = new FakeDaemon()) { + const drafts: MailDraftEvent[] = []; + let n = 0; + const all = createGraphcodeTools({ + nodeID: ME, + projectPath: "/work/repo", + daemon, + messagesOtherLoops: () => policy, + emit: (event) => drafts.push(event), + newID: () => `d${++n}`, + }); + const call = (name: string, args: Record = {}) => all.find((t) => t.name === name)!.handler(args); + return { all, call, drafts, daemon }; +} + +describe("graph", () => { + test("relations read from, to and beside, forks included", () => { + const level = levelOf(graph, ME)!; + expect(relationOf(level, ME, PRICING)).toBe("from"); + expect(relationOf(level, ME, NOTES)).toBe("to"); + expect(relationOf(level, ME, BILLING)).toBe("beside"); + expect(relationOf(level, ME, FORK)).toBe("beside"); + }); + + test("a composite child's level is its sub-graph", () => { + expect(levelOf(graph, CHILD)?.nodes.map((n) => n.title)).toEqual(["Server"]); + }); + + test("loops resolve by title, unique prefix or id", () => { + expect(resolveLoop(graph, "@billingui", ME)?.id).toBe(BILLING); + expect(resolveLoop(graph, "rel", ME)?.id).toBe(NOTES); + expect(resolveLoop(graph, "Mon", ME)?.id).toBe(FORK); + expect(resolveLoop(graph, "Nobody", ME)).toBeUndefined(); + expect(resolveLoop(graph, "server", ME)?.id).toBe(CHILD); + }); + + test("inbound lines name their sender and kind", () => { + expect(classifyInbound("[graphcode] BillingUI: what does /export return?", graph, ME)).toEqual({ + kind: "mail", senderID: BILLING, senderTitle: "BillingUI", body: "what does /export return?", + }); + expect(classifyInbound("[graphcode] Pricing finished.", graph, ME)?.kind).toBe("handoff"); + expect(classifyInbound("[graphcode] BillingUI: Handoff: banner done", graph, ME)).toMatchObject({ kind: "handoff", body: "banner done" }); + expect(classifyInbound("[graphcode] Stop requested from the graph.", graph, ME)).toBeUndefined(); + expect(classifyInbound("fix /export", graph, ME)).toBeUndefined(); + }); +}); + +describe("read-only tools", () => { + test("the server mounts the read tools and ask/handoff", () => { + const { all } = tools("draftForMe"); + expect(all.map((t) => t.name)).toEqual(["siblings", "edges", "handoff_briefs", "mailroom", "mailroom_read", "ask", "handoff"]); + expect(all.filter((t) => !t.readOnly).map((t) => t.name)).toEqual(["ask", "handoff"]); + }); + + test("siblings lists the other loops at this level with their relation", async () => { + const { text } = await tools("draftForMe").call("siblings"); + expect(text).toContain(`Pricing (${PRICING}) | goalBased · succeeded | relation: from | brief: Decide the free tier`); + expect(text).toContain("ReleaseNotes"); + expect(text).toContain("relation: to"); + expect(text).toContain("Monetization2"); + expect(text).not.toContain(`Monetization (${ME})`); + expect(text).not.toContain("Server"); + }); + + test("edges shows only this loop's unless all", async () => { + const mine = await tools("draftForMe").call("edges"); + expect(mine.text.split("\n")).toEqual([ + "- Pricing → Monetization · handoff · always · fired 1×", + "- Monetization → ReleaseNotes · handoff · always · not fired", + "- BillingUI → Monetization · message · always · not fired", + ]); + }); + + test("handoff briefs carry the source's result and the edge's payload", async () => { + const { text } = await tools("draftForMe").call("handoff_briefs"); + expect(text).toContain("Pricing"); + expect(text).toContain("handed off"); + expect(text).toContain("result: 50 exports a month, 402 over the cap"); + expect(text).toContain("the brief: Free tier is 50"); + }); + + test("the mailroom is read without moving the cursor", async () => { + const t = tools("draftForMe"); + const list = await t.call("mailroom", { search: "tier", unread: true, limit: 1 }); + expect(list.text).toBe("#2 BillingUI · 2001-01-01T00:01:00.000Z: banner wip"); + expect(t.daemon.queries[0]).toEqual({ selection: { unread: { reader: ME } }, search: "tier", fullBodies: false, advanceCursor: false }); + const one = await t.call("mailroom_read", { id: 1 }); + expect(one.text).toContain("#1 (nod) Pricing"); + expect(t.daemon.queries[1]).toMatchObject({ selection: { post: { id: 1 } }, fullBodies: true, advanceCursor: false }); + expect((await t.call("mailroom_read", { id: 9 })).isError).toBe(true); + }); +}); + +describe("ask and handoff are gated by messagesOtherLoops", () => { + test("Draft for me emits a mailDraft and sends nothing", async () => { + const t = tools("draftForMe"); + const result = await t.call("ask", { to: "billing", text: "402 with { limit, resetsAt }", inReplyTo: "u7" }); + expect(result.isError).toBeUndefined(); + expect(t.drafts).toEqual([{ type: "mailDraft", draftID: "d1", toNodeID: BILLING, inReplyTo: "u7", text: "402 with { limit, resetsAt }" }]); + expect(t.daemon.commands).toEqual([]); + }); + + test("Send messages the loop through the daemon as this loop", async () => { + const t = tools("send"); + await t.call("ask", { to: "BillingUI", text: "done?" }); + expect(t.drafts).toEqual([]); + expect(t.daemon.commands).toEqual([{ messageNode: { _0: BILLING, text: "done?", from: ME, followUp: true } }]); + }); + + test("Never refuses and does nothing", async () => { + const t = tools("never"); + const ask = await t.call("ask", { to: "BillingUI", text: "x" }); + const handoff = await t.call("handoff", { brief: "x" }); + expect(ask.isError).toBe(true); + expect(handoff.isError).toBe(true); + expect(ask.text).toContain("Settings › Agents › Nod"); + expect(t.drafts).toEqual([]); + expect(t.daemon.commands).toEqual([]); + }); + + test("handoff goes downstream by default, or to the named loop", async () => { + const t = tools("send"); + await t.call("handoff", { brief: "Moved /export into the paid group." }); + await t.call("handoff", { brief: "check the 402", to: "BillingUI" }); + expect(t.daemon.commands).toEqual([ + { messageNode: { _0: NOTES, text: "Handoff: Moved /export into the paid group.", from: ME, followUp: true } }, + { messageNode: { _0: BILLING, text: "Handoff: check the 402", from: ME, followUp: true } }, + ]); + const drafted = tools("draftForMe"); + await drafted.call("handoff", { brief: "summary" }); + expect(drafted.drafts.map((d) => [d.toNodeID, d.text, d.inReplyTo])).toEqual([[NOTES, "Handoff: summary", undefined]]); + }); + + test("unknown loops and empty text are errors, not sends", async () => { + const t = tools("send"); + expect((await t.call("ask", { to: "Nobody", text: "x" })).isError).toBe(true); + expect((await t.call("ask", { to: "BillingUI", text: " " })).isError).toBe(true); + expect((await t.call("handoff", { brief: "" })).isError).toBe(true); + expect(t.daemon.commands).toEqual([]); + }); +}); + +describe("daemon wire", () => { + let server: Server | undefined; + let dir: string | undefined; + afterEach(() => { + server?.close(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + async function fakeGraphcoded(answer: (command: Record) => Json[]): Promise<{ path: string; seen: Json[] }> { + dir = mkdtempSync(join(tmpdir(), "nodmcp-")); + const path = join(dir, "d.sock"); + const seen: Json[] = []; + server = createServer((socket) => { + const reader = new FrameReader(); + socket.write(encodeFrame({ nodesChanged: { _0: [] } })); + socket.on("data", (chunk: Buffer) => { + for (const command of reader.push(chunk)) { + seen.push(command as Json); + for (const event of answer(command as Record)) socket.write(encodeFrame(event)); + } + }); + }); + await new Promise((resolve) => server!.listen(path, resolve)); + return { path, seen }; + } + + test("frames split and join across chunk boundaries", () => { + const bytes = Buffer.concat([encodeFrame({ a: 1 }), encodeFrame({ b: "ü" })]); + const reader = new FrameReader(); + expect(reader.push(bytes.subarray(0, 3))).toEqual([]); + expect(reader.push(bytes.subarray(3, 12))).toEqual([{ a: 1 }]); + expect(reader.push(bytes.subarray(12))).toEqual([{ b: "ü" }]); + }); + + test("a snapshot opens the project and skips unsolicited events", async () => { + const { path, seen } = await fakeGraphcoded((c) => ("openProject" in c ? [{ graphChanged: { _0: graph as unknown as Json } }] : [])); + const snapshot = (await daemonClient(path, 2000).snapshot("/work/repo")) as unknown as WireGraph; + expect(snapshot.nodes.length).toBe(graph.nodes.length); + expect(seen).toEqual([{ openProject: { path: "/work/repo" } }]); + }); + + test("a message is sent after opening the project, and a refusal is thrown", async () => { + let refuse = false; + const { path, seen } = await fakeGraphcoded((c) => { + if ("openProject" in c) return [{ graphChanged: { _0: graph as unknown as Json } }]; + return refuse ? [{ errorOccurred: { _0: "no loop with that id" } }] : [{ graphChanged: { _0: graph as unknown as Json } }]; + }); + const daemon = daemonClient(path, 2000); + await sendDraft({ nodeID: ME.toLowerCase(), projectPath: "/work/repo", daemon }, { toNodeID: BILLING, text: "hi" }); + expect(seen[1]).toEqual({ + graphCommand: { projectPath: "/work/repo", command: { messageNode: { _0: BILLING, text: "hi", from: ME, followUp: true } } }, + }); + refuse = true; + await expect(sendDraft({ nodeID: ME, projectPath: "/work/repo", daemon }, { toNodeID: BILLING, text: "x" })).rejects.toThrow( + "no loop with that id", + ); + }); + + test("the mailbox answer is unwrapped", async () => { + const { path } = await fakeGraphcoded((c) => ("mailbox" in c ? [{ mailbox: { mailbox: { posts: [{ id: 3 }] } } }] : [])); + expect(await daemonClient(path, 2000).mailbox("/p", { selection: { board: {} } })).toEqual({ posts: [{ id: 3 }] }); + }); + + test("a missing daemon is a clear error", async () => { + await expect(daemonClient("/nonexistent/graphcoded.sock", 500).snapshot("/p")).rejects.toThrow("not reachable"); + }); + + test("the socket is found the way the CLI finds it", () => { + expect(socketPath({ GRAPHCODE_SOCKET: "/tmp/x.sock" })).toBe("/tmp/x.sock"); + expect(socketPath({ GRAPHCODE_SUPPORT_DIR: "/s" })).toBe("/s/graphcoded.sock"); + expect(socketPath({})).toEndWith("/.graphcode/graphcoded.sock"); + }); +}); diff --git a/NodRuntime/src/mcp/tools.ts b/NodRuntime/src/mcp/tools.ts new file mode 100644 index 00000000..07e278c0 --- /dev/null +++ b/NodRuntime/src/mcp/tools.ts @@ -0,0 +1,282 @@ +import { randomUUID } from "node:crypto"; +import type { GraphDaemon, Json } from "./daemon"; +import { + allNodes, + brief, + downstream, + handoffPrefix, + levelOf, + oneLine, + payloadDescription, + referenceDate, + relationOf, + resolveLoop, + stateName, + type WireGraph, + type WireNode, +} from "./graph"; + +export type MessagePolicy = "draftForMe" | "send" | "never"; + +/** The `mailDraft` event the runtime appends to `events.jsonl`; `v`, `seq` and `at` are its to add. */ +export interface MailDraftEvent { + type: "mailDraft"; + draftID: string; + toNodeID: string; + inReplyTo?: string; + text: string; +} + +export interface ToolResult { + text: string; + isError?: boolean; +} + +/** Engine-neutral: the runtime adapts these to the Claude Agent SDK's or Copilot SDK's tool shape. */ +export interface GraphcodeTool { + name: string; + description: string; + inputSchema: { type: "object"; properties: Record; required?: string[] }; + readOnly: boolean; + handler(args: Record): Promise; +} + +export interface GraphcodeToolContext { + nodeID: string; + projectPath: string; + daemon: GraphDaemon; + /** Read per call, so a change in Settings › Agents › Nod applies without a restart. */ + messagesOtherLoops(): MessagePolicy; + emit(event: MailDraftEvent): void; + newID?(): string; +} + +export const serverName = "graphcode"; + +const policyOff = + "Messaging other loops is off for Nod (Settings › Agents › Nod › Message other loops). Tell the human instead."; + +export function createGraphcodeTools(ctx: GraphcodeToolContext): GraphcodeTool[] { + const graph = async () => (await ctx.daemon.snapshot(ctx.projectPath)) as unknown as WireGraph; + const newID = ctx.newID ?? (() => randomUUID()); + + async function deliver(to: WireNode[], text: string, inReplyTo?: string): Promise { + const policy = ctx.messagesOtherLoops(); + if (policy === "never") return { text: policyOff, isError: true }; + if (policy === "draftForMe") { + for (const target of to) { + ctx.emit({ type: "mailDraft", draftID: newID(), toNodeID: target.id, inReplyTo, text }); + } + const names = to.map((t) => t.title).join(", "); + return { text: `Drafted for the human to approve; nothing is sent until they do. To: ${names}.` }; + } + for (const target of to) await sendDraft(ctx, { toNodeID: target.id, text }); + return { text: `Sent to ${to.map((t) => t.title).join(", ")}.` }; + } + + return [ + { + name: "siblings", + description: + "The other loops in this loop's graph: title, id, type, state, what each was handed, what it is doing, and how it relates to you (from = hands off to you, to = you hand off to it, beside = messages or forks).", + inputSchema: { type: "object", properties: {} }, + readOnly: true, + async handler() { + const g = await graph(); + const level = levelOf(g, ctx.nodeID) ?? g; + const others = level.nodes.filter((node) => node.id.toLowerCase() !== ctx.nodeID.toLowerCase()); + if (others.length === 0) return { text: "No other loops in this graph." }; + const lines = others.map((node) => { + const parts = [ + `${node.title} (${node.id})`, + `${node.loopType} · ${stateName(node.state)}`, + `relation: ${relationOf(level, ctx.nodeID, node.id)}`, + ]; + const handed = brief(node); + if (handed) parts.push(`brief: ${handed}`); + if (node.activity) parts.push(`now: ${oneLine(node.activity)}`); + return `- ${parts.join(" | ")}`; + }); + return { text: lines.join("\n") }; + }, + }, + { + name: "edges", + description: + "Edges touching this loop, or every edge in its graph with all=true: kind (handoff sequences, message talks, spawn instantiates), condition, and whether it has fired.", + inputSchema: { + type: "object", + properties: { all: { type: "boolean", description: "Every edge in the graph, not just yours." } }, + }, + readOnly: true, + async handler(args) { + const g = await graph(); + const level = levelOf(g, ctx.nodeID) ?? g; + const titles = new Map(allNodes(g).map((n) => [n.id.toLowerCase(), n.title])); + const mine = (id: string) => id.toLowerCase() === ctx.nodeID.toLowerCase(); + const edges = args.all === true ? level.edges : level.edges.filter((e) => mine(e.from) || mine(e.to)); + if (edges.length === 0) return { text: "No edges." }; + return { + text: edges + .map((edge) => { + const condition = typeof edge.condition === "string" ? edge.condition : stateName(edge.condition); + const fired = (edge.fireCount ?? 0) > 0 ? `fired ${edge.fireCount}×` : "not fired"; + const from = titles.get(edge.from.toLowerCase()) ?? edge.from; + const to = titles.get(edge.to.toLowerCase()) ?? edge.to; + return `- ${from} → ${to} · ${edge.kind} · ${condition} · ${fired}`; + }) + .join("\n"), + }; + }, + }, + { + name: "handoff_briefs", + description: + "What upstream loops hand to this one: each source's brief, state, result and what its edge carries. Read this before starting work that continues someone else's.", + inputSchema: { type: "object", properties: {} }, + readOnly: true, + async handler() { + const g = await graph(); + const level = levelOf(g, ctx.nodeID) ?? g; + const inbound = level.edges.filter( + (e) => e.kind !== "message" && e.to.toLowerCase() === ctx.nodeID.toLowerCase(), + ); + if (inbound.length === 0) return { text: "Nothing hands off to this loop." }; + return { + text: inbound + .map((edge) => { + const source = level.nodes.find((n) => n.id.toLowerCase() === edge.from.toLowerCase()); + if (!source) return `- ${edge.from}: source no longer in the graph`; + const lines = [ + `- ${source.title} (${source.id}) · ${stateName(source.state)} · ${(edge.fireCount ?? 0) > 0 ? "handed off" : "not yet handed off"}`, + ]; + const handed = source.goal?.summary ?? source.firstInstruction ?? source.triggerPrompt; + if (handed) lines.push(` was handed: ${handed.trim()}`); + if (source.resolution?.detail) lines.push(` result: ${source.resolution.detail.trim()}`); + lines.push(` the edge carries ${payloadDescription(edge.payloadTransform)}`); + return lines.join("\n"); + }) + .join("\n"), + }; + }, + }, + { + name: "mailroom", + description: + "The project's Mailroom: notices any loop posted for whoever comes next, newest last. Read-only — your read cursor does not move. Bodies are cut short; use mailroom_read for one in full.", + inputSchema: { + type: "object", + properties: { + search: { type: "string", description: "Substring across author, topic and body." }, + unread: { type: "boolean", description: "Only posts this loop has not read yet." }, + limit: { type: "number", description: "At most this many, newest kept. Default 20." }, + }, + }, + readOnly: true, + async handler(args) { + const selection: Json = args.unread === true ? { unread: { reader: ctx.nodeID } } : { board: {} }; + const search = typeof args.search === "string" && args.search.length > 0 ? args.search : null; + const box = (await ctx.daemon.mailbox(ctx.projectPath, { + selection, + search, + fullBodies: false, + advanceCursor: false, + })) as { posts?: Array> }; + const limit = typeof args.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 20; + const posts = (box.posts ?? []).slice(-limit); + if (posts.length === 0) return { text: "No posts." }; + return { text: posts.map(formatPost).join("\n") }; + }, + }, + { + name: "mailroom_read", + description: "One Mailroom post in full, by id.", + inputSchema: { type: "object", properties: { id: { type: "number" } }, required: ["id"] }, + readOnly: true, + async handler(args) { + if (typeof args.id !== "number") return { text: "id must be a post number.", isError: true }; + const box = (await ctx.daemon.mailbox(ctx.projectPath, { + selection: { post: { id: args.id } }, + search: null, + fullBodies: true, + advanceCursor: false, + })) as { posts?: Array> }; + const post = box.posts?.[0]; + return post ? { text: formatPost(post) } : { text: `No post #${args.id}.`, isError: true }; + }, + }, + { + name: "ask", + description: + "Message another loop — a question or an answer to one. Depending on the human's setting this is drafted for their approval, sent, or refused. Pass inReplyTo with the id of the message you are answering.", + inputSchema: { + type: "object", + properties: { + to: { type: "string", description: "The loop's title or id." }, + text: { type: "string" }, + inReplyTo: { type: "string", description: "The id of the message this answers, if any." }, + }, + required: ["to", "text"], + }, + readOnly: false, + async handler(args) { + const text = typeof args.text === "string" ? args.text.trim() : ""; + if (!text || typeof args.to !== "string") return { text: "to and text are required.", isError: true }; + const target = resolveLoop(await graph(), args.to, ctx.nodeID); + if (!target) return { text: `No loop called ${args.to}. Call siblings to see who is there.`, isError: true }; + const inReplyTo = typeof args.inReplyTo === "string" ? args.inReplyTo : undefined; + return deliver([target], text, inReplyTo); + }, + }, + { + name: "handoff", + description: + "Pass a brief downstream: to every loop this one hands off to, or to the named loop. Use when your part is done and the next loop should know what you found. Gated like ask.", + inputSchema: { + type: "object", + properties: { + brief: { type: "string", description: "What the next loop needs to know." }, + to: { type: "string", description: "A loop's title or id; default is every downstream loop." }, + }, + required: ["brief"], + }, + readOnly: false, + async handler(args) { + const text = typeof args.brief === "string" ? args.brief.trim() : ""; + if (!text) return { text: "brief is required.", isError: true }; + const g = await graph(); + let targets: WireNode[]; + if (typeof args.to === "string" && args.to.length > 0) { + const target = resolveLoop(g, args.to, ctx.nodeID); + if (!target) return { text: `No loop called ${args.to}.`, isError: true }; + targets = [target]; + } else { + targets = downstream(g, ctx.nodeID); + if (targets.length === 0) { + return { text: "Nothing is downstream of this loop; name a loop with to.", isError: true }; + } + } + return deliver(targets, handoffPrefix + text); + }, + }, + ]; +} + +/** + * Sends one message as this loop — what `sendDraft` on the control socket does once the + * human approves a draft, and what `ask`/`handoff` do under the Send policy. + */ +export async function sendDraft(ctx: Pick, draft: { + toNodeID: string; + text: string; +}): Promise { + await ctx.daemon.graphCommand(ctx.projectPath, { + messageNode: { _0: draft.toNodeID.toUpperCase(), text: draft.text, from: ctx.nodeID.toUpperCase(), followUp: true }, + }); +} + +function formatPost(post: Record): string { + const topic = typeof post.topic === "string" ? ` (${post.topic})` : ""; + const at = referenceDate(post.at); + return `#${post.id}${topic} ${post.author ?? "unknown"}${at ? ` · ${at}` : ""}: ${post.body ?? ""}`; +} diff --git a/graphcode/Tests/NodGraphLayerTests.swift b/graphcode/Tests/NodGraphLayerTests.swift new file mode 100644 index 00000000..026fef89 --- /dev/null +++ b/graphcode/Tests/NodGraphLayerTests.swift @@ -0,0 +1,470 @@ +import Foundation +import IdentifiedCollections +import Testing + +@testable import GraphcodeKit + +@Suite +struct NodCompositeGroupingTests { + /// The design's plan: steps 1, 2 and 4 touch the server, 3 the app, 4 is the done check. + static let usageCapSteps = [ + NodPlanStep(id: "1", text: "Move /export behind UsageGate", files: ["Sources/Server/Routes.swift"]), + NodPlanStep( + id: "2", text: "Return 402 with { limit, resetsAt }, and log the block", + files: ["Sources/Server/UsageGate.swift"], editedByHuman: true), + NodPlanStep( + id: "3", text: "Upgrade banner in the macOS app reads the 402", + files: ["App/Billing/BillingBanner.swift"], size: .medium), + NodPlanStep( + id: "4", text: "Tests: past cap, monthly reset, legacy export", + files: ["Tests/ServerTests/UsageCapTests.swift"], doneCheck: true), + ] + + @Test + func theDesignPlanBecomesTwoLoopsAndACheck() { + let plan = NodCompositePlan(title: "Usage caps on every paid route", steps: Self.usageCapSteps) + #expect(plan.groups.map(\.area) == ["Server", "App"]) + #expect(plan.groups.map { $0.steps.map(\.id) } == [["1", "2"], ["3"]]) + #expect(plan.doneChecks.map(\.id) == ["4"]) + #expect(plan.check == "Tests: past cap, monthly reset, legacy export") + } + + @Test(arguments: [ + ("Sources/Server/Routes.swift", "Server"), + ("Tests/ServerTests/UsageCapTests.swift", "Server"), + ("src/lib/api/client.ts", "api"), + ("./App/Billing/Banner.swift", "App"), + ("README.md", ""), + ("Sources/main.swift", ""), + ("packages/web-tests/a.ts", "web"), + ]) + func areaIsTheFirstMeaningfulDirectory(path: String, area: String) { + #expect(NodCompositePlan.area(ofFile: path) == area) + } + + @Test + func aStepTouchingTwoAreasJoinsThem() { + let steps = [ + NodPlanStep(id: "a", text: "server", files: ["Sources/Server/A.swift"]), + NodPlanStep(id: "b", text: "app", files: ["App/B.swift"]), + NodPlanStep(id: "c", text: "docs", files: ["docs/c.md"]), + NodPlanStep(id: "d", text: "bridge", files: ["App/D.swift", "Sources/Server/D.swift"]), + ] + let plan = NodCompositePlan(title: "x", steps: steps) + #expect(plan.groups.map { $0.steps.map(\.id) } == [["a", "b", "d"], ["c"]]) + } + + @Test + func stepsWithoutFilesFollowTheStepBeforeThem() { + let steps = [ + NodPlanStep(id: "lead", text: "think first"), + NodPlanStep(id: "a", text: "server", files: ["Sources/Server/A.swift"]), + NodPlanStep(id: "b", text: "app", files: ["App/B.swift"]), + NodPlanStep(id: "tail", text: "tidy the app"), + ] + let plan = NodCompositePlan(title: "x", steps: steps) + #expect(plan.groups.map { $0.steps.map(\.id) } == [["lead", "a"], ["b", "tail"]]) + } + + @Test + func aPlanWithNoFilesIsOneLoopAndNoCheckWithoutADoneStep() { + let plan = NodCompositePlan( + title: "tidy", steps: [NodPlanStep(id: "1", text: "a"), NodPlanStep(id: "2", text: "b")]) + #expect(plan.groups.count == 1) + #expect(plan.check == nil) + } + + @Test + func theCompositeHasAGoalChildPerGroupEachBriefedFromTheTranscript() throws { + let worktree = WorktreeRef( + id: "loop/monetization", repositoryPath: "/repo", worktreePath: "/repo-wt", + branch: "loop/monetization") + let source = LoopNode( + title: "Monetization", loopType: .sketch, backend: .nod, worktreeBinding: worktree) + let plan = NodCompositePlan(title: "Usage caps on every paid route", steps: Self.usageCapSteps) + let made = plan.makeComposite(plannedIn: source) { "/briefs/\($0.uuidString).json" } + + #expect(made.draft.loopType == .composite) + #expect(made.draft.title == "UsageCapsOnEvery") + #expect(made.draft.checkDescription == plan.check) + #expect(made.draft.createdBy == source.id) + let children = try #require(made.draft.subGraph?.nodes) + #expect(children.map(\.title) == ["Server", "App"]) + for child in children { + #expect(child.loopType == .goalBased) + #expect(child.backend == .nod) + #expect(child.worktreeBinding == worktree) + #expect(child.lineage?.kind == .compositeChild) + #expect(child.lineage?.sourceNodeID == source.id) + } + #expect(children[0].goal?.summary.contains("Move /export behind UsageGate") == true) + #expect(children[0].goal?.summary.contains("Upgrade banner") == false) + + #expect(made.briefs.count == 2) + #expect(made.briefs.map(\.path) == children.compactMap(\.lineage?.briefPath)) + let brief = made.briefs[0].brief + #expect(brief.kind == .compositeChild) + #expect(brief.fromNodeID == source.id) + #expect(brief.attachments == [NodAttachment(kind: .loopTranscript, reference: source.id.uuidString)]) + #expect(brief.text.contains("→ 1. Move /export behind UsageGate")) + #expect(brief.text.contains(" 3. Upgrade banner")) + #expect(brief.text.contains("✓ 4. Tests")) + #expect(brief.text.contains("Steps a human rewrote are theirs")) + } + + /// `makeNode` re-identifies a composite's sub-graph; the lineage, and so the brief, must + /// survive it. + @Test + func lineageSurvivesCreation() throws { + let source = LoopNode(title: "Planner", loopType: .sketch) + let made = NodCompositePlan(title: "p", steps: Self.usageCapSteps) + .makeComposite(plannedIn: source) { "/b/\($0).json" } + let node = made.draft.makeNode() + let children = try #require(node.subGraph?.nodes) + #expect(children.map(\.lineage) == made.draft.subGraph?.nodes.map(\.lineage)) + #expect(Set(children.map(\.id)).isDisjoint(with: made.draft.subGraph?.nodes.map(\.id) ?? [])) + } +} + +@Suite +struct NodForkTests { + static func graph(_ nodes: [LoopNode], edges: [LoopEdge] = []) -> LoopGraph { + LoopGraph( + project: ProjectRef(path: "/work/repo", name: "repo"), + nodes: IdentifiedArrayOf(uniqueElements: nodes), + edges: IdentifiedArrayOf(uniqueElements: edges)) + } + + @Test + func aForkIsASiblingOnItsOwnBranchCutFromTheSources() { + let source = LoopNode( + title: "Monetization", loopType: .goalBased, + goal: GoalSpec(summary: "every paid route is capped", predicate: "swift test"), + backend: .nod, + worktreeBinding: WorktreeRef( + id: "loop/monetization", repositoryPath: "/work/repo", + worktreePath: "/work/repo-loop-monetization", branch: "loop/monetization")) + let fork = NodFork( + of: source, in: Self.graph([source]), atMessage: "m7", conversationID: "c1", + approach: "check the cap inside the handler", briefPath: "/b/f.json") + + #expect(fork.draft.title == "Monetization2") + #expect(fork.draft.loopType == .goalBased) + #expect(fork.draft.goal == source.goal) + #expect(fork.draft.backend == .nod) + #expect(fork.draft.createdBy == nil) + #expect(fork.draft.lineage == LoopLineage(kind: .fork, sourceNodeID: source.id, briefPath: "/b/f.json")) + #expect(fork.worktree.repositoryPath == "/work/repo") + #expect(fork.worktree.branch == "loop/monetization-fork2") + #expect(fork.worktree.worktreePath == "/work/repo-loop-monetization-fork2") + #expect(fork.worktree.startPoint == "loop/monetization") + #expect(fork.brief.kind == .fork) + #expect(fork.brief.fork == NodBrief.ForkPoint(conversationID: "c1", messageID: "m7")) + #expect(fork.brief.text.contains("check the cap inside the handler")) + } + + @Test + func forksNumberUpAndAnUnboundSourceForksFromTheProject() { + let source = LoopNode(title: "Spike", loopType: .sketch, backend: .nod) + let first = NodFork(of: source, in: Self.graph([source]), atMessage: "m", briefPath: "/a") + var second = first.draft.makeNode() + second = LoopNode( + id: second.id, title: second.title, loopType: second.loopType, lineage: second.lineage) + let next = NodFork(of: source, in: Self.graph([source, second]), atMessage: "m", briefPath: "/b") + #expect(first.worktree.branch == "nod/Spike-fork2") + #expect(first.worktree.startPoint == nil) + #expect(first.worktree.worktreePath == "/work/repo-nod-Spike-fork2") + #expect(next.draft.title == "Spike3") + } + + @Test + func theForkedFromLinkIsDrawnOnlyWhileTheSourceExists() { + let source = LoopNode(title: "A", loopType: .sketch) + let fork = LoopNode( + title: "A2", loopType: .sketch, lineage: LoopLineage(kind: .fork, sourceNodeID: source.id)) + let child = LoopNode( + title: "C", loopType: .goalBased, + lineage: LoopLineage(kind: .compositeChild, sourceNodeID: source.id)) + let links = Self.graph([source, fork, child]).forkLinks + #expect(links.count == 1) + #expect(links.first?.from == source.id && links.first?.to == fork.id) + #expect(Self.graph([fork]).forkLinks.isEmpty) + } + + @Test + func lineageRoundTripsAndAGraphWithoutItStillDecodes() throws { + let node = LoopNode( + title: "A2", lineage: LoopLineage(kind: .fork, sourceNodeID: UUID(), briefPath: "/b")) + let data = try JSONEncoder().encode(node) + #expect(try JSONDecoder().decode(LoopNode.self, from: data).lineage == node.lineage) + + var json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + json["lineage"] = ["kind": "fromTheFuture", "sourceNodeID": UUID().uuidString] + let future = try JSONSerialization.data(withJSONObject: json) + #expect(try JSONDecoder().decode(LoopNode.self, from: future).lineage == nil) + } +} + +@Suite +struct NodGraphVerbTests { + static let me = LoopNode(title: "Monetization", loopType: .goalBased) + static let pricing = LoopNode(title: "Pricing", loopType: .goalBased) + static let notes = LoopNode(title: "ReleaseNotes", loopType: .turnBased) + static let billing = LoopNode(title: "BillingUI", loopType: .turnBased) + static let graph = NodForkTests.graph( + [me, pricing, notes, billing], + edges: [ + LoopEdge(from: pricing.id, to: me.id), + LoopEdge(from: me.id, to: notes.id), + LoopEdge(from: billing.id, to: me.id, kind: .message), + ]) + + @Test + func theContextStripReadsFromBesideTo() { + let context = NodGraphContext(nodeID: Self.me.id, in: Self.graph) + #expect(context.from.map(\.title) == ["Pricing"]) + #expect(context.to.map(\.title) == ["ReleaseNotes"]) + #expect(context.beside.map(\.title) == ["BillingUI"]) + #expect(!context.isEmpty) + } + + @Test + func theContextStripCollapsesWithNoEdges() { + let lone = LoopNode(title: "Lone", loopType: .sketch) + #expect(NodGraphContext(nodeID: lone.id, in: NodForkTests.graph([lone, Self.me])).isEmpty) + } + + @Test + func handoffGoesDownstreamOrToTheNamedLoop() throws { + let downstream = try NodGraphVerb.parse("/handoff 402 body is { limit, resetsAt }").get() + #expect(downstream == .handoff(target: nil, brief: "402 body is { limit, resetsAt }")) + #expect( + try downstream.commands(from: Self.me.id, in: Self.graph).get() == [ + .messageNode( + Self.notes.id, text: "Handoff: 402 body is { limit, resetsAt }", from: Self.me.id, + followUp: true) + ]) + + let named = try NodGraphVerb.parse("/handoff @billing check the 402").get() + #expect( + try named.commands(from: Self.me.id, in: Self.graph).get().first + == .messageNode(Self.billing.id, text: "Handoff: check the 402", from: Self.me.id, followUp: true)) + } + + @Test + func handoffRefusesWhatItCannotDeliver() throws { + #expect( + try NodGraphVerb.parse("/handoff brief").get().commands(from: Self.notes.id, in: Self.graph) + == .failure(.noDownstream)) + #expect( + try NodGraphVerb.parse("/handoff @Nobody x").get().commands(from: Self.me.id, in: Self.graph) + == .failure(.unknownLoop("Nobody"))) + #expect( + try NodGraphVerb.parse("/handoff").get().commands(from: Self.me.id, in: Self.graph) + == .failure(.emptyBrief)) + } + + @Test + func askMessagesASibling() throws { + let ask = try NodGraphVerb.parse("/ask @BillingUI does the banner read the 402?").get() + #expect( + try ask.commands(from: Self.me.id, in: Self.graph).get() == [ + .messageNode( + Self.billing.id, text: "does the banner read the 402?", from: Self.me.id, followUp: true) + ]) + #expect(NodGraphVerb.parse("/ask nobody") == .failure(.usage(NodGraphVerb.askUsage))) + } + + @Test + func promoteGivesAMainLoopAShape() throws { + let sketch = LoopNode(title: "Spike", loopType: .sketch) + let graph = NodForkTests.graph([sketch, Self.notes]) + let goal = try NodGraphVerb.parse("/promote goal every route is capped").get() + #expect( + try goal.commands(from: sketch.id, in: graph).get() == [ + .promoteNode( + sketch.id, promotion: .goal(GoalSpec(summary: "every route is capped")), promotedBy: nil) + ]) + #expect( + try NodGraphVerb.parse("/promote turn writes").get() + == .promote(.turn(pausesBeforeWritesOnly: true))) + #expect( + try NodGraphVerb.parse("/promote timed /loop 1h check").get() + == .promote(.timed(triggerPrompt: "/loop 1h check"))) + #expect(NodGraphVerb.parse("/promote goal") == .failure(.usage(NodGraphVerb.promoteUsage))) + #expect( + try goal.commands(from: Self.notes.id, in: graph) == .failure(.notASketch)) + #expect(NodGraphVerb.parse("/plan") == .failure(.notAGraphVerb)) + } +} + +@Suite +struct NodInboundMailTests { + static func message(_ text: String, from: UUID? = nil) -> NodEvent.UserMessage { + NodEvent.UserMessage(id: "u1", text: text, delivery: .queue, attachments: [], fromNodeID: from) + } + + @Test + func aSiblingsQuestionIsMail() throws { + let mail = try #require( + NodInboundMail.classify( + Self.message("[graphcode] BillingUI: What does /export return over the cap?"), + nodeID: NodGraphVerbTests.me.id, in: NodGraphVerbTests.graph)) + #expect(mail.kind == .mail) + #expect(mail.sender?.id == NodGraphVerbTests.billing.id) + #expect(mail.body == "What does /export return over the cap?") + #expect(mail.isQuestion) + #expect( + mail.answerCommand(from: NodGraphVerbTests.me.id, text: "402") + == .messageNode( + NodGraphVerbTests.billing.id, text: "402", from: NodGraphVerbTests.me.id, followUp: true)) + } + + @Test + func anUpstreamLoopFinishingIsAHandoff() throws { + let edgeFired = try #require( + NodInboundMail.classify( + Self.message("[graphcode] Pricing: Free tier is 50 exports a month."), + nodeID: NodGraphVerbTests.me.id, in: NodGraphVerbTests.graph)) + #expect(edgeFired.kind == .handoff) + let bare = try #require( + NodInboundMail.classify( + Self.message("[graphcode] Pricing finished."), nodeID: NodGraphVerbTests.me.id, + in: NodGraphVerbTests.graph)) + #expect(bare.kind == .handoff && bare.body.isEmpty) + let named = try #require( + NodInboundMail.classify( + Self.message("[graphcode] BillingUI: Handoff: banner done", from: NodGraphVerbTests.billing.id), + nodeID: NodGraphVerbTests.me.id, in: NodGraphVerbTests.graph)) + #expect(named.kind == .handoff && named.body == "banner done") + } + + @Test + func humanTypingAndDaemonNoticesAreNotMail() { + let graph = NodGraphVerbTests.graph + #expect(NodInboundMail.classify(Self.message("fix /export"), nodeID: NodGraphVerbTests.me.id, in: graph) == nil) + #expect( + NodInboundMail.classify( + Self.message("[graphcode] Stop requested from the graph."), nodeID: NodGraphVerbTests.me.id, + in: graph) == nil) + } + + @Test + func theNewestDraftForTheMessageWins() throws { + let mail = try #require( + NodInboundMail.classify( + Self.message("[graphcode] BillingUI: what status?"), nodeID: NodGraphVerbTests.me.id, + in: NodGraphVerbTests.graph)) + let to = NodGraphVerbTests.billing.id + let events: [NodEvent] = [ + .mailDraft(.init(draftID: "d1", toNodeID: to, inReplyTo: "u1", text: "402")), + .mailDraft(.init(draftID: "d2", toNodeID: to, inReplyTo: "other", text: "no")), + .mailDraft(.init(draftID: "d3", toNodeID: to, inReplyTo: "u1", text: "402 { limit, resetsAt }")), + ] + #expect(mail.draft(in: events)?.draftID == "d3") + } +} + +@Suite +struct NodHandoffOfferTests { + static let met = NodEvent.GoalCheck( + turn: 4, evaluatorModel: "haiku", + clauses: [ + NodGoalClause(text: "Every paid route goes through UsageGate", met: true, evidence: "4 of 4 paid routes capped"), + NodGoalClause(text: "swift test passes", met: true, evidence: "31 tests pass"), + ], met: true) + + @Test + func offeredWhenTheGoalHoldsAndSomethingIsDownstream() throws { + let offer = try #require( + NodHandoffOffer.make( + check: Self.met, nodeID: NodGraphVerbTests.me.id, in: NodGraphVerbTests.graph, + summaries: ["Moved /export into the paid group."])) + #expect(offer.targets.map(\.title) == ["ReleaseNotes"]) + #expect(offer.evidence == ["4 of 4 paid routes capped", "31 tests pass"]) + #expect(offer.suggestedBrief == "Moved /export into the paid group.") + #expect( + offer.commands(brief: "Moved /export.\nTests added.") == [ + .messageNode( + NodGraphVerbTests.notes.id, text: "Handoff: Moved /export.\nTests added.", + from: NodGraphVerbTests.me.id, followUp: true), + .completeNode(NodGraphVerbTests.me.id, result: "Moved /export.", from: nil), + ]) + } + + @Test + func notOfferedUnmetOrWithNothingDownstream() { + var unmet = Self.met + unmet.met = false + #expect(NodHandoffOffer.make(check: unmet, nodeID: NodGraphVerbTests.me.id, in: NodGraphVerbTests.graph) == nil) + #expect(NodHandoffOffer.make(check: Self.met, nodeID: NodGraphVerbTests.notes.id, in: NodGraphVerbTests.graph) == nil) + } +} + +@Suite +struct NodEditablePlanTests { + static var plan: NodEditablePlan { + NodEditablePlan(planID: "p1", title: "Usage caps", steps: NodCompositeGroupingTests.usageCapSteps) + } + + @Test + func rewritingAStepMakesItTheHumans() { + var plan = Self.plan + plan.rewrite(stepID: "1", to: " Move /export behind UsageGate ") + #expect(!plan.steps[0].editedByHuman) + plan.rewrite(stepID: "1", to: "Move /export and /share behind UsageGate") + #expect(plan.steps[0].editedByHuman) + #expect(plan.steps[0].text == "Move /export and /share behind UsageGate") + } + + @Test + func dragReordersAndDeleteDrops() { + var plan = Self.plan + plan.move(fromOffsets: IndexSet(integer: 2), toOffset: 0) + #expect(plan.steps.map(\.id) == ["3", "1", "2", "4"]) + plan.move(fromOffsets: IndexSet([0, 1]), toOffset: 4) + #expect(plan.steps.map(\.id) == ["2", "4", "3", "1"]) + plan.remove(stepID: "4") + #expect(plan.steps.map(\.id) == ["2", "3", "1"]) + } + + @Test + func addedStepsAreHumansAndTheCompositeCountFollowsEdits() { + var plan = Self.plan + #expect(plan.compositeLoopCount == 2) + plan.add("Docs for the 402") + #expect(plan.steps.last?.editedByHuman == true) + plan.steps[plan.steps.count - 1].files = ["docs/billing.md"] + #expect(plan.compositeLoopCount == 3) + plan.toggleDoneCheck(stepID: "3") + #expect(plan.compositeLoopCount == 2) + #expect( + plan.runCommand(.composite) + == .runPlan(.init(planID: "p1", steps: plan.steps, mode: .composite))) + } +} + +@Suite +struct NodProtocolGraphLayerTests { + @Test + func aPlanStepWithoutDoneCheckDecodesAsNotOne() throws { + let json = #"{"id":"1","text":"a","files":[],"editedByHuman":false}"# + let step = try JSONDecoder().decode(NodPlanStep.self, from: Data(json.utf8)) + #expect(!step.doneCheck) + let done = try JSONDecoder().decode( + NodPlanStep.self, from: Data(#"{"id":"1","text":"a","doneCheck":true}"#.utf8)) + #expect(done.doneCheck && done.files.isEmpty && !done.editedByHuman) + } + + @Test + func aBriefRoundTripsInTheAgreedEncoding() throws { + let brief = NodBrief( + kind: .fork, fromNodeID: UUID(), text: "t", + fork: .init(conversationID: "c", messageID: "m")) + let data = try NodProtocol.makeEncoder().encode(brief) + #expect(try NodProtocol.makeDecoder().decode(NodBrief.self, from: data) == brief) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(object["v"] as? Int == NodProtocol.version) + #expect(object["kind"] as? String == "fork") + } +} From 32fd50bedaf027fdfc8931eec48e1c444c205141 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:35:18 -0700 Subject: [PATCH 4/6] Add Nod chat pane graph components and the fork line on the canvas Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Sources/Domain/NodCompositePlan.swift | 6 +- .../Sources/Domain/NodGraphVerbs.swift | 4 +- .../Nod/GraphLayer/ForkLineView.swift | 25 +++ .../Nod/GraphLayer/NodContextStrip.swift | 73 ++++++++ .../Nod/GraphLayer/NodGraphActions.swift | 117 ++++++++++++ .../Nod/GraphLayer/NodHandoffOfferView.swift | 63 +++++++ .../Nod/GraphLayer/NodInlineMailCard.swift | 127 +++++++++++++ .../Features/Nod/GraphLayer/NodPlanCard.swift | 172 ++++++++++++++++++ .../Features/Project/ProjectCanvasView.swift | 6 + graphcode/Tests/NodGraphActionsTests.swift | 134 ++++++++++++++ graphcode/Tests/NodGraphLayerTests.swift | 41 +++-- 11 files changed, 753 insertions(+), 15 deletions(-) create mode 100644 graphcode/Sources/Features/Nod/GraphLayer/ForkLineView.swift create mode 100644 graphcode/Sources/Features/Nod/GraphLayer/NodContextStrip.swift create mode 100644 graphcode/Sources/Features/Nod/GraphLayer/NodGraphActions.swift create mode 100644 graphcode/Sources/Features/Nod/GraphLayer/NodHandoffOfferView.swift create mode 100644 graphcode/Sources/Features/Nod/GraphLayer/NodInlineMailCard.swift create mode 100644 graphcode/Sources/Features/Nod/GraphLayer/NodPlanCard.swift create mode 100644 graphcode/Tests/NodGraphActionsTests.swift diff --git a/GraphcodeKit/Sources/Domain/NodCompositePlan.swift b/GraphcodeKit/Sources/Domain/NodCompositePlan.swift index 0456550a..e813bcbd 100644 --- a/GraphcodeKit/Sources/Domain/NodCompositePlan.swift +++ b/GraphcodeKit/Sources/Domain/NodCompositePlan.swift @@ -56,7 +56,8 @@ public struct NodCompositePlan: Equatable, Sendable { var areasByStep = steps.map { Set($0.files.map(area(ofFile:))) } if let firstWithFiles = areasByStep.firstIndex(where: { !$0.isEmpty }) { for index in areasByStep.indices where areasByStep[index].isEmpty { - areasByStep[index] = index < firstWithFiles + areasByStep[index] = + index < firstWithFiles ? areasByStep[firstWithFiles] : areasByStep[index - 1] } } else { @@ -116,7 +117,8 @@ public struct NodCompositePlan: Equatable, Sendable { lines.append("\(marker) \(number). \(step.text)\(files)") } lines.append("") - lines.append("→ marks your steps. Other loops take the rest; ✓ is how the composite is checked.") + lines.append( + "→ marks your steps. Other loops take the rest; ✓ is how the composite is checked.") if group.steps.contains(where: \.editedByHuman) { lines.append("Steps a human rewrote are theirs: do them as written.") } diff --git a/GraphcodeKit/Sources/Domain/NodGraphVerbs.swift b/GraphcodeKit/Sources/Domain/NodGraphVerbs.swift index f02ff892..9f4fee40 100644 --- a/GraphcodeKit/Sources/Domain/NodGraphVerbs.swift +++ b/GraphcodeKit/Sources/Domain/NodGraphVerbs.swift @@ -251,6 +251,8 @@ public struct NodHandoffOffer: Equatable, Sendable { let text = brief.trimmingCharacters(in: .whitespacesAndNewlines) let targets = targets.map { LoopNode(id: $0.id, title: $0.title) } return NodGraphVerb.handoffCommands(from: nodeID, to: targets, brief: text) - + [.completeNode(nodeID, result: text.split(separator: "\n").first.map(String.init), from: nil)] + + [ + .completeNode(nodeID, result: text.split(separator: "\n").first.map(String.init), from: nil) + ] } } diff --git a/graphcode/Sources/Features/Nod/GraphLayer/ForkLineView.swift b/graphcode/Sources/Features/Nod/GraphLayer/ForkLineView.swift new file mode 100644 index 00000000..38fd433b --- /dev/null +++ b/graphcode/Sources/Features/Nod/GraphLayer/ForkLineView.swift @@ -0,0 +1,25 @@ +import GraphcodeKit +import SwiftUI + +/// The dotted "forked from" line between a loop and the fork racing it. Not an edge: it +/// sequences nothing and carries nothing, so it has no head and no context menu. +struct ForkLineView: View { + let from: CGPoint + let to: CGPoint + + var body: some View { + let start = CanvasEdgeGeometry.exit(from: from, toward: to, cardSize: LoopCardView.Metrics.size) + let end = CanvasEdgeGeometry.entry(at: to, from: from, cardSize: LoopCardView.Metrics.size) + let controls = CanvasEdgeGeometry.controls(from: start, to: end) + Path { path in + path.move(to: start) + path.addCurve(to: end, control1: controls.0, control2: controls.1) + } + .stroke( + Color.white.opacity(0.4), + style: StrokeStyle(lineWidth: 1.5, lineCap: .round, dash: [0.5, 5]) + ) + .help("forked from") + .accessibilityLabel("Forked from") + } +} diff --git a/graphcode/Sources/Features/Nod/GraphLayer/NodContextStrip.swift b/graphcode/Sources/Features/Nod/GraphLayer/NodContextStrip.swift new file mode 100644 index 00000000..2085c3e5 --- /dev/null +++ b/graphcode/Sources/Features/Nod/GraphLayer/NodContextStrip.swift @@ -0,0 +1,73 @@ +import GraphcodeKit +import SwiftUI + +/// The strip above a Nod transcript: `from Pricing → Monetization → Release notes · +/// beside Billing UI`. Renders nothing at all for a loop with no neighbours, so a Main loop +/// on its own keeps the plain reading column. +struct NodContextStrip: View { + let context: NodGraphContext + let onOpen: (UUID) -> Void + + var body: some View { + if !context.isEmpty { + HStack(spacing: 6) { + if !context.from.isEmpty { + label("from") + chips(context.from) + arrow + } + Text(context.title) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.primary) + if !context.to.isEmpty { + arrow + chips(context.to) + } + if !context.beside.isEmpty { + label("beside").padding(.leading, 8) + chips(context.beside) + } + Spacer(minLength: 8) + Text("Nod can read these") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.55)) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Theme.activityStrip) + .overlay(alignment: .bottom) { Rectangle().fill(Theme.loopCardBorder).frame(height: 1) } + .accessibilityElement(children: .contain) + .accessibilityLabel("Loops beside this one") + } + } + + private var arrow: some View { + Image(systemName: "arrow.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.white.opacity(0.55)) + } + + private func label(_ text: String) -> some View { + Text(text) + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.55)) + } + + private func chips(_ neighbours: [NodGraphContext.Neighbour]) -> some View { + ForEach(neighbours) { neighbour in + Button { + onOpen(neighbour.id) + } label: { + HStack(spacing: 4) { + Circle().fill(neighbour.loopType.accent).frame(width: 6, height: 6) + Text(neighbour.title).font(.system(size: 11)) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Theme.tabSelectedBackground)) + } + .buttonStyle(.plain) + .help("\(neighbour.loopType.displayName) · open \(neighbour.title)") + } + } +} diff --git a/graphcode/Sources/Features/Nod/GraphLayer/NodGraphActions.swift b/graphcode/Sources/Features/Nod/GraphLayer/NodGraphActions.swift new file mode 100644 index 00000000..22d1ac96 --- /dev/null +++ b/graphcode/Sources/Features/Nod/GraphLayer/NodGraphActions.swift @@ -0,0 +1,117 @@ +import Foundation +import GraphcodeKit + +/// Performs the graph-changing actions a Nod conversation offers: Run as Composite and +/// Fork as a new sibling. The app does these rather than the runtime because creating a +/// worktree and writing briefs are the app's jobs, as they are for the node form. +/// +/// Top-level loops only; a loop inside a composite is addressed through its parent. +enum NodGraphActions { + enum Failure: Error, Equatable { + case nodUnavailable + case nothingToRun + case worktree(String) + } + + typealias Send = @Sendable (GraphCommand) async throws -> Void + + static var briefDirectory: URL { + SupportDirectory.url.appendingPathComponent("nod/briefs", isDirectory: true) + } + + static func briefPath(for id: UUID, in directory: URL = briefDirectory) -> String { + directory.appendingPathComponent("\(id.uuidString).json").path + } + + static func write(_ brief: NodBrief, to path: String) throws { + let url = URL(fileURLWithPath: path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try NodProtocol.makeEncoder().encode(brief).write(to: url, options: .atomic) + } + + /// Creates the composite and pilots it, so its children start. Returns its id. + @discardableResult + static func runAsComposite( + _ plan: NodEditablePlan, plannedIn source: LoopNode, + briefDirectory: URL = briefDirectory, + enabled: Bool = FeatureRamps.isEnabled(.nod), send: Send + ) async throws -> UUID { + guard enabled else { throw Failure.nodUnavailable } + let composite = NodCompositePlan(title: plan.title, steps: plan.steps) + guard !composite.groups.isEmpty else { throw Failure.nothingToRun } + let made = composite.makeComposite(plannedIn: source) { + briefPath(for: $0, in: briefDirectory) + } + for (path, brief) in made.briefs { try write(brief, to: path) } + try await send(.createNode(made.draft)) + try await send(.pilotComposite(made.draft.id)) + return made.draft.id + } + + /// Creates the fork's worktree on a branch cut from the source's, writes its brief, and + /// creates it beside the source. Returns its id. + @discardableResult + static func fork( + _ source: LoopNode, in graph: LoopGraph, atMessage messageID: String, + conversationID: String? = nil, approach: String? = nil, + briefDirectory: URL = briefDirectory, + enabled: Bool = FeatureRamps.isEnabled(.nod), + createWorktree: @Sendable (NodFork.WorktreeRequest) async throws -> WorktreeRef = + gitWorktree, + send: Send + ) async throws -> UUID { + guard enabled else { throw Failure.nodUnavailable } + let briefID = UUID() + let path = briefPath(for: briefID, in: briefDirectory) + let fork = NodFork( + of: source, in: graph, atMessage: messageID, conversationID: conversationID, + approach: approach, briefPath: path) + var draft = fork.draft + draft.worktree = try await createWorktree(fork.worktree) + try write(fork.brief, to: path) + try await send(.createNode(draft)) + return draft.id + } + + /// `git worktree add -b -- []`. + @Sendable + static func gitWorktree(_ request: NodFork.WorktreeRequest) async throws -> WorktreeRef { + var arguments = [ + "-C", request.repositoryPath, "worktree", "add", "-b", request.branch, "--", + request.worktreePath, + ] + if let start = request.startPoint { arguments.append(start) } + let (status, output) = try await run("/usr/bin/git", arguments) + guard status == 0 else { throw Failure.worktree(output) } + return WorktreeRef( + id: request.branch, repositoryPath: request.repositoryPath, + worktreePath: request.worktreePath, branch: request.branch) + } + + /// Awaited through `terminationHandler`: `waitUntilExit` on a cooperative thread can + /// deadlock the executor. + private static func run(_ executable: String, _ arguments: [String]) async throws -> ( + Int32, String + ) { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + return try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { process in + let data = pipe.fileHandleForReading.readDataToEndOfFile() + continuation.resume( + returning: (process.terminationStatus, String(decoding: data, as: UTF8.self))) + } + do { + try process.run() + } catch { + process.terminationHandler = nil + continuation.resume(throwing: error) + } + } + } +} diff --git a/graphcode/Sources/Features/Nod/GraphLayer/NodHandoffOfferView.swift b/graphcode/Sources/Features/Nod/GraphLayer/NodHandoffOfferView.swift new file mode 100644 index 00000000..83f27807 --- /dev/null +++ b/graphcode/Sources/Features/Nod/GraphLayer/NodHandoffOfferView.swift @@ -0,0 +1,63 @@ +import GraphcodeKit +import SwiftUI + +/// "✓ Goal holds · Hand off to Release notes with a summary of what changed?" — under a +/// met goal check, when something is downstream. The brief is reviewed before it goes. +struct NodHandoffOfferView: View { + let offer: NodHandoffOffer + /// The reviewed brief; send `offer.commands(brief:)`. + let onHandOff: (String) -> Void + let onDismiss: () -> Void + + @State private var reviewing = false + @State private var brief = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "checkmark.circle.fill").foregroundStyle(LoopType.goalBased.accent) + Text("Goal holds").font(.system(size: 12, weight: .semibold)) + Text(offer.evidence.joined(separator: " · ")) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + } + Text("Hand off to \(targetNames) with a summary of what changed?") + .font(.system(size: 12)) + if reviewing { + TextEditor(text: $brief) + .font(.system(size: 12)) + .scrollContentBackground(.hidden) + .frame(minHeight: 60, maxHeight: 180) + .padding(4) + .background(RoundedRectangle(cornerRadius: 6).fill(Theme.draftField)) + HStack { + Button("Hand off") { onHandOff(brief) } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: .command) + .disabled(brief.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel") { reviewing = false } + } + .controlSize(.small) + } else { + HStack { + Button("Review & hand off") { + brief = offer.suggestedBrief + reviewing = true + } + .buttonStyle(.borderedProminent) + Button("Not now", action: onDismiss) + } + .controlSize(.small) + } + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Theme.sheet)) + .overlay( + RoundedRectangle(cornerRadius: 8).stroke(LoopType.goalBased.accent.opacity(0.45))) + } + + private var targetNames: String { + ListFormatter.localizedString(byJoining: offer.targets.map(\.title)) + } +} diff --git a/graphcode/Sources/Features/Nod/GraphLayer/NodInlineMailCard.swift b/graphcode/Sources/Features/Nod/GraphLayer/NodInlineMailCard.swift new file mode 100644 index 00000000..d262d042 --- /dev/null +++ b/graphcode/Sources/Features/Nod/GraphLayer/NodInlineMailCard.swift @@ -0,0 +1,127 @@ +import GraphcodeKit +import SwiftUI + +/// A sibling's mail, or an upstream handoff, shown where it landed in the conversation. +/// When Nod drafted a reply (`messagesOtherLoops` = Draft for me) it sits underneath with +/// Send reply · Edit · Answer myself, so a blocked loop is unblocked without leaving the pane. +struct NodInlineMailCard: View { + let mail: NodInboundMail + let draft: NodEvent.MailDraft? + var receivedAt: Date? + /// The draft as sent — the human may have edited it first. + let onSendReply: (NodEvent.MailDraft, String) -> Void + /// A reply written by the human, sent as this loop without Nod. + let onAnswerMyself: (String) -> Void + let onOpenTranscript: (UUID) -> Void + + private enum Composing: Equatable { + case none + case editingDraft + case answeringMyself + } + + @State private var composing = Composing.none + @State private var text = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + if !mail.body.isEmpty { + Text(mail.body) + .font(.system(size: 12)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + if mail.kind == .mail { reply } + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Theme.sheet)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.loopCardBorder)) + } + + private var header: some View { + HStack(spacing: 6) { + Image(systemName: mail.kind == .handoff ? "arrow.down.right.circle" : "envelope") + .foregroundStyle(.white.opacity(0.7)) + Text(headline).font(.system(size: 11, weight: .semibold)) + if let receivedAt { + Text("· \(receivedAt, style: .relative)") + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.55)) + } + Spacer() + if let sender = mail.sender { + Button("Open transcript") { onOpenTranscript(sender.id) } + .buttonStyle(.link) + .font(.system(size: 11)) + } + } + } + + private var headline: String { + switch mail.kind { + case .handoff: "Handoff in from \(mail.senderTitle)" + case .mail: mail.isQuestion ? "\(mail.senderTitle) asks" : "\(mail.senderTitle) wrote" + } + } + + @ViewBuilder private var reply: some View { + switch composing { + case .none: + if let draft { + VStack(alignment: .leading, spacing: 6) { + Text("Nod's draft:").font(.system(size: 11)).foregroundStyle(.white.opacity(0.55)) + Text(draft.text) + .font(.system(size: 12)) + .textSelection(.enabled) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 6).fill(Theme.draftField)) + HStack { + Button("Send reply") { onSendReply(draft, draft.text) } + .buttonStyle(.borderedProminent) + Button("Edit") { begin(.editingDraft, with: draft.text) } + Button("Answer myself") { begin(.answeringMyself, with: "") } + } + .controlSize(.small) + } + } else if mail.sender != nil { + Button("Answer myself") { begin(.answeringMyself, with: "") } + .controlSize(.small) + } + case .editingDraft, .answeringMyself: + VStack(alignment: .leading, spacing: 6) { + TextEditor(text: $text) + .font(.system(size: 12)) + .scrollContentBackground(.hidden) + .frame(minHeight: 48, maxHeight: 140) + .padding(4) + .background(RoundedRectangle(cornerRadius: 6).fill(Theme.draftField)) + HStack { + Button("Send") { send() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: .command) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel") { composing = .none } + } + .controlSize(.small) + } + } + } + + private func begin(_ mode: Composing, with initial: String) { + text = initial + composing = mode + } + + private func send() { + let reply = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !reply.isEmpty else { return } + if composing == .editingDraft, let draft { + onSendReply(draft, reply) + } else { + onAnswerMyself(reply) + } + composing = .none + } +} diff --git a/graphcode/Sources/Features/Nod/GraphLayer/NodPlanCard.swift b/graphcode/Sources/Features/Nod/GraphLayer/NodPlanCard.swift new file mode 100644 index 00000000..9c7b9551 --- /dev/null +++ b/graphcode/Sources/Features/Nod/GraphLayer/NodPlanCard.swift @@ -0,0 +1,172 @@ +import GraphcodeKit +import SwiftUI +import UniformTypeIdentifiers + +/// A `planProposed`, as an editable list: drag to reorder, click a step to rewrite it, +/// ⌫ or the × to drop one, + to add. Rewritten and added steps are marked as the human's. +/// Run here pins the plan in this loop; Run as Composite makes one child loop per area. +struct NodPlanCard: View { + @Binding var plan: NodEditablePlan + let onRunHere: () -> Void + let onRunAsComposite: () -> Void + let onKeepRefining: () -> Void + + @State private var editingStepID: String? + @State private var editText = "" + @State private var newStep = "" + @State private var draggingStepID: String? + @FocusState private var focusedStepID: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text("Plan").font(.system(size: 11, weight: .semibold)).foregroundStyle( + .white.opacity(0.55)) + Text(plan.title).font(.system(size: 13, weight: .semibold)) + Spacer() + Text("read-only until you run it") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.55)) + } + VStack(spacing: 2) { + ForEach(Array(plan.steps.enumerated()), id: \.element.id) { index, step in + row(step, number: index + 1) + .onDrag { + draggingStepID = step.id + return NSItemProvider(object: step.id as NSString) + } + .onDrop( + of: [UTType.text], + delegate: StepDropDelegate( + targetID: step.id, plan: $plan, draggingStepID: $draggingStepID)) + } + } + HStack(spacing: 6) { + Image(systemName: "plus").font(.system(size: 10)).foregroundStyle(.white.opacity(0.55)) + TextField("Add step", text: $newStep) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .onSubmit { + plan.add(newStep) + newStep = "" + } + } + .padding(.horizontal, 6) + HStack { + Button("Keep refining", action: onKeepRefining) + Spacer() + Button("Run as Composite · \(plan.compositeLoopCount) loops", action: onRunAsComposite) + .disabled(plan.compositeLoopCount == 0) + Button("Run here", action: onRunHere) + .buttonStyle(.borderedProminent) + .disabled(plan.steps.isEmpty) + } + .controlSize(.small) + } + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Theme.sheet)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Theme.loopCardBorder)) + } + + @ViewBuilder + private func row(_ step: NodPlanStep, number: Int) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("⋮⋮").font(.system(size: 11)).foregroundStyle(.white.opacity(0.4)) + .help("Drag to reorder") + Text("\(number)").font(.system(size: 11, weight: .semibold).monospacedDigit()) + .foregroundStyle(.white.opacity(0.55)) + VStack(alignment: .leading, spacing: 2) { + if editingStepID == step.id { + TextField("Step", text: $editText, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .focused($focusedStepID, equals: step.id) + .onSubmit { commitEdit() } + .onChange(of: focusedStepID) { _, focused in + if focused != step.id { commitEdit() } + } + .onExitCommand { editingStepID = nil } + } else { + Text(step.text) + .font(.system(size: 12)) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .onTapGesture { beginEdit(step) } + } + if let caption = caption(for: step) { + Text(caption).font(.system(size: 10)).foregroundStyle(.white.opacity(0.55)) + } + } + Spacer(minLength: 4) + if let size = step.size { + Text(size.rawValue).font(.system(size: 10)).foregroundStyle(.white.opacity(0.55)) + } + Button { + plan.toggleDoneCheck(stepID: step.id) + } label: { + Image(systemName: step.doneCheck ? "checkmark.seal.fill" : "checkmark.seal") + } + .buttonStyle(.plain) + .foregroundStyle(step.doneCheck ? LoopType.goalBased.accent : .white.opacity(0.4)) + .help(step.doneCheck ? "The done check — not a loop of its own" : "Make this the done check") + Button { + plan.remove(stepID: step.id) + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .foregroundStyle(.white.opacity(0.4)) + .help("Drop this step") + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(draggingStepID == step.id ? Theme.tabSelectedBackground : .clear) + ) + .focusable(editingStepID != step.id) + .onDeleteCommand { plan.remove(stepID: step.id) } + } + + private func caption(for step: NodPlanStep) -> String? { + var parts = step.files.map { ($0 as NSString).lastPathComponent } + if step.editedByHuman { parts.append("you edited this") } + if step.doneCheck { parts.append("done check") } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private func beginEdit(_ step: NodPlanStep) { + editText = step.text + editingStepID = step.id + focusedStepID = step.id + } + + private func commitEdit() { + guard let id = editingStepID else { return } + plan.rewrite(stepID: id, to: editText) + editingStepID = nil + } +} + +private struct StepDropDelegate: DropDelegate { + let targetID: String + @Binding var plan: NodEditablePlan + @Binding var draggingStepID: String? + + func dropEntered(info: DropInfo) { + guard let dragging = draggingStepID, dragging != targetID, + let from = plan.steps.firstIndex(where: { $0.id == dragging }), + let to = plan.steps.firstIndex(where: { $0.id == targetID }) + else { return } + withAnimation(.easeOut(duration: 0.12)) { + plan.move(fromOffsets: IndexSet(integer: from), toOffset: to > from ? to + 1 : to) + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { DropProposal(operation: .move) } + + func performDrop(info: DropInfo) -> Bool { + draggingStepID = nil + return true + } +} diff --git a/graphcode/Sources/Features/Project/ProjectCanvasView.swift b/graphcode/Sources/Features/Project/ProjectCanvasView.swift index 99497740..6114101e 100644 --- a/graphcode/Sources/Features/Project/ProjectCanvasView.swift +++ b/graphcode/Sources/Features/Project/ProjectCanvasView.swift @@ -421,7 +421,13 @@ struct ProjectCanvasView: View { originLane: entryPorts(derived).isEmpty ? 0 : CanvasBand.originLane) } + @ViewBuilder private func edgesLayer(focus: EdgeFocus?) -> some View { + ForEach(store.canvasGraph.forkLinks, id: \.to) { link in + if let from = store.nodePositions[link.from], let to = store.nodePositions[link.to] { + ForkLineView(from: from, to: to) + } + } ForEach(store.canvasGraph.edges) { edge in if let from = store.nodePositions[edge.from], let to = store.nodePositions[edge.to] { EdgeLineView( diff --git a/graphcode/Tests/NodGraphActionsTests.swift b/graphcode/Tests/NodGraphActionsTests.swift new file mode 100644 index 00000000..e270686c --- /dev/null +++ b/graphcode/Tests/NodGraphActionsTests.swift @@ -0,0 +1,134 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +@Suite +struct NodGraphActionsTests { + static func scratch() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("nod-briefs-\(UUID().uuidString)") + } + + @Test + func runAsCompositeWritesEachChildsBriefThenCreatesAndPilots() async throws { + let directory = Self.scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let sent = LockIsolated<[GraphCommand]>([]) + let source = LoopNode(title: "Monetization", loopType: .sketch, backend: .nod) + let plan = NodEditablePlan( + planID: "p", title: "Usage caps", steps: NodCompositeGroupingTests.usageCapSteps) + + let id = try await NodGraphActions.runAsComposite( + plan, plannedIn: source, briefDirectory: directory, enabled: true + ) { command in sent.withValue { $0.append(command) } } + + let commands = sent.value + #expect(commands.count == 2) + guard case .createNode(let draft) = commands.first else { + Issue.record("expected createNode first") + return + } + #expect(draft.id == id) + #expect(commands.last == .pilotComposite(id)) + let paths = draft.subGraph?.nodes.compactMap(\.lineage?.briefPath) ?? [] + #expect(paths.count == 2) + for path in paths { + #expect(path.hasPrefix(directory.path)) + let brief = try NodProtocol.makeDecoder().decode( + NodBrief.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + #expect(brief.kind == .compositeChild) + #expect(brief.fromNodeID == source.id) + } + } + + @Test + func nothingHappensWhileNodIsRampedOff() async { + let sent = LockIsolated<[GraphCommand]>([]) + let source = LoopNode(title: "M", loopType: .sketch) + let plan = NodEditablePlan( + planID: "p", title: "t", steps: NodCompositeGroupingTests.usageCapSteps) + await #expect(throws: NodGraphActions.Failure.nodUnavailable) { + try await NodGraphActions.runAsComposite( + plan, plannedIn: source, briefDirectory: Self.scratch(), enabled: false + ) { command in sent.withValue { $0.append(command) } } + } + await #expect(throws: NodGraphActions.Failure.nodUnavailable) { + try await NodGraphActions.fork( + source, in: NodForkTests.graph([source]), atMessage: "m", enabled: false, + createWorktree: { _ in + Issue.record("no worktree while off") + throw CancellationError() + }, + send: { command in sent.withValue { $0.append(command) } }) + } + #expect(sent.value.isEmpty) + } + + @Test + func forkCreatesItsWorktreeThenTheSiblingBoundToIt() async throws { + let directory = Self.scratch() + defer { try? FileManager.default.removeItem(at: directory) } + let sent = LockIsolated<[GraphCommand]>([]) + let requests = LockIsolated<[NodFork.WorktreeRequest]>([]) + let source = LoopNode(title: "Spike", loopType: .sketch, backend: .nod) + + let id = try await NodGraphActions.fork( + source, in: NodForkTests.graph([source]), atMessage: "m3", conversationID: "c", + briefDirectory: directory, enabled: true, + createWorktree: { request in + requests.withValue { $0.append(request) } + return WorktreeRef( + id: request.branch, repositoryPath: request.repositoryPath, + worktreePath: request.worktreePath, branch: request.branch) + }, + send: { command in sent.withValue { $0.append(command) } }) + + #expect(requests.value.map(\.branch) == ["nod/Spike-fork2"]) + guard case .createNode(let draft) = sent.value.first, sent.value.count == 1 else { + Issue.record("expected exactly one createNode") + return + } + #expect(draft.id == id) + #expect(draft.worktree?.branch == "nod/Spike-fork2") + #expect(draft.lineage?.kind == .fork) + let path = try #require(draft.lineage?.briefPath) + let brief = try NodProtocol.makeDecoder().decode( + NodBrief.self, from: Data(contentsOf: URL(fileURLWithPath: path))) + #expect(brief.fork == NodBrief.ForkPoint(conversationID: "c", messageID: "m3")) + } + + @Test + func aRealForkWorktreeBranchesFromTheSourcesBranch() async throws { + let root = Self.scratch() + defer { try? FileManager.default.removeItem(at: root) } + let repo = root.appendingPathComponent("repo") + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + func git(_ arguments: String...) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = + ["-C", repo.path, "-c", "user.name=t", "-c", "user.email=t@t"] + arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + } + try git("init", "-q", "-b", "main") + try git("commit", "-q", "--allow-empty", "-m", "base") + try git("checkout", "-q", "-b", "loop/a") + try "x".write(to: repo.appendingPathComponent("a.txt"), atomically: true, encoding: .utf8) + try git("add", "a.txt") + try git("commit", "-q", "-m", "on the loop branch") + try git("checkout", "-q", "main") + + let ref = try await NodGraphActions.gitWorktree( + NodFork.WorktreeRequest( + repositoryPath: repo.path, worktreePath: root.appendingPathComponent("repo-fork").path, + branch: "loop/a-fork2", startPoint: "loop/a")) + #expect(ref.branch == "loop/a-fork2") + #expect( + FileManager.default.fileExists(atPath: root.appendingPathComponent("repo-fork/a.txt").path)) + } +} diff --git a/graphcode/Tests/NodGraphLayerTests.swift b/graphcode/Tests/NodGraphLayerTests.swift index 026fef89..1c746ab7 100644 --- a/graphcode/Tests/NodGraphLayerTests.swift +++ b/graphcode/Tests/NodGraphLayerTests.swift @@ -8,7 +8,8 @@ import Testing struct NodCompositeGroupingTests { /// The design's plan: steps 1, 2 and 4 touch the server, 3 the app, 4 is the done check. static let usageCapSteps = [ - NodPlanStep(id: "1", text: "Move /export behind UsageGate", files: ["Sources/Server/Routes.swift"]), + NodPlanStep( + id: "1", text: "Move /export behind UsageGate", files: ["Sources/Server/Routes.swift"]), NodPlanStep( id: "2", text: "Return 402 with { limit, resetsAt }, and log the block", files: ["Sources/Server/UsageGate.swift"], editedByHuman: true), @@ -105,7 +106,8 @@ struct NodCompositeGroupingTests { let brief = made.briefs[0].brief #expect(brief.kind == .compositeChild) #expect(brief.fromNodeID == source.id) - #expect(brief.attachments == [NodAttachment(kind: .loopTranscript, reference: source.id.uuidString)]) + #expect( + brief.attachments == [NodAttachment(kind: .loopTranscript, reference: source.id.uuidString)]) #expect(brief.text.contains("→ 1. Move /export behind UsageGate")) #expect(brief.text.contains(" 3. Upgrade banner")) #expect(brief.text.contains("✓ 4. Tests")) @@ -153,7 +155,9 @@ struct NodForkTests { #expect(fork.draft.goal == source.goal) #expect(fork.draft.backend == .nod) #expect(fork.draft.createdBy == nil) - #expect(fork.draft.lineage == LoopLineage(kind: .fork, sourceNodeID: source.id, briefPath: "/b/f.json")) + #expect( + fork.draft.lineage + == LoopLineage(kind: .fork, sourceNodeID: source.id, briefPath: "/b/f.json")) #expect(fork.worktree.repositoryPath == "/work/repo") #expect(fork.worktree.branch == "loop/monetization-fork2") #expect(fork.worktree.worktreePath == "/work/repo-loop-monetization-fork2") @@ -170,7 +174,8 @@ struct NodForkTests { var second = first.draft.makeNode() second = LoopNode( id: second.id, title: second.title, loopType: second.loopType, lineage: second.lineage) - let next = NodFork(of: source, in: Self.graph([source, second]), atMessage: "m", briefPath: "/b") + let next = NodFork( + of: source, in: Self.graph([source, second]), atMessage: "m", briefPath: "/b") #expect(first.worktree.branch == "nod/Spike-fork2") #expect(first.worktree.startPoint == nil) #expect(first.worktree.worktreePath == "/work/repo-nod-Spike-fork2") @@ -248,7 +253,8 @@ struct NodGraphVerbTests { let named = try NodGraphVerb.parse("/handoff @billing check the 402").get() #expect( try named.commands(from: Self.me.id, in: Self.graph).get().first - == .messageNode(Self.billing.id, text: "Handoff: check the 402", from: Self.me.id, followUp: true)) + == .messageNode( + Self.billing.id, text: "Handoff: check the 402", from: Self.me.id, followUp: true)) } @Test @@ -334,7 +340,8 @@ struct NodInboundMailTests { #expect(bare.kind == .handoff && bare.body.isEmpty) let named = try #require( NodInboundMail.classify( - Self.message("[graphcode] BillingUI: Handoff: banner done", from: NodGraphVerbTests.billing.id), + Self.message( + "[graphcode] BillingUI: Handoff: banner done", from: NodGraphVerbTests.billing.id), nodeID: NodGraphVerbTests.me.id, in: NodGraphVerbTests.graph)) #expect(named.kind == .handoff && named.body == "banner done") } @@ -342,7 +349,9 @@ struct NodInboundMailTests { @Test func humanTypingAndDaemonNoticesAreNotMail() { let graph = NodGraphVerbTests.graph - #expect(NodInboundMail.classify(Self.message("fix /export"), nodeID: NodGraphVerbTests.me.id, in: graph) == nil) + #expect( + NodInboundMail.classify( + Self.message("fix /export"), nodeID: NodGraphVerbTests.me.id, in: graph) == nil) #expect( NodInboundMail.classify( Self.message("[graphcode] Stop requested from the graph."), nodeID: NodGraphVerbTests.me.id, @@ -359,7 +368,8 @@ struct NodInboundMailTests { let events: [NodEvent] = [ .mailDraft(.init(draftID: "d1", toNodeID: to, inReplyTo: "u1", text: "402")), .mailDraft(.init(draftID: "d2", toNodeID: to, inReplyTo: "other", text: "no")), - .mailDraft(.init(draftID: "d3", toNodeID: to, inReplyTo: "u1", text: "402 { limit, resetsAt }")), + .mailDraft( + .init(draftID: "d3", toNodeID: to, inReplyTo: "u1", text: "402 { limit, resetsAt }")), ] #expect(mail.draft(in: events)?.draftID == "d3") } @@ -370,7 +380,9 @@ struct NodHandoffOfferTests { static let met = NodEvent.GoalCheck( turn: 4, evaluatorModel: "haiku", clauses: [ - NodGoalClause(text: "Every paid route goes through UsageGate", met: true, evidence: "4 of 4 paid routes capped"), + NodGoalClause( + text: "Every paid route goes through UsageGate", met: true, + evidence: "4 of 4 paid routes capped"), NodGoalClause(text: "swift test passes", met: true, evidence: "31 tests pass"), ], met: true) @@ -396,15 +408,20 @@ struct NodHandoffOfferTests { func notOfferedUnmetOrWithNothingDownstream() { var unmet = Self.met unmet.met = false - #expect(NodHandoffOffer.make(check: unmet, nodeID: NodGraphVerbTests.me.id, in: NodGraphVerbTests.graph) == nil) - #expect(NodHandoffOffer.make(check: Self.met, nodeID: NodGraphVerbTests.notes.id, in: NodGraphVerbTests.graph) == nil) + #expect( + NodHandoffOffer.make( + check: unmet, nodeID: NodGraphVerbTests.me.id, in: NodGraphVerbTests.graph) == nil) + #expect( + NodHandoffOffer.make( + check: Self.met, nodeID: NodGraphVerbTests.notes.id, in: NodGraphVerbTests.graph) == nil) } } @Suite struct NodEditablePlanTests { static var plan: NodEditablePlan { - NodEditablePlan(planID: "p1", title: "Usage caps", steps: NodCompositeGroupingTests.usageCapSteps) + NodEditablePlan( + planID: "p1", title: "Usage caps", steps: NodCompositeGroupingTests.usageCapSteps) } @Test From dde3e480475f90c2dd0722cc3cc6df1bc48963da Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:42:54 -0700 Subject: [PATCH 5/6] Give NodFork.WorktreeRequest a public initializer Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- GraphcodeKit/Sources/Domain/NodFork.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/GraphcodeKit/Sources/Domain/NodFork.swift b/GraphcodeKit/Sources/Domain/NodFork.swift index 8c49fc2e..8228f3c9 100644 --- a/GraphcodeKit/Sources/Domain/NodFork.swift +++ b/GraphcodeKit/Sources/Domain/NodFork.swift @@ -12,6 +12,15 @@ public struct NodFork: Equatable, Sendable { public var branch: String /// What the branch starts from: the source's branch, so the fork sees its edits. public var startPoint: String? + + public init( + repositoryPath: String, worktreePath: String, branch: String, startPoint: String? = nil + ) { + self.repositoryPath = repositoryPath + self.worktreePath = worktreePath + self.branch = branch + self.startPoint = startPoint + } } public var draft: NodeDraft From ecc5ce53d4c947b685889e347b1fc066de6363a7 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 22:14:24 -0700 Subject: [PATCH 6/6] Move Nod graph verbs out of Domain, which builds without IPC Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- GraphcodeKit/Sources/{Domain => IPC}/NodGraphVerbs.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename GraphcodeKit/Sources/{Domain => IPC}/NodGraphVerbs.swift (100%) diff --git a/GraphcodeKit/Sources/Domain/NodGraphVerbs.swift b/GraphcodeKit/Sources/IPC/NodGraphVerbs.swift similarity index 100% rename from GraphcodeKit/Sources/Domain/NodGraphVerbs.swift rename to GraphcodeKit/Sources/IPC/NodGraphVerbs.swift