Repository navigation
Expand file tree
/
Copy pathGraphcodeCommand.swift
More file actions
1187 lines (1104 loc) · 58.4 KB
/
Copy pathGraphcodeCommand.swift
File metadata and controls
1187 lines (1104 loc) · 58.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import Foundation
import MailroomKit
/// Argument parsing and output formatting for the `graphcode` CLI
/// (docs/03-architecture.md#cli-graphcode).
///
/// Lives in `GraphcodeKit` rather than in the CLI target's own sources so it's testable
/// from `graphcodeTests` without spawning the binary — the same reasoning that put
/// `GraphStore` here instead of inside `graphcoded`.
///
/// No third-party argument parser: the surface is a handful of verbs, and adding a
/// dependency to the one target that has to stay a plain command-line tool isn't worth
/// it for that.
public enum GraphcodeCommand: Equatable, Sendable {
case help
case listProjects
case status(projectPath: String)
/// `into` is the composite whose sub-graph the loop belongs in, when one was named —
/// the command is the same, addressed at a nested graph instead of this one.
case createNode(projectPath: String, draft: NodeDraft, into: UUID? = nil)
case createEdge(projectPath: String, from: UUID, to: UUID, spec: EdgeSpec)
case stopNode(projectPath: String, nodeID: UUID)
/// Kill the loop's session and resume it on the same transcript — the verb for a
/// replaced `zmx` or backend CLI. `restartSessions` does it for every live loop.
case restartNode(projectPath: String, nodeID: UUID)
case restartSessions(projectPath: String)
case deleteNode(projectPath: String, nodeID: UUID)
case sendMessage(projectPath: String, nodeID: UUID, text: String, followUp: Bool = false)
case updateNode(projectPath: String, nodeID: UUID, update: NodeUpdate)
/// Give a sketch a shape — the CLI half of the canvas's "Promote to…" menu. The
/// promoter's identity is attributed at execution (`ZMX_SESSION`), not parsed here,
/// 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.
case refineNode(projectPath: String, nodeID: UUID, text: String)
case rollbackRefinement(projectPath: String, nodeID: UUID)
case pilotComposite(projectPath: String, nodeID: UUID)
case armComposite(projectPath: String, nodeID: UUID)
case usage(projectPath: String)
/// Kill `zmx` sessions no graph node in any workspace owns anymore — the on-demand
/// recovery for PTY exhaustion (#197). No project path: orphanhood is a machine-wide
/// question, and scoping it to one project is exactly the mistake that kills live
/// sessions.
case reap(dryRun: Bool)
case exportNode(projectPath: String, nodeID: UUID, output: String, includeChildren: Bool = false)
case exportGraph(projectPath: String, output: String)
case importNodes(projectPath: String, fromZip: String, asChildOf: UUID? = nil)
/// The Mailroom verbs (docs/03-architecture.md#cli-graphcode): the shared,
/// unaddressed board any loop can write to and read. Attribution is not parsed —
/// like `sendMessage`, the sender comes from `ZMX_SESSION` at execution.
case mailroomPost(projectPath: String, topic: String?, text: String)
/// Read unread, then mark the board read — the cursor belongs to the calling loop,
/// so this verb only means anything run from inside a session. `--headlines` prints
/// one triage line per unread post instead of full bodies (pair it with `read`);
/// `--mark` advances the cursor without printing the backlog ("start me from now");
/// `--json` emits the unread posts machine-readably; `--full` insists on every body
/// where the verb would otherwise triage a large backlog down to headlines itself.
case mailroomInbox(
projectPath: String, headlines: Bool, mark: Bool, json: Bool, full: Bool)
/// One post in full, by id — the deep-read half of `sync --headlines` triage.
/// Read-only: the post is in the snapshot, no command reaches the daemon.
case mailroomRead(projectPath: String, postID: Int)
/// The whole board, read-only: no command reaches the daemon, no cursor moves.
/// `--search` filters by substring across author/topic/body; `--json` emits the
/// board machine-readably.
case mailroomList(projectPath: String, search: String?, json: Bool)
/// Subscribe (`on: true`, `--topic` filters) or unsubscribe (`--off`) the calling
/// loop; like `sync`, the subscription belongs to a loop, not a shell.
case mailroomWatch(projectPath: String, on: Bool, topic: String?)
public enum ParseError: Error, Equatable {
case unknownCommand(String)
case unknownOption(String)
case missingArgument(String)
case invalidValue(argument: String, value: String)
case invalidDraft
/// `FeatureRamps.nod` is off for this install — `NodRuntimeLocator.isRampedOn`.
case nodNotEnabled
}
public static let helpText = """
graphcode — drive a graph of loops from the shell.
USAGE
graphcode projects
graphcode status <project-path>
graphcode node create <project-path> --title <t> --type <main|turn|goal|time|composite> [options]
graphcode node stop <project-path> <node-id>
graphcode node restart <project-path> <node-id> kill its session and resume it on
the same transcript — for a replaced zmx or backend CLI
graphcode sessions restart <project-path> the same, for every live loop
graphcode node delete <project-path> <node-id> removes it, its edges, session
and memory — irreversible; stop is the reversible verb
graphcode node send <project-path> <node-id> [--follow-up] <message…>
graphcode node update <project-path> <node-id> [options]
graphcode node promote <project-path> <node-id> --type <goal|turn|time> [options]
give a main loop a shape, or turn a goal loop time-based
and back, 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)
graphcode edge create <project-path> <from-id> <to-id> [--kind <k>] [--condition <c>]
graphcode mail post <project-path> [--topic <t>] <notice…>
post a notice to the whole graph, for whoever comes next
graphcode mail inbox <project-path> [--headlines] [--full] [--mark] [--json]
read your unread mail; what prints is what is marked read.
A large backlog prints as headlines on its own and says
so — --full insists on every body, --headlines insists on
triage lines (deep-read either with `read`) — and comes a
page at a time: run it again for the next page. --mark
marks everything read without printing it, --json is the
machine-readable shape. Combined, the output wins in the
order --json > --mark > --headlines > --full
graphcode mail read <project-path> <post-id>
one post in full — the deep-read half of --headlines
graphcode mail list <project-path> [--search <text>] [--json]
the whole room, read-only — no cursor moves; --search
filters by substring across author, topic and body
graphcode mail watch <project-path> [--topic <t>] [--off]
have matching posts typed into this loop's session as they
land; --off stops watching
graphcode usage <project-path>
graphcode reap [--dry-run] recover suspected orphaned zmx sessions when PTYs
cannot be allocated or deleted loops leave sessions behind
graphcode node export <project-path> <node-id> [--output file.zip] [--no-children]
packages the loop and everything descended from it — child
loops, sub-loops, session memory — into a shareable zip
graphcode graph export <project-path> [--output file.zip]
graphcode node import <project-path> <file.zip> [--as-child-of <parent-id>]
splices a bundle's loops in with fresh identities; name a
parent to hang them under an existing loop
The reserved path graphcode://global addresses the always-resident global graph —
the app's pinned "Graph" row — which every other verb accepts wherever
<project-path> appears.
A loop created with --into lives inside its composite's sub-graph, but its id is
still unique across the whole tree: node stop/delete/send/update/memo/refine and
edge create accept it as-is, paired with the project path of the graph the
composite belongs to.
RECOVERY AND SAFETY
Use `graphcode projects` to discover project paths and `status` to inspect state
before retrying a command. `GRAPHCODE_SUPPORT_DIR` selects the workspace for
ordinary commands; `reap` is the exception: it reads every discovered workspace
on this machine and queries zmx directly, so it also works when graphcoded is down.
`graphcode reap --dry-run` is read-only and must be run first. Plain `reap` kills
sessions that have no owner in any persisted graph, quick-chat store, or terminal
layout; attached sessions are protected. Do not use reap for ordinary cleanup.
`node stop` is reversible; `node delete` removes the node, edges, memory, and
session irreversibly. `--help` and `-h` only print this help and never execute a
command. Unknown options are errors; do not guess flag spellings.
NODE OPTIONS
--into <composite-id> create this loop *inside* that composite's sub-graph rather
than beside it — the CLI half of "add loops inside".
(`edge create` has its own --into; that one takes a project
path and only means anything on a --kind spawn.)
--check <text> what a human verifies each turn; optional
--goal <text> required for --type goal
--predicate <cmd> optional stop condition for --type goal (exit 0 = met)
--prompt <text> required for --type time; put the cadence in it (/loop 1h …).
For --type main it is the optional starting note
--heartbeat <secs> for --type time, experimental: the daemon delivers the prompt
every interval instead of the prompt carrying /loop. Needs
"Daemon heartbeat" enabled in the app's Settings; the prompt
is then the bare task, no cadence in it
--backend <name> claudeCode | copilotCLI | codex | openCode | pi — default: run from inside a
loop, the creating loop's backend; otherwise the default
in Settings -> Sessions
--model <tier> fast | standard | capable (default: by loop type)
--metric <cmd> how the loop's performance is measured — fed into its prompt
so it can score itself as it works, and sampled by graphcoded
once per cycle pass (last stdout line must be a number)
--direction <d> minimize | maximize (default: maximize)
--budget <tokens> for --type goal: end the loop once its backend reports this
many tokens spent — input + output + every cache-read and
cache-creation token the API metered. On Claude Code each
turn re-meters the whole context as cache reads, so a
budget burns per turn, not per hour. Reported, never
estimated — a loop whose backend reports nothing is never
stopped by a budget
--skip-unchanged for --type goal: while the session is busy, don't re-run
the predicate while HEAD and the dirty file list are
unchanged since its last failure. An idle session gets
one more predicate run and failure notice per unchanged
tree, then polls stay quiet until the tree changes —
the loop is the only writer of its own tree, so waiting
on a change would wait on the loop itself
UPDATE OPTIONS (node update; pass only what changes)
--goal, --predicate, --prompt, --check, --model, --metric, --direction as above
--poll <seconds> how often the predicate is polled
--stall <seconds> stall bound; 0 clears it
--budget <tokens> token budget, counted as at creation; 0 clears it
--heartbeat <secs> daemon heartbeat interval; 0 returns cadence to the prompt
--skip-unchanged <true|false>
A loop may not change its own --predicate or --budget: the verifier stays outside
the verified. Session-facing changes reach a live session as a [graphcode] notice.
SEND OPTIONS (node send)
--follow-up first word after the id: don't interrupt — the message is
staged to the loop's memory and typed into its session when
it next goes idle, instead of mid-turn
PROMOTE OPTIONS (node promote; each target asks for its one decision)
--type goal with --goal <text> (required); --predicate, --metric,
--direction as above. A loop may not set its own
--predicate through promotion, the same rule update holds.
--type turn with --pause <every-turn|before-writes> (default: every-turn)
--type time with --prompt <text> (required); put the cadence in it
A main loop can take any shape and never goes back to main. A goal loop can
become time-based (--type time) and a time-based loop a goal loop (--type goal);
turn and composite loops keep theirs. A loop may not drop its own goal.
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;
it still may not touch its goal, predicate, or budget.
EDGE OPTIONS
--kind <k> handoff | message | spawn (default: handoff)
--condition <c> always | onSuccess | onFailure (default: always)
--into <path> spawn into a different project (--kind spawn only); this is
how the global graph dispatches work into a project
MAILROOM
The shared, unaddressed board: `node send` reaches one peer you already know;
a notice is addressed to nobody, left for whoever comes next and found by loops
that did not exist when it was written. Run from inside a loop, posts are
attributed to that loop (`ZMX_SESSION`, the same mechanism as `node send`); from
a human's shell they read as from "a human". `inbox` and `watch` need that loop
identity — the read cursor and the subscription belong to a loop — so a human
reads the room with `list`. A notice is a note to a peer, not a transcript: 1 KB
bound, and `--topic <t>` groups a thread (a watcher of a topic only hears
matching posts; watched posts are delivered like a --follow-up message). The
letters copied from `direct` and `handoff` traffic never ring watchers — they
are the room's copy of something that already had its own delivery, so a watch
on those topics alone stays silent.
EXIT CODES
0 done
1 bad usage, or graphcoded refused the command
69 graphcoded unreachable — nothing was sent, so retrying is safe
75 sent but never acknowledged — it may have been applied. Check `graphcode
status` rather than re-running: create, send and memo are not idempotent.
Everything talks to graphcoded, not to the app, so these work whether or not a
window is open.
COMMON WORKFLOWS
graphcode projects
graphcode status <project-path>
graphcode node send <project-path> <node-id> --follow-up <message…>
stage work without interrupting an active turn
graphcode mail inbox <project-path>
check what other loops left for you before starting a pass
graphcode mail post <project-path> --topic claims issue #12 is mine
stake a claim where every loop will find it, addressed to no one
graphcode node pilot <project-path> <composite-id>
graphcode node arm <project-path> <composite-id>
pilot before arming a proactive routine
graphcode reap --dry-run
inspect suspected PTY orphans before any kill
"""
public static func parse(_ arguments: [String]) throws -> GraphcodeCommand {
do {
return try parseVerb(arguments)
} catch is HelpRequested {
return .help
}
}
/// Thrown from wherever `--help` turns up in place of the argument that was expected.
/// `graphcode node create --help` used to fail with "missing project-path", because the
/// only help check was on the first argument and everything after a verb was read
/// positionally — so the one moment a caller admits they don't know the arguments was
/// the one moment they were required to supply them.
private struct HelpRequested: Error {}
private static func isHelpFlag(_ argument: String) -> Bool {
argument == "--help" || argument == "-h"
}
// swiftlint:disable:next cyclomatic_complexity
private static func parseVerb(_ arguments: [String]) throws -> GraphcodeCommand {
var arguments = arguments
guard !arguments.isEmpty else { return .help }
switch arguments.removeFirst() {
case "help", "-h", "--help":
return .help
case "projects":
try validateFlags(arguments, allowed: [])
return .listProjects
case "status":
let path = try take(&arguments, name: "project-path")
try validateFlags(arguments, allowed: [])
return .status(projectPath: path)
case "usage":
let path = try take(&arguments, name: "project-path")
try validateFlags(arguments, allowed: [])
return .usage(projectPath: path)
case "sessions":
let verb = try take(&arguments, name: "sessions subcommand")
guard verb == "restart" else { throw ParseError.unknownCommand("sessions \(verb)") }
let path = try take(&arguments, name: "project-path")
try validateFlags(arguments, allowed: [])
return .restartSessions(projectPath: path)
case "reap":
if arguments.contains(where: isHelpFlag) { throw HelpRequested() }
let flags = try parseReapFlags(arguments)
return .reap(dryRun: flags["dry-run"] != nil)
case "graph":
let verb = try take(&arguments, name: "graph subcommand")
guard verb == "export" else { throw ParseError.unknownCommand("graph \(verb)") }
let path = try take(&arguments, name: "project-path")
try validateFlags(arguments, allowed: ["help", "output"])
let flags = parseFlags(arguments)
if flags["help"] != nil { throw HelpRequested() }
let output = flags["output"] ?? "\(path.split(separator: "/").last ?? "graph").zip"
return .exportGraph(projectPath: path, output: output)
case "node":
let verb = try take(&arguments, name: "node subcommand")
let path = try take(&arguments, name: "project-path")
switch verb {
case "export":
return try parseNodeExport(&arguments, projectPath: path)
case "import":
return try parseNodeImport(&arguments, projectPath: path)
case "create":
var into: UUID?
if let raw = parseFlags(arguments)["into"] {
guard let id = UUID(uuidString: raw) else {
throw ParseError.invalidValue(argument: "--into", value: raw)
}
into = id
}
return .createNode(projectPath: path, draft: try parseDraft(arguments), into: into)
case "stop", "restart", "delete", "pilot", "arm", "send", "update", "memo", "promote",
"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)
}
switch verb {
case "pilot":
try validateFlags(arguments, allowed: [])
return .pilotComposite(projectPath: path, nodeID: nodeID)
case "arm":
try validateFlags(arguments, allowed: [])
return .armComposite(projectPath: path, nodeID: nodeID)
case "delete":
try validateFlags(arguments, allowed: [])
return .deleteNode(projectPath: path, nodeID: nodeID)
case "restart":
try validateFlags(arguments, allowed: [])
return .restartNode(projectPath: path, nodeID: nodeID)
case "promote":
return .promoteNode(
projectPath: path, nodeID: nodeID, promotion: try parsePromotion(arguments))
case "send":
// `--follow-up` is recognised only as the first word after the id, so it can
// still be *sent* by putting it anywhere later in the message.
var followUp = false
if arguments.first == "--follow-up" {
followUp = true
arguments.removeFirst()
}
// Everything after the id is the message — joined rather than flagged, so
// `graphcode node send <path> <id> tests are green, ship it` needs no quoting
// gymnastics from the agent typing it.
let text = arguments.joined(separator: " ").trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { throw ParseError.missingArgument("message") }
return .sendMessage(projectPath: path, nodeID: nodeID, text: text, followUp: followUp)
case "update":
return .updateNode(projectPath: path, nodeID: nodeID, update: try parseUpdate(arguments))
case "memo":
// Joined like `send`, and for the same reason: a note should cost no quoting.
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:
try validateFlags(arguments, allowed: [])
return .stopNode(projectPath: path, nodeID: nodeID)
}
default:
throw ParseError.unknownCommand("node \(verb)")
}
// `artifactory` is what this shipped as, and live loops carry the old verb in
// briefings and memory logs written before the rename. Accepted, undocumented.
case "mail", "mailroom", "artifactory":
return try parseMailroom(&arguments)
case "edge":
let verb = try take(&arguments, name: "edge subcommand")
guard verb == "create" else { throw ParseError.unknownCommand("edge \(verb)") }
let path = try take(&arguments, name: "project-path")
let from = try takeUUID(&arguments, name: "from-id")
let to = try takeUUID(&arguments, name: "to-id")
try validateFlags(arguments, allowed: ["help", "kind", "condition", "into"])
let flags = parseFlags(arguments)
if flags["help"] != nil { throw HelpRequested() }
var spec = EdgeSpec()
if let raw = flags["kind"] {
guard let kind = EdgeKind(rawValue: raw) else {
throw ParseError.invalidValue(argument: "--kind", value: raw)
}
spec.kind = kind
}
if let raw = flags["condition"] {
guard let condition = EdgeCondition(rawValue: raw) else {
throw ParseError.invalidValue(argument: "--condition", value: raw)
}
spec.condition = condition
}
// Only meaningful on a `.spawn` — the one edge kind allowed to cross graphs.
// Accepted regardless of kind and simply ignored elsewhere would be worse than
// refusing: a human who typed it meant something by it.
if let target = flags["into"] {
guard spec.kind == .spawn else {
throw ParseError.invalidValue(argument: "--into", value: target)
}
spec.spawnTargetProjectPath = target
}
return .createEdge(projectPath: path, from: from, to: to, spec: spec)
case let other:
throw ParseError.unknownCommand(other)
}
}
private static func parseDraft(_ arguments: [String]) throws -> NodeDraft {
try validateFlags(
arguments,
allowed: [
"help", "title", "type", "into", "check", "goal", "predicate", "prompt",
"heartbeat", "backend", "model", "metric", "direction", "budget", "skip-unchanged",
])
let flags = parseFlags(arguments)
if flags["help"] != nil { throw HelpRequested() }
guard let rawTitle = flags["title"] else { throw ParseError.missingArgument("--title") }
// A loop fanning work out types the name it would say out loud — "Board Visibility" —
// and the instruction to write it as one word is only ever an instruction. Folded
// here, at the boundary, so a CLI-created loop is named the way every other one is
// (see `LoopName`).
let title = LoopName.folded(rawTitle) ?? rawTitle
guard let rawType = flags["type"] else { throw ParseError.missingArgument("--type") }
let loopType: LoopType
switch rawType {
// `sketch` too, because that is the word every graph on disk still serialises and
// what any script written before the rename still passes.
case "main", "sketch": loopType = .sketch
case "turn", "turnBased": loopType = .turnBased
case "goal", "goalBased": loopType = .goalBased
case "time", "timeBased": loopType = .timeBased
// `proactive` too, because that is what a composite still serialises as and what a
// human reading an existing graph off disk will have in front of them.
case "composite", "proactive": loopType = .composite
default: throw ParseError.invalidValue(argument: "--type", value: rawType)
}
// No flag means no choice — the draft travels with `backend: nil` and the daemon
// resolves it: the creating loop's own backend when this command came from inside a
// session, Claude Code otherwise. Hardcoding the default here was how a Copilot
// loop's children came out as Claude Code loops.
var backend: CLISessionBackendKind?
if let raw = flags["backend"] {
guard let parsed = CLISessionBackendKind(rawValue: raw) else {
throw ParseError.invalidValue(argument: "--backend", value: raw)
}
backend = parsed
}
var modelTier: ModelTier?
if let raw = flags["model"] {
guard let parsed = ModelTier(rawValue: raw) else {
throw ParseError.invalidValue(argument: "--model", value: raw)
}
modelTier = parsed
}
var metricDirection = MetricDirection.maximize
if let raw = flags["direction"] {
guard let parsed = MetricDirection(rawValue: raw) else {
throw ParseError.invalidValue(argument: "--direction", value: raw)
}
metricDirection = parsed
}
var tokenBudget: Int?
if let raw = flags["budget"] {
guard let value = Int(raw), value > 0 else {
throw ParseError.invalidValue(argument: "--budget", value: raw)
}
tokenBudget = value
}
let skipsUnchanged = try parseSkipUnchanged(flags) ?? false
var heartbeat: Double?
if let raw = flags["heartbeat"] {
guard let value = Double(raw), value.isFinite, value > 0 else {
throw ParseError.invalidValue(argument: "--heartbeat", value: raw)
}
heartbeat = value
}
let draft = NodeDraft(
title: title,
loopType: loopType,
checkDescription: flags["check"],
triggerPrompt: flags["prompt"],
heartbeatIntervalSeconds: loopType == .timeBased ? heartbeat : nil,
// A turn-based loop needs something to do. `--prompt` is what a caller already
// types for a timed loop's opening instruction, so it means the same thing here
// rather than making them learn a second flag for the same idea.
// A sketch's `--prompt` is its optional starting note, the same reuse.
firstInstruction: loopType == .turnBased || loopType == .sketch ? flags["prompt"] : nil,
goal: flags["goal"].map {
GoalSpec(
summary: $0, predicate: flags["predicate"],
metricCommand: flags["metric"], metricDirection: metricDirection,
tokenBudget: tokenBudget, skipsUnchangedWorkspace: skipsUnchanged)
},
backend: backend,
modelTier: modelTier)
// The same validation the daemon applies, run early so the CLI can say what's
// missing instead of exiting 0 on a command that quietly did nothing.
guard draft.effectiveBackend != .nod || NodRuntimeLocator.isRampedOn else {
throw ParseError.nodNotEnabled
}
guard draft.isValid else { throw ParseError.invalidDraft }
return draft
}
/// The partial edit `node update` sends — only the flags present travel, so the
/// daemon can tell "leave alone" (absent) from "clear" (empty string / 0).
private static func parseUpdate(_ arguments: [String]) throws -> NodeUpdate {
try validateFlags(
arguments,
allowed: [
"help", "goal", "predicate", "poll", "stall", "metric", "direction", "budget",
"heartbeat", "check", "model", "skip-unchanged", "prompt",
])
let flags = parseFlags(arguments)
if flags["help"] != nil { throw HelpRequested() }
var pollIntervalSeconds: Double?
if let raw = flags["poll"] {
guard let value = Double(raw) else {
throw ParseError.invalidValue(argument: "--poll", value: raw)
}
pollIntervalSeconds = value
}
var stallAfterSeconds: Double?
if let raw = flags["stall"] {
guard let value = Double(raw) else {
throw ParseError.invalidValue(argument: "--stall", value: raw)
}
stallAfterSeconds = value
}
var metricDirection: MetricDirection?
if let raw = flags["direction"] {
guard let parsed = MetricDirection(rawValue: raw) else {
throw ParseError.invalidValue(argument: "--direction", value: raw)
}
metricDirection = parsed
}
var modelTier: ModelTier?
if let raw = flags["model"] {
guard let parsed = ModelTier(rawValue: raw) else {
throw ParseError.invalidValue(argument: "--model", value: raw)
}
modelTier = parsed
}
var tokenBudget: Int?
if let raw = flags["budget"] {
guard let value = Int(raw) else {
throw ParseError.invalidValue(argument: "--budget", value: raw)
}
tokenBudget = value
}
var heartbeat: Double?
if let raw = flags["heartbeat"] {
guard let value = Double(raw), value.isFinite else {
throw ParseError.invalidValue(argument: "--heartbeat", value: raw)
}
heartbeat = value
}
let update = NodeUpdate(
goalSummary: flags["goal"],
goalPredicate: flags["predicate"],
pollIntervalSeconds: pollIntervalSeconds,
stallAfterSeconds: stallAfterSeconds,
metricCommand: flags["metric"],
metricDirection: metricDirection,
tokenBudget: tokenBudget,
skipsUnchangedWorkspace: try parseSkipUnchanged(flags),
triggerPrompt: flags["prompt"],
heartbeatIntervalSeconds: heartbeat,
checkDescription: flags["check"],
modelTier: modelTier)
guard !update.isEmpty else { throw ParseError.missingArgument("an option to change") }
return update
}
/// `node refine`'s three spellings: `--rollback` restores the previous playbook,
/// `--file <path>` sends a file's contents (a playbook is a multi-line document, and
/// joined argv words arrive as one line), and trailing words send exactly what was
/// typed. The file is read *here*, on the caller's machine, because the daemon may be
/// serving a remote project whose filesystem has no such path.
private static func parseRefine(
_ arguments: [String], projectPath: String, nodeID: UUID
) throws -> GraphcodeCommand {
if arguments.first == "--rollback" {
return .rollbackRefinement(projectPath: projectPath, nodeID: nodeID)
}
if arguments.first == "--file" {
guard arguments.count >= 2 else { throw ParseError.missingArgument("file path") }
guard let text = try? String(contentsOfFile: arguments[1], encoding: .utf8),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
throw ParseError.invalidValue(argument: "--file", value: arguments[1])
}
return .refineNode(projectPath: projectPath, nodeID: nodeID, text: text)
}
let text = arguments.joined(separator: " ").trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { throw ParseError.missingArgument("playbook text") }
return .refineNode(projectPath: projectPath, nodeID: nodeID, text: text)
}
/// `--skip-unchanged` — bare or `true` opts in, `false` opts back out, absent means
/// "leave it alone" (create's caller defaults that to off).
private static func parseSkipUnchanged(_ flags: [String: String]) throws -> Bool? {
guard let raw = flags["skip-unchanged"] else { return nil }
switch raw {
case "", "true": return true
case "false": return false
default: throw ParseError.invalidValue(argument: "--skip-unchanged", value: raw)
}
}
/// The one decision each target type needs — the same vocabulary `node create`
/// already taught: `--goal`/`--predicate`/`--metric`/`--direction` for goal,
/// `--prompt` for time. Turn's decision is where to pause, which create never asks
/// (`--pause every-turn|before-writes`), defaulting to every turn like the app's form.
private static func parsePromotion(_ arguments: [String]) throws -> SketchPromotion {
try validateFlags(
arguments,
allowed: [
"help", "type", "goal", "predicate", "metric", "direction", "pause", "prompt",
])
let flags = parseFlags(arguments)
if flags["help"] != nil { throw HelpRequested() }
guard let rawType = flags["type"] else { throw ParseError.missingArgument("--type") }
switch rawType {
case "goal", "goalBased":
guard let summary = flags["goal"] else { throw ParseError.missingArgument("--goal") }
var metricDirection = MetricDirection.maximize
if let raw = flags["direction"] {
guard let parsed = MetricDirection(rawValue: raw) else {
throw ParseError.invalidValue(argument: "--direction", value: raw)
}
metricDirection = parsed
}
return .goal(
GoalSpec(
summary: summary, predicate: flags["predicate"],
metricCommand: flags["metric"], metricDirection: metricDirection))
case "turn", "turnBased":
switch flags["pause"] {
case nil, "every-turn":
return .turn(pausesBeforeWritesOnly: false)
case "before-writes":
return .turn(pausesBeforeWritesOnly: true)
case .some(let raw):
throw ParseError.invalidValue(argument: "--pause", value: raw)
}
case "time", "timeBased":
guard let prompt = flags["prompt"] else { throw ParseError.missingArgument("--prompt") }
return .timed(triggerPrompt: prompt)
// `main` and `composite` are refused by shape, not by the daemon: demotion is
// unrepresentable and a composite is not one decision (see `SketchPromotion`).
default:
throw ParseError.invalidValue(argument: "--type", value: rawType)
}
}
/// `--name value` pairs. Bare positional arguments are ignored here; every caller has
/// already consumed the positional ones it needs.
private static func parseFlags(_ arguments: [String]) -> [String: String] {
var flags: [String: String] = [:]
var index = 0
while index < arguments.count {
let argument = arguments[index]
guard argument.hasPrefix("--") else {
index += 1
continue
}
let name = String(argument.dropFirst(2))
if index + 1 < arguments.count, !arguments[index + 1].hasPrefix("--") {
flags[name] = arguments[index + 1]
index += 2
} else {
flags[name] = ""
index += 1
}
}
return flags
}
private static func parseReapFlags(_ arguments: [String]) throws -> [String: String] {
for argument in arguments where argument.hasPrefix("--") {
guard argument == "--dry-run" else { throw ParseError.unknownOption(argument) }
}
return parseFlags(arguments)
}
private static func validateFlags(_ arguments: [String], allowed: Set<String>) throws {
for argument in arguments where argument.hasPrefix("--") {
let name = String(argument.dropFirst(2))
guard allowed.contains(name) else { throw ParseError.unknownOption(argument) }
}
}
/// Positional arguments only — never the trailing words of `node send`/`node memo`,
/// which are joined rather than taken. That is what keeps `--help` meaning help here
/// while staying literal text in a message somebody wants to transmit.
private static func take(_ arguments: inout [String], name: String) throws -> String {
if let first = arguments.first, isHelpFlag(first) { throw HelpRequested() }
guard !arguments.isEmpty, !arguments[0].hasPrefix("--") else {
throw ParseError.missingArgument(name)
}
return arguments.removeFirst()
}
private static func takeUUID(_ arguments: inout [String], name: String) throws -> UUID {
let raw = try take(&arguments, name: name)
guard let value = UUID(uuidString: raw) else {
throw ParseError.invalidValue(argument: name, value: raw)
}
return value
}
}
// MARK: - Output
extension GraphcodeCommand {
/// A graph rendered for a terminal. Node ids are shown in full because they're what
/// every other subcommand takes as input — a truncated id would look tidier and be
/// useless.
public static func render(
_ graph: LoopGraph, mailroomReader readerID: UUID? = nil
) -> String {
var lines = ["\(graph.project.name) (\(graph.aggregateState))"]
if graph.nodes.isEmpty {
lines.append(" no loops yet")
// The board can outlive every loop on it — human posts carry no authorID, so
// "last loop deleted" does not mean "board empty". The line belongs on this
// path too, not only on the rendered-below one.
if let boardLine = renderMailroomStatusLine(graph, readerID: readerID) {
lines.append(" \(boardLine)")
}
return lines.joined(separator: "\n")
}
for node in graph.nodes {
var line = " \(node.id) \(node.displayState) \(node.loopType) \(node.title)"
if let exitCode = node.presence?.exitCode {
line += " ← session exited (\(exitCode))"
} else if let reason = AttentionRollup.reason(for: node) {
// Stalled is two different endings — a blown budget and a blown deadline — and
// only memory told them apart. The graph records which one it was; print it.
if let why = node.stallReason, !why.isEmpty {
line += " ← \(reason.displayName): \(why)"
} else {
line += " ← \(reason.displayName)"
}
}
lines.append(line)
}
if !graph.edges.isEmpty {
lines.append(" edges:")
for edge in graph.edges {
let arrow = edge.fired ? "──▶" : "╌╌▶"
let fromTitle = graph.nodes[id: edge.from]?.title ?? "?"
let toTitle = graph.nodes[id: edge.to]?.title ?? "?"
lines.append(
" \(fromTitle) \(arrow) \(toTitle) [\(edge.kind.rawValue)/\(edge.condition.rawValue)]"
)
}
}
// The board rides last: one line, only when there is anything on it, so a
// project that never touched the Mailroom renders as it always did.
if let boardLine = renderMailroomStatusLine(graph, readerID: readerID) {
lines.append(" \(boardLine)")
}
return lines.joined(separator: "\n")
}
/// The usage rollup, always stating its coverage. A bare total would read as the whole
/// bill when it may be a fraction of one — graphcode only knows what a backend's hooks
/// report, and a cost figure someone might act on has to say what it left out.
public static func renderUsage(_ graph: LoopGraph) -> String {
let coverage = graph.usageCoverage
guard let usage = graph.usage else {
return """
\(graph.project.name): no usage reported (0/\(coverage.total) loops)
Usage is reported by the backend, never estimated. Claude Code loops report it
automatically at each turn end (graphcode's own Stop hook); a loop that has
not finished a turn since that hook was installed has nothing to report yet.
Other backends need a hook running
`zmx set "$ZMX_SESSION" usage=input.<tokens>_output.<tokens>`
"""
}
var lines = ["\(graph.project.name): \(coverage.reporting)/\(coverage.total) loops reporting"]
if let cost = usage.costUSD { lines.append(String(format: " cost $%.4f", cost)) }
if let input = usage.inputTokens { lines.append(" input \(input) tokens") }
if let output = usage.outputTokens { lines.append(" output \(output) tokens") }
for node in graph.nodes {
guard let nodeUsage = node.usage else { continue }
let cost = nodeUsage.costUSD.map { String(format: " $%.4f", $0) } ?? ""
lines.append(" \(node.title) \(nodeUsage.totalTokens ?? 0) tokens\(cost)")
}
return lines.joined(separator: "\n")
}
public static func render(_ projects: [ProjectRef]) -> String {
guard !projects.isEmpty else { return "no projects yet" }
return projects.map { "\($0.name) \($0.path)" }.joined(separator: "\n")
}
/// The room for a terminal, from the mailbox the daemon answered
/// (`DaemonCommand.mailbox`). `mail list` asks for the whole room (`unread` false);
/// `mail inbox` asks for what the reading loop's cursor has not covered. The
/// subtraction, the search and the triage all happened in the daemon, so what
/// arrives is exactly what prints — this only decides the words around it. A
/// mailbox whose bodies the room cut says so (`bodiesTrimmed`), and unless the
/// caller asked for headlines itself, the header tells the reader it is reading a
/// triaged room and where the full text is.
public static func renderMailroom(
_ mailbox: Mailbox, project: ProjectRef, unread: Bool, headlines: Bool = false,
search: String? = nil
) -> String {
let posts = mailbox.posts
guard !posts.isEmpty else {
if let search, !search.isEmpty {
return unread ? "no unread posts match '\(search)'" : "no posts match '\(search)'"
}
guard unread else {
return "the room is empty — post one: graphcode mail post <project-path> <notice…>"
}
return mailbox.prunedUnread > 0
? "no unread posts — but \(mailbox.prunedUnread) landed since your last inbox and "
+ "were pruned before you read them; the room keeps \(Mailroom.maxNotices) notices "
+ "and \(Mailroom.maxLetters) letters, read it more often"
: "no unread posts"
}
let triaged = mailbox.bodiesTrimmed && !headlines
let label = unread ? "mailroom, unread" : "mailroom"
var header = "\(project.name) \(label): \(posts.count) post\(posts.count == 1 ? "" : "s")"
if triaged {
header +=
" — headlines only, that is a lot to read at once. "
+ "Full text: graphcode mail read \(project.path) <post-id>"
}
var lines = [header]
for post in posts {
lines.append(
headlines || mailbox.bodiesTrimmed ? " \(renderHeadline(post))" : " \(render(post))")
}
if unread, mailbox.prunedUnread > 0 {
// Said before the posts, in words, or the loop believes it is caught up: mail
// that landed after its cursor and was pruned before it asked is gone for good.
lines.append(
" \(mailbox.prunedUnread) post\(mailbox.prunedUnread == 1 ? "" : "s") landed since your "
+ "last inbox and \(mailbox.prunedUnread == 1 ? "was" : "were") pruned before you read "
+ "\(mailbox.prunedUnread == 1 ? "it" : "them") — the room keeps "
+ "\(Mailroom.maxNotices) notices and \(Mailroom.maxLetters) letters; read it more often")
}
if mailbox.remaining > 0 {
// A page, not the whole backlog: the cursor stopped at the last post above, so
// the same command again is the next page — said in words, or a loop would
// take one page for the lot.
lines.append(
" \(mailbox.remaining) more unread past #\(mailbox.highestDeliveredID ?? 0) — "
+ "run the same command again for the next page")
}
return lines.joined(separator: "\n")
}
/// The mailbox as one machine-readable object — the same posts `renderMailroom`
/// would print, plus the reader's cursor so a client can compute unread itself.
/// Dates are ISO-8601, pinned by test — the encoder's default (seconds since 2001)
/// is a wire format only this process should ever have to know about.
public static func renderMailroomJSON(_ mailbox: Mailbox) -> String {
struct Board: Encodable {
var posts: [MailroomPost]
var lastRead: Int?
/// Present only when the answer is a page, so the shape scripts already parse
/// is untouched until there is something to say.
var remaining: Int?
var prunedUnread: Int?
}
let board = Board(
posts: mailbox.posts, lastRead: mailbox.lastRead,
remaining: mailbox.remaining > 0 ? mailbox.remaining : nil,
prunedUnread: mailbox.prunedUnread > 0 ? mailbox.prunedUnread : nil)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(board) else { return "{}" }
return String(decoding: data, as: UTF8.self)
}
/// `status`'s one-line window onto the room: how many posts exist and — when the
/// caller is a loop with a cursor here — whether any are unread for it. `nil` when
/// the room is empty, so a project that never touched the Mailroom renders exactly
/// as it did before this line existed. The point is cost: the briefing already sends
/// loops to `status` before claiming or creating work, and this makes the "is there
/// mail I should know about" check ride along for free — off the snapshot's digest,
/// without the posts themselves ever crossing the socket.
public static func renderMailroomStatusLine(
_ graph: LoopGraph, readerID: UUID? = nil
) -> String? {
let digest = graph.boardDigest
guard !digest.isEmpty else { return nil }
let plural = digest.count == 1 ? "" : "s"
// "For you" needs a *you* this room knows: the daemon refuses the cursor advance
// for a reader absent from the graph, so the status line claims nothing for one
// either — a foreign or stale id gets the plain count, same as a human.
guard let readerID, let reader = graph.nodes[id: readerID] else {
return "mailroom: \(digest.count) post\(plural)"
}
let unread = digest.latestID > (reader.lastMailroomRead ?? 0)
return "mailroom: \(digest.count) post\(plural), "
+ (unread ? "unread mail for you" : "nothing unread for you")
}
/// One post, one line — the same identification the daemon's wake nudge quotes, so
/// a loop reads a note the same way everywhere it meets one.
public static func render(_ post: MailroomPost) -> String {
let topic = post.topic.map { " (\($0))" } ?? ""
// `Date.formatted` has no precedent in GraphcodeKit and corelibs-foundation's
// FormatStyle support has been uneven across the toolchains the Linux CI runs;
// a fixed DateFormatter is the boring, portable answer.
let stamp = MailroomPost.stampFormat.string(from: post.at)
return "#\(post.id)\(topic) from \(post.author) at \(stamp) — \(post.body)"
}
/// The triage line — everything `render` says about a post's identity, with the
/// body cut to a glance. The pair (`sync --headlines`, `mail read <id>`) is
/// how a loop joining after forty messages spends forty lines instead of forty
/// kilobytes, and deep-reads only the posts that turned out to matter.
public static func renderHeadline(_ post: MailroomPost) -> String {
// Bodies are single-line at the daemon (memos flatten), but this renders a
// *rendered line*, and the one-triage-line promise survives anything.
let full = render(post).replacingOccurrences(of: "\n", with: " ")
let budget = 80
guard full.count > budget else { return full }
return String(full.prefix(budget)) + "…"
}