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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public enum GraphcodeCommand: Equatable, Sendable {
/// matching how `updateNode` fills `updatedBy`.
case promoteNode(projectPath: String, nodeID: UUID, promotion: SketchPromotion)
case memoNode(projectPath: String, nodeID: UUID, text: String)
/// Report a goal loop's goal as met; the trailing words are the result, optional.
case completeNode(projectPath: String, nodeID: UUID, result: String?)
/// Replace the loop's playbook (`NodeMemory.refinePlaybook`) — trailing words, or a
/// whole file via `--file` since a playbook is a multi-line document and argv words
/// arrive flattened. `--rollback` restores the previous version instead.
Expand Down Expand Up @@ -97,6 +99,7 @@ public enum GraphcodeCommand: Equatable, Sendable {
graphcode node promote <project-path> <node-id> --type <goal|turn|time> [options]
give a main loop a shape, keeping its session, edges and memory
graphcode node memo <project-path> <node-id> <note…>
graphcode node done <project-path> <node-id> [result…]
graphcode node refine <project-path> <node-id> <playbook…|--file f|--rollback>
graphcode node pilot <project-path> <node-id> dry-run a composite
graphcode node arm <project-path> <node-id> arm it (needs a pilot first)
Expand Down Expand Up @@ -216,6 +219,10 @@ public enum GraphcodeCommand: Equatable, Sendable {
node memo appends a note to the loop's own memory log — what the next pass reads
before starting. Record dead ends and decisions, not a transcript.

node done reports a goal loop's goal as met, with an optional result. Run it only
once the goal holds — never while waiting on mail, CI, or loops it created. A
predicate still decides, and a leader resolves once the loops it created have.

node refine replaces the loop's playbook — its own distilled method, carried into
every wake ahead of the history. Whole document each time (--file for multi-line);
the old version is snapshotted, --rollback restores it. A loop may refine itself;
Expand Down Expand Up @@ -350,7 +357,7 @@ public enum GraphcodeCommand: Equatable, Sendable {
}
return .createNode(projectPath: path, draft: try parseDraft(arguments), into: into)
case "stop", "restart", "delete", "pilot", "arm", "send", "update", "memo", "promote",
"refine":
"refine", "done":
let raw = try take(&arguments, name: "node-id")
guard let nodeID = UUID(uuidString: raw) else {
throw ParseError.invalidValue(argument: "node-id", value: raw)
Expand Down Expand Up @@ -392,6 +399,10 @@ public enum GraphcodeCommand: Equatable, Sendable {
let text = arguments.joined(separator: " ").trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { throw ParseError.missingArgument("note") }
return .memoNode(projectPath: path, nodeID: nodeID, text: text)
case "done":
let result = arguments.joined(separator: " ").trimmingCharacters(in: .whitespaces)
return .completeNode(
projectPath: path, nodeID: nodeID, result: result.isEmpty ? nil : result)
case "refine":
return try parseRefine(arguments, projectPath: path, nodeID: nodeID)
default:
Expand Down
19 changes: 19 additions & 0 deletions GraphcodeKit/Sources/Domain/GoalVerdict.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

/// A backend's own answer to "is this session's goal met?", read out of what the backend
/// records — never inferred from a turn ending. A turn ends while a loop waits on mail,
/// CI or its children; only a goal-specific record says the condition holds (#346).
public struct GoalVerdict: Equatable, Sendable {
public var met: Bool
/// The backend's stated reason, when it gives one — Claude Code's evaluator does.
public var detail: String?
/// When the backend wrote the record, so a verdict on an earlier goal can be told apart
/// from one on the goal the loop has now.
public var recordedAt: Date?

public init(met: Bool, detail: String? = nil, recordedAt: Date? = nil) {
self.met = met
self.detail = detail
self.recordedAt = recordedAt
}
}
13 changes: 13 additions & 0 deletions GraphcodeKit/Sources/Domain/GraphcodeSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,15 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
/// (`SessionBriefing`). Off means loops behave exactly as they did before briefings
/// existed — they do the work they were given and never create anything.
public var briefsSessionsAboutTheGraph: Bool
/// How long a resolved loop's session is kept after it resolves, in minutes; `0` keeps
/// it until someone deletes the loop. Ending it frees the agent process and keeps the
/// transcript, so opening the loop resumes the conversation (#346). A number rather than
/// an optional because an encoded `nil` is an absent key, which reads back as the default.
public var endsResolvedSessionsAfterMinutes: Int

public var resolvedSessionGrace: Duration? {
endsResolvedSessionsAfterMinutes > 0 ? .seconds(endsResolvedSessionsAfterMinutes * 60) : nil
}

/// Whether graphcode picks a model for loops nobody chose one for.
///
Expand Down Expand Up @@ -441,8 +450,10 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
daemonHeartbeatEnabled: Bool = false,
mailroomEnabled: Bool = true,
keepsMacAwakeWhileLoopsRun: Bool = false,
endsResolvedSessionsAfterMinutes: Int = 10,
worktreePolicies: [String: WorktreeHygienePolicy] = [:]
) {
self.endsResolvedSessionsAfterMinutes = endsResolvedSessionsAfterMinutes
self.defaultBackend = defaultBackend.isSpiked ? defaultBackend : .claudeCode
self.codexApprovals = codexApprovals
self.openCodePermissions = openCodePermissions
Expand Down Expand Up @@ -485,6 +496,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
?? .allowEverything
briefsSessionsAboutTheGraph =
try container.decodeIfPresent(Bool.self, forKey: .briefsSessionsAboutTheGraph) ?? true
endsResolvedSessionsAfterMinutes =
max(0, try container.decodeIfPresent(Int.self, forKey: .endsResolvedSessionsAfterMinutes) ?? 10)
// Absent in files written before the setting existed, and those loops were all being
// routed by graphcode. They take the new default — off — which is the point of #10:
// the fix has to reach people who already have a settings file, not just new ones.
Expand Down
19 changes: 18 additions & 1 deletion GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
/// Set when the daemon stopped this loop because its backend's CLI is not on the
/// launch shell's PATH; cleared by the restart that follows the fix.
public var launchFailure: LaunchFailure?
/// How the loop resolved; `nil` while it is unresolved, and for loops resolved before
/// the field existed.
public var resolution: LoopResolution?
/// A completion reported while loops this one created were still unresolved — held,
/// and applied the moment the last of them resolves. A leader whose own part is done
/// is not done while its workers run.
public var pendingCompletion: LoopResolution?
/// 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 state: LoopState
public var createdAt: Date

Expand Down Expand Up @@ -495,7 +505,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
case lastMailroomRead, mailroomWatch
case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly
case summary, board, heartbeatIntervalSeconds, stallReason
case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure
case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure, resolution
case pendingCompletion, goalSetAt
}

/// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph`
Expand Down Expand Up @@ -552,6 +563,12 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
?? decoder.legacyMailroomValue(MailroomWatch.self, "artifactoryWatch")
stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason)
launchFailure = try container.decodeIfPresent(LaunchFailure.self, forKey: .launchFailure)
// `try?`: a basis added by a newer daemon must cost an older app the label, not the
// whole graph — a client cannot skip a frame it fails to decode.
resolution = try? container.decodeIfPresent(LoopResolution.self, forKey: .resolution)
pendingCompletion =
try? container.decodeIfPresent(LoopResolution.self, forKey: .pendingCompletion)
goalSetAt = try? container.decodeIfPresent(Date.self, forKey: .goalSetAt)
state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
}
Expand Down
58 changes: 58 additions & 0 deletions GraphcodeKit/Sources/Domain/LoopResolution.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation

/// How a loop reached `.succeeded` or `.failed` — the evidence behind the word on its card.
///
/// A SUCCEEDED that a predicate proved and one an agent claimed are not the same claim,
/// and a human deciding whether to trust a result has to be able to tell them apart
/// without opening the loop's memory (issue #346).
public struct LoopResolution: Codable, Equatable, Sendable {
public enum Basis: String, Codable, Equatable, Sendable, CaseIterable {
/// The goal's `--predicate` exited 0.
case predicate
/// The backend recorded its own `/goal` as met (`GoalVerdictReader`).
case nativeGoal
/// The loop itself, or the loop that created it, ran `graphcode node done`.
case agentReported
/// Someone at the Mac's own shell ran `graphcode node done`.
case human
/// The pane watching the session saw its process finish.
case sessionExited
/// A composite's workers rolled up to a terminal state.
case workers

/// A judgement on the goal itself, as opposed to a surface reporting that something
/// ended — which a human's repeated check approval legitimately does more than once.
public var isVerdict: Bool {
switch self {
case .predicate, .nativeGoal, .agentReported, .human: return true
case .sessionExited, .workers: return false
}
}
}

public var basis: Basis
/// What the resolver had to say about it, when it said anything.
public var detail: String?
public var resolvedAt: Date

public init(basis: Basis, detail: String? = nil, resolvedAt: Date = Date()) {
self.basis = basis
self.detail = detail
self.resolvedAt = resolvedAt
}

/// The card's line for a resolved loop: the basis, then the resolver's own words.
public var displayLine: String {
let phrase: String
switch basis {
case .predicate: phrase = "predicate passed"
case .nativeGoal: phrase = "goal met"
case .agentReported: phrase = "reported done"
case .human: phrase = "marked done"
case .sessionExited: phrase = "session exited"
case .workers: phrase = "workers rolled up"
}
guard let detail, !detail.isEmpty else { return phrase }
return "\(phrase) · \(detail)"
}
}
8 changes: 4 additions & 4 deletions GraphcodeKit/Sources/Domain/SessionBriefing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,10 @@ public enum SessionBriefing {
two of the three types start running the moment you create them, and one does not.

- `--type goal --goal <what done looks like>` — **the default, and what you almost
always want.** The loop starts immediately and resolves when its own session
finishes the goal. Add `--predicate <shell command>` only when a command can
actually decide it (exit 0 means met, e.g. a test run); without one, finishing the
work is what resolves it.
always want.** The loop starts immediately and resolves when its goal is met. Add
`--predicate <shell command>` only when a command can actually decide it (exit 0
means met, e.g. a test run); without one, it resolves when its backend records the
goal as met or when it runs `graphcode node done`.
\(timeBullet.trimmingCharacters(in: .whitespacesAndNewlines))
- `--type turn --check <what a human verifies>` — for work a **human** must review
each turn before it continues. **A turn-based loop does not start on its own**:
Expand Down
Loading
Loading