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
165 changes: 84 additions & 81 deletions GraphcodeKit/Sources/CLI/GraphcodeCommand.swift

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions GraphcodeKit/Sources/Domain/GraphcodeSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,20 +362,20 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
/// heartbeat loops immediately without restarting anything.
public var daemonHeartbeatEnabled: Bool

/// Whether loops get the **Artifactory** — a shared, unaddressed message board the
/// Whether loops get the **Mailroom** — a shared, unaddressed message board the
/// graph's loops post to and read without any wiring: `node send` and edges are
/// for talking to a peer you already know, while the Artifactory is the ambient
/// for talking to a peer you already know, while the Mailroom is the ambient
/// counterpart, a note dropped for whoever comes next (a decision, a dead end, a
/// claim on a task), discoverable by loops that did not exist when it was written.
///
/// **Off by default, and beta-ramped.** The app resolves
/// `FeatureRamps.Feature.artifactory` — beta installs first, stable only when the
/// `FeatureRamps.Feature.mailroom` — beta installs first, stable only when the
/// ramp says so — and writes the resolved value here, which is the bit the daemon
/// (which cannot see ramps or `UserDefaults`) actually enforces: every `artifactory`
/// (which cannot see ramps or `UserDefaults`) actually enforces: every `mailroom`
/// command, the briefing's board section, and the wake digest's pointer all read
/// this. A flip the human made in Settings is a recorded choice, preserved the way
/// `summarisesLoops`' is.
public var artifactoryEnabled: Bool
public var mailroomEnabled: Bool

/// Whether `graphcoded` keeps the Mac awake while any loop is running
/// (`AwakeAssertion`). Off by default and deliberately so: a background process that
Expand Down Expand Up @@ -404,7 +404,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
summaryUsesModel: Bool = false,
visualisesSummaries: Bool = false,
daemonHeartbeatEnabled: Bool = false,
artifactoryEnabled: Bool = true,
mailroomEnabled: Bool = true,
keepsMacAwakeWhileLoopsRun: Bool = false,
worktreePolicies: [String: WorktreeHygienePolicy] = [:]
) {
Expand All @@ -420,7 +420,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
self.summaryUsesModel = summaryUsesModel
self.visualisesSummaries = visualisesSummaries
self.daemonHeartbeatEnabled = daemonHeartbeatEnabled
self.artifactoryEnabled = artifactoryEnabled
self.mailroomEnabled = mailroomEnabled
self.keepsMacAwakeWhileLoopsRun = keepsMacAwakeWhileLoopsRun
self.worktreePolicies = worktreePolicies
}
Expand Down Expand Up @@ -474,8 +474,9 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
// beta-only — "no app has spoken yet" — and now means the default: a CLI-only
// machine or a hand-edited file gets the board the way every install does, and
// the app still writes an explicit value the moment a human flips the switch.
artifactoryEnabled =
try container.decodeIfPresent(Bool.self, forKey: .artifactoryEnabled) ?? true
mailroomEnabled =
try container.decodeIfPresent(Bool.self, forKey: .mailroomEnabled)
?? decoder.legacyMailroomValue(Bool.self, "artifactoryEnabled") ?? true
// Absent means nobody has asked for it, which is the default. An update must never
// start holding a power assertion on a machine whose owner did not choose that.
keepsMacAwakeWhileLoopsRun =
Expand Down
16 changes: 9 additions & 7 deletions GraphcodeKit/Sources/Domain/LoopGraph.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ArtifactoryKit
import Foundation
import IdentifiedCollections
import MailroomKit

/// The unit `graphcoded`'s `GraphStore` owns and the graph canvas renders — see
/// docs/02-graph-of-loops.md#loopgraph.
Expand All @@ -20,16 +20,16 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
public var scope: LoopGraphScope
public var nodes: IdentifiedArrayOf<LoopNode>
public var edges: IdentifiedArrayOf<LoopEdge>
/// The project's Artifactory — every post any loop has dropped onto the shared board,
/// The project's Mailroom — every post any loop has dropped onto the shared board,
/// oldest first, notes and mirrored records each capped on their own budget
/// (`Artifactory.maxNotes`, `Artifactory.maxRecords`). Kept on the graph rather than in a
/// (`Mailroom.maxNotices`, `Mailroom.maxLetters`). Kept on the graph rather than in a
/// side store so it inherits for free everything graph state already has: one
/// writer (the daemon), atomic persistence beside the graph file, a snapshot in
/// every `.graphChanged` (which is how the CLI reads it — no second read path), and
/// the global graph at `graphcode://global` becoming a cross-project board without
/// a line of extra code. Empty for anyone who never touches the board; graphs saved
/// before the field existed decode with it empty.
public var artifactory: [ArtifactoryPost] = []
public var mailroom: [MailroomPost] = []

public var project: ProjectRef {
get { scope.projectRef }
Expand Down Expand Up @@ -254,7 +254,7 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
// MARK: - Coding

private enum CodingKeys: String, CodingKey {
case id, nodes, edges, artifactory
case id, nodes, edges, mailroom
/// Persisted as a `ProjectRef` rather than as the scope enum. Every graph on disk
/// predates `LoopGraphScope`, and the ref round-trips both cases losslessly (the
/// global graph's reserved path decodes straight back to `.global`), so there was
Expand All @@ -269,7 +269,9 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
scope = LoopGraphScope(projectPath: ref.path, name: ref.name)
nodes = try container.decodeIfPresent(IdentifiedArrayOf<LoopNode>.self, forKey: .nodes) ?? []
edges = try container.decodeIfPresent(IdentifiedArrayOf<LoopEdge>.self, forKey: .edges) ?? []
artifactory = try container.decodeIfPresent([ArtifactoryPost].self, forKey: .artifactory) ?? []
mailroom =
try container.decodeIfPresent([MailroomPost].self, forKey: .mailroom)
?? decoder.legacyMailroomValue([MailroomPost].self, "artifactory") ?? []
}

public func encode(to encoder: Encoder) throws {
Expand All @@ -280,6 +282,6 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable {
try container.encode(edges, forKey: .edges)
// Absent while empty, so a graph file nobody has posted to stays byte-for-byte
// what it was — the same reason `hasActiveDependents` never reaches disk.
if !artifactory.isEmpty { try container.encode(artifactory, forKey: .artifactory) }
if !mailroom.isEmpty { try container.encode(mailroom, forKey: .mailroom) }
}
}
35 changes: 19 additions & 16 deletions GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ArtifactoryKit
import Foundation
import MailroomKit

/// One node in a graph of loops: a unit of agentic work with a well-defined hand-off
/// contract, running inside a real CLI session. See docs/02-graph-of-loops.md.
Expand Down Expand Up @@ -173,18 +173,18 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
/// The template a **timed or composite** loop still follows — see `TemplateFollow`
/// for why only those two types do. `nil` for every snapshot loop.
public var templateFollow: TemplateFollow?
/// The newest Artifactory post this loop has read — `ArtifactoryPost.id` of the last
/// post a `graphcode artifactory sync` showed it. `nil` has not synced yet and makes
/// The newest Mailroom post this loop has read — `MailroomPost.id` of the last
/// post a `graphcode mail inbox` showed it. `nil` has not synced yet and makes
/// every post unread; the cursor only moves through sync, so a loop that ignores
/// the board accrues nothing but a number, and a loop that died with unread mail
/// finds it still waiting at the next wake.
public var lastArtifactoryRead: Int?
/// This loop's standing subscription to its project's Artifactory — set and cleared
/// with `graphcode artifactory watch`. Non-nil means every matching post also gets
public var lastMailroomRead: Int?
/// This loop's standing subscription to its project's Mailroom — set and cleared
/// with `graphcode mail watch`. Non-nil means every matching post also gets
/// delivered to this loop the way a `--follow-up` message is: typed into a live
/// idle session, staged to a busy one's memory, waiting in the post itself for a
/// loop that is gone. The post is the durable half; this is only the ding.
public var artifactoryWatch: ArtifactoryWatch?
public var mailroomWatch: MailroomWatch?
/// Why the loop is `.stalled`, when the graph knows. A budget exhaustion and a stall
/// bound both land in the same terminal state, and both wrote their reason only to
/// the loop's memory log — every surface then showed a bare STALLED and a human had
Expand Down Expand Up @@ -219,8 +219,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
createdBy: UUID? = nil,
createdFromTemplateID: UUID? = nil,
templateFollow: TemplateFollow? = nil,
lastArtifactoryRead: Int? = nil,
artifactoryWatch: ArtifactoryWatch? = nil,
lastMailroomRead: Int? = nil,
mailroomWatch: MailroomWatch? = nil,
stallReason: String? = nil,
state: LoopState = .idle,
createdAt: Date = Date()
Expand Down Expand Up @@ -248,8 +248,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
self.createdBy = createdBy
self.createdFromTemplateID = createdFromTemplateID
self.templateFollow = templateFollow
self.lastArtifactoryRead = lastArtifactoryRead
self.artifactoryWatch = artifactoryWatch
self.lastMailroomRead = lastMailroomRead
self.mailroomWatch = mailroomWatch
self.stallReason = stallReason
self.state = state
self.createdAt = createdAt
Expand Down Expand Up @@ -489,7 +489,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
private enum CodingKeys: String, CodingKey {
case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier
case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy
case lastArtifactoryRead, artifactoryWatch
case lastMailroomRead, mailroomWatch
case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly
case summary, board, heartbeatIntervalSeconds, stallReason
case createdFromTemplateID, templateFollow, sessionRestarts
Expand Down Expand Up @@ -539,11 +539,14 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
// snapshots, which is what nil says.
templateFollow = try container.decodeIfPresent(
TemplateFollow.self, forKey: .templateFollow)
// Absent from graphs saved before the Artifactory existed — every loop simply has
// Absent from graphs saved before the Mailroom existed — every loop simply has
// not read anything yet, which is what `nil` says.
lastArtifactoryRead = try container.decodeIfPresent(Int.self, forKey: .lastArtifactoryRead)
artifactoryWatch = try container.decodeIfPresent(
ArtifactoryWatch.self, forKey: .artifactoryWatch)
lastMailroomRead =
try container.decodeIfPresent(Int.self, forKey: .lastMailroomRead)
?? decoder.legacyMailroomValue(Int.self, "lastArtifactoryRead")
mailroomWatch =
try container.decodeIfPresent(MailroomWatch.self, forKey: .mailroomWatch)
?? decoder.legacyMailroomValue(MailroomWatch.self, "artifactoryWatch")
stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason)
state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
Expand Down
28 changes: 28 additions & 0 deletions GraphcodeKit/Sources/Domain/MailroomLegacyDecoding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// The spellings a file written before the Artifactory → Mailroom rename uses.
///
/// A dynamic key rather than extra `CodingKeys` cases, for a reason that is easy to
/// discover the hard way: `LoopNode` and `GraphcodeSettings` both hand-write
/// `init(from:)` and let the compiler synthesise `encode(to:)`, and synthesis requires
/// every case to have a matching property. A legacy key has none — and must be read
/// and never written, or the old spelling would outlive the rename in every file the
/// app touches.
private struct MailroomLegacyKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init(_ stringValue: String) { self.stringValue = stringValue }
init?(stringValue: String) { self.init(stringValue) }
init?(intValue: Int) { nil }
}

extension Decoder {
/// What an older file stored under `key`, or nil — never a throw. A legacy value that
/// will not decode falls back to the current default instead of failing the whole
/// read, which for a graph means `ProjectPersistence` reporting "no saved graph" and
/// for settings means every other preference resetting alongside it.
func legacyMailroomValue<T: Decodable>(_ type: T.Type, _ key: String) -> T? {
guard let container = try? container(keyedBy: MailroomLegacyKey.self) else { return nil }
return (try? container.decodeIfPresent(type, forKey: MailroomLegacyKey(key))) ?? nil
}
}
47 changes: 23 additions & 24 deletions GraphcodeKit/Sources/Domain/SessionBriefing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,46 +77,45 @@ public enum SessionBriefing {
Do not reach for this for one-off work: "check the build" is a goal, "check the
build every hour" is time-based.
"""
// The Artifactory's section exists only while the beta ramp has the feature on: a
// The Mailroom's section exists only while the beta ramp has the feature on: a
// briefing that taught verbs the daemon would refuse would send every loop
// through a refusal once per idea. It interpolates inline after the "one-off."
// sentence (the value leading with blank lines) so that off — an empty value —
// leaves the briefing byte-for-byte what it was before this section existed.
let artifactorySection =
settings.artifactoryEnabled
let mailroomSection =
settings.mailroomEnabled
? """


## The Artifactorynotes for whoever comes next
## The Mailroomthe graph's mail, and notices for whoever comes next

`node send` reaches one peer you already know. The Artifactory is the shared
`node send` reaches one peer you already know. The Mailroom is the shared
counterpart: an unaddressed board any loop can post to and any loop can read,
with no wiring and no ids — post for *whoever comes next*, including loops that
do not exist yet. Check it at the start of a pass; post the moment you learn
something a peer or successor should not have to rediscover:

```sh
graphcode artifactory sync \(projectPath) # read what you have not seen, mark it read
graphcode artifactory read \(projectPath) <post-id> # one post in full
graphcode artifactory post \(projectPath) [--topic <t>] <note…> # leave something behind
graphcode artifactory list \(projectPath) # read-only peek, cursor untouched
graphcode artifactory watch \(projectPath) [--topic <t>] # ring me when new mail lands
graphcode mail inbox \(projectPath) # read what you have not seen, mark it read
graphcode mail read \(projectPath) <post-id> # one post in full
graphcode mail post \(projectPath) [--topic <t>] <notice…> # leave something behind
graphcode mail list \(projectPath) # read-only peek, cursor untouched
graphcode mail watch \(projectPath) [--topic <t>] # ring me when new mail lands
```

Post decisions made, dead ends hit, claims staked ("I'm taking issue #12") —
a note for a peer, not a transcript. Sync before you rely on nobody having
got there first, and watch a topic when you want the board to come to you.
A big backlog prints as one line per post and says so; `read <post-id>` then
spends context only on the ones that turned out to matter.

The board also keeps the record for you: every direct message, message-edge
delivery, and handoff (topics `direct` and `handoff`) is mirrored onto it
automatically, so a loop that joins mid-flight can read what was already said.
Those mirrored records are the record, not the delivery — they never ring a
watcher, so watching only those topics stays silent, and they prune on their
own budget so graph chatter can never crowd out a note. Your posts outlive
you: they stay after you resolve, and after your loop is deleted — only the
byline goes.
a notice for a peer, not a transcript. Check your inbox before you rely on
nobody having got there first, and watch a topic when you want the room to
come to you. A big backlog prints as one line per post and says so;
`read <post-id>` then spends context only on the ones that turned out to matter.

The room also keeps the letters: every direct message, message-edge delivery,
and handoff (topics `direct` and `handoff`) is copied here automatically, so a
loop that joins mid-flight can read what was already said. A letter is the
room's copy, not the delivery — it never rings a watcher, so watching only
those topics stays silent, and letters prune on their own budget so graph
chatter can never crowd out a notice. Your posts outlive you: they stay after
you resolve, and after your loop is deleted — only the byline goes.
"""
: ""
return """
Expand Down Expand Up @@ -191,7 +190,7 @@ public enum SessionBriefing {
the exact command for reporting results back to it. For recurring communication,
an edge is still the right tool: a `message` edge fires automatically when you
finish, a `handoff` sequences the other loop after you. This command is the
one-off.\(artifactorySection)
one-off.\(mailroomSection)

## Remembering across passes

Expand Down
Loading
Loading