uml-viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 43940d01f25e1e7ac319450763c3c3498654a77ac60de6a5ca1bf5dcc8ded444
4
+ data.tar.gz: 00cd3efb4a8ede7ffc031b56b8d4f3f16caf4fed27746e4723682d786343b411
5
+ SHA512:
6
+ metadata.gz: 03a181720c4fd021b9f7bc13dc5b3a414c9ad2b146d2197f51c0e6a5e6e198d3a46a8accfdffbc3b660cc5d6d9fbec86aabef1914fad90e73642b79d4c6dcaa3
7
+ data.tar.gz: 69bee8c7b56e6f0c6ec3a1b4f532ea67a43649d2f76f2483270af913afd366b0786e45b9823f087738fa2e4ad723f32d8648e214fc96fd7a4d730fc175b0961c
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-10-23
8
+
9
+ ### Added
10
+ - `uml-viewer <project>` scans a Ruby or Rails project with Prism and opens a live UML diagram in the browser. You drill down from folder and namespace boxes to classes and to source lines.
11
+ - Levels come from `policy.yml`. A dependency from an inner level to an outer level is a red arrow. Associations are never red.
12
+ - Box colors show CRAP from `coverage/.resultset.json` (SimpleCov) and M grades from an optional `mutation.json`. Grey means no data.
13
+ - Proposals regroup the code into ranked components without a change to the code.
14
+ - Declutter cycles through five modes on the `d` key.
15
+ - A right-click sends Explain or Omit to a Claude Code session through a mailbox. `uml-viewer install-skill` installs the skill, and `uml-viewer watch` wakes the session through the Monitor tool.
16
+ - State lives in `~/.uml-viewer/projects/`. Nothing is written into the viewed project.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Hopman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # uml-viewer
2
+
3
+ A live UML diagram of a Ruby or Rails project in the browser. Red arrows break the Dependency Rule. A Claude Code session handles your clicks.
4
+
5
+ > Based on the ideas of [unclebob/uml-viewer](https://github.com/unclebob/uml-viewer) by Robert C. Martin (Uncle Bob), a Clojure tool. This gem reuses its ideas and its observed behavior for Ruby and Rails. No code is copied.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ gem install uml-viewer
11
+ uml-viewer install-skill
12
+ uml-viewer path/to/project
13
+ ```
14
+
15
+ `install-skill` installs the Claude Code skill that handles the clicks from the diagram. Nothing is written into the viewed project. State lives in `~/.uml-viewer/projects/`.
16
+
17
+ ## Navigate
18
+
19
+ - Double-click a box to open it. Press `Esc` to go back.
20
+ - Double-click a class to open its card. Click an operation to see its source.
21
+ - Hover an arrow to see the class pairs behind it.
22
+ - Right-click a box or a class to send **Explain** or **Omit** to Claude.
23
+
24
+ ## Test quality
25
+
26
+ Box colors show test quality: green is a good grade, red is a poor grade, and grey dots mean no data. The class card says why.
27
+
28
+ - **Coverage and CRAP:** run your tests with [SimpleCov](https://github.com/simplecov-ruby/simplecov). The viewer reads `coverage/.resultset.json` in the project.
29
+ - **Mutation grades:** put a mutation snapshot as `mutation.json` in the state folder. `uml-viewer path` prints that folder.
30
+
31
+ ## Proposals
32
+
33
+ Proposals regroup the code without a change to the code.
34
+
35
+ 1. Press **New proposal**, or ask Claude for one. Claude writes it into `policy.yml`.
36
+ 2. A proposal lists components in rank order. The first component is level 0.
37
+ 3. A member is a class id, a namespace, or a folder box such as `folder:models`.
38
+ 4. Choose the proposal in the diagram list. The diagram shows the code in its components, with red arrows by the ranks of the proposal.
39
+
40
+ Classes in no component wait in **Unassigned**.
41
+
42
+ ## Declutter
43
+
44
+ Press `d` or the declutter button to cycle through five modes: one arrow per pair, triangles, hide members, hide classes, none.
45
+
46
+ ## Check the skill
47
+
48
+ ```sh
49
+ bundle exec rake eval:skill
50
+ ```
51
+
52
+ This runs the skill with `claude -p` on a copy of the fixture project and checks what it changed. It needs a logged-in `claude` and costs a few cents per run. `EVAL_MODEL`, `EVAL_BUDGET` and `EVAL_TIMEOUT` tune it.
53
+
54
+ ## Credits and licence
55
+
56
+ - The ideas come from [unclebob/uml-viewer](https://github.com/unclebob/uml-viewer) by Robert C. Martin. No code is copied.
57
+ - [elkjs](https://github.com/kieler/elkjs) (EPL-2.0) does the layout. It is vendored in `lib/uml/viewer/web/`.
58
+ - uml-viewer is released under the [MIT licence](LICENSE).
data/exe/uml-viewer ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require "uml/viewer"
3
+
4
+ exit Uml::Viewer::CLI.new(ARGV).run
@@ -0,0 +1,133 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module Uml
5
+ module Viewer
6
+ class CLI
7
+ COMMANDS = %w[scan inbox done watch path init install-skill].freeze
8
+ USAGE = "usage: uml-viewer [path] | scan|inbox|watch|path|init [path] | done <id> [path] [--failed reason] | install-skill"
9
+
10
+ def initialize(argv, out: $stdout, err: $stderr)
11
+ @argv = argv.dup
12
+ @out = out
13
+ @err = err
14
+ end
15
+
16
+ def run
17
+ command = COMMANDS.include?(@argv.first) ? @argv.shift : "open"
18
+ send("run_#{command.tr('-', '_')}")
19
+ rescue Interrupt
20
+ 130
21
+ rescue Workspace::MissingProject, PolicyFile::Invalid => error
22
+ @err.puts error.message
23
+ 1
24
+ end
25
+
26
+ private
27
+
28
+ def workspace
29
+ @workspace ||= Workspace.new(@argv.first || ".")
30
+ end
31
+
32
+ def run_path
33
+ @out.puts workspace.dir
34
+ 0
35
+ end
36
+
37
+ def run_init
38
+ policy = workspace.path("policy.yml")
39
+ if File.exist?(policy)
40
+ @out.puts "policy.yml exists: #{policy}"
41
+ else
42
+ Graph.prepare(workspace)
43
+ @out.puts "wrote #{policy}"
44
+ end
45
+ 0
46
+ end
47
+
48
+ def run_scan
49
+ Graph.prepare(workspace)
50
+ graph = Graph.build(workspace)
51
+ summary = graph["summary"]
52
+ violating = graph["edges"].count { |edge| edge["violating"] }
53
+ @out.puts "#{graph['classes'].size} classes, #{graph['edges'].size} edges, #{violating} violating, " \
54
+ "skipped #{summary['skipped'].size} files, no constant"
55
+ @out.puts "#{summary['unresolved']} unresolved associations, #{summary['polymorphic']} polymorphic associations (no target)"
56
+ @out.puts "unlisted foreign: #{summary['foreign'].map { |name, count| "#{name} (#{count})" }.join(', ')}"
57
+ @out.puts Graph.coverage_line(summary["coverage"])
58
+ summary["warnings"] += [summary["coverage"]["banner"]].compact
59
+ summary["warnings"].each { |warning| @out.puts "warning: #{warning}" }
60
+ 0
61
+ end
62
+
63
+ def run_inbox
64
+ command = Mailbox.new(workspace).peek
65
+ @out.puts JSON.generate(command) if command
66
+ 0
67
+ end
68
+
69
+ def run_done
70
+ id, failed = parse_done_arguments
71
+ return usage unless id
72
+
73
+ if Mailbox.new(workspace).done(id, failed: failed)
74
+ 0
75
+ else
76
+ @err.puts "no command with id #{id}"
77
+ 1
78
+ end
79
+ end
80
+
81
+ def parse_done_arguments
82
+ failed = nil
83
+ OptionParser.new { |opts| opts.on("--failed REASON") { |reason| failed = reason } }.parse!(@argv)
84
+ [Integer(@argv.shift, 10), failed]
85
+ rescue OptionParser::ParseError, ArgumentError, TypeError
86
+ nil
87
+ end
88
+
89
+ def usage
90
+ @err.puts USAGE
91
+ 2
92
+ end
93
+
94
+ def run_watch
95
+ Watcher.new(workspace).run
96
+ end
97
+
98
+ def run_install_skill
99
+ installer = SkillInstaller.new(executable: File.expand_path($PROGRAM_NAME))
100
+ @out.puts "wrote #{installer.install}"
101
+ installer.allowed_tools.each { |rule| @out.puts " #{rule}" }
102
+ 0
103
+ end
104
+
105
+ def run_open
106
+ if workspace.server_alive?
107
+ info = workspace.server_info
108
+ url = "http://127.0.0.1:#{info['port']}/?token=#{info['token']}"
109
+ @out.puts "already running at #{url}"
110
+ open_browser(url)
111
+ return 0
112
+ end
113
+ Graph.prepare(workspace)
114
+ Graph.build(workspace)
115
+ server = Server.new(workspace).start
116
+ @out.puts "uml-viewer: #{server.url}"
117
+ open_browser(server.url)
118
+ trap("INT") { Thread.new { server.shutdown } }
119
+ trap("TERM") { Thread.new { server.shutdown } }
120
+ begin
121
+ server.run
122
+ ensure
123
+ server.shutdown
124
+ end
125
+ 0
126
+ end
127
+
128
+ def open_browser(url)
129
+ system("open", url) unless ENV["UML_VIEWER_NO_BROWSER"] == "1"
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,51 @@
1
+ module Uml
2
+ module Viewer
3
+ module Commands
4
+ TARGET_KEYS = {
5
+ "explain" => %w[id kind proposal_id],
6
+ "omit" => %w[id kind proposal_id],
7
+ "context" => %w[proposal_id],
8
+ "propose" => nil
9
+ }.freeze
10
+
11
+ def self.validate(body, ids:, proposal_ids:)
12
+ return refuse("body must be a JSON object") unless body.is_a?(Hash)
13
+
14
+ extra = (body.keys - %w[op target]).first
15
+ return refuse("unknown key: #{extra}") if extra
16
+
17
+ op = body["op"]
18
+ return refuse("unknown op: #{op}") unless TARGET_KEYS.key?(op)
19
+
20
+ keys = TARGET_KEYS[op]
21
+ return refuse("#{op} takes no target") if keys.nil? && body.key?("target")
22
+ return [op, nil, nil] if keys.nil?
23
+
24
+ target = body["target"]
25
+ return refuse("#{op} needs a target") unless target.is_a?(Hash)
26
+
27
+ extra = (target.keys - keys).first
28
+ return refuse("unknown key in target: #{extra}") if extra
29
+ return refuse("target needs #{(keys - target.keys).join(', ')}") unless (keys - target.keys).empty?
30
+
31
+ reason = check_target(target, ids, proposal_ids)
32
+ reason ? refuse(reason) : [op, target, nil]
33
+ end
34
+
35
+ def self.check_target(target, ids, proposal_ids)
36
+ proposal = target["proposal_id"]
37
+ return "unknown proposal: #{proposal}" unless proposal.nil? || proposal_ids.include?(proposal)
38
+ return nil unless target.key?("id")
39
+ return "target kind must be class or box" unless %w[class box].include?(target["kind"])
40
+ known = target["kind"] == "box" && proposal ? ids.fetch("proposal_boxes", {}).fetch(proposal, Set.new) : ids.fetch(target["kind"])
41
+ return "unknown target: #{target['id']}" unless known.include?(target["id"])
42
+
43
+ nil
44
+ end
45
+
46
+ def self.refuse(reason)
47
+ [nil, nil, reason]
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,28 @@
1
+ require "prism"
2
+
3
+ module Uml
4
+ module Viewer
5
+ module Complexity
6
+ BRANCHES = [
7
+ Prism::IfNode, Prism::UnlessNode, Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::WhenNode,
8
+ Prism::InNode, Prism::RescueNode, Prism::RescueModifierNode, Prism::AndNode, Prism::OrNode
9
+ ].freeze
10
+ SHORT_CIRCUIT_WRITES = Prism.constants.grep(/(And|Or)WriteNode\z/).map { |name| Prism.const_get(name) }.freeze
11
+
12
+ def self.of(definition)
13
+ 1 + count(definition.body)
14
+ end
15
+
16
+ def self.count(node)
17
+ return 0 if node.nil?
18
+
19
+ (branch?(node) ? 1 : 0) + node.compact_child_nodes.sum { |child| count(child) }
20
+ end
21
+
22
+ def self.branch?(node)
23
+ BRANCHES.include?(node.class) || SHORT_CIRCUIT_WRITES.include?(node.class) ||
24
+ (node.respond_to?(:safe_navigation?) && node.safe_navigation?)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,67 @@
1
+ require "json"
2
+ require "set"
3
+
4
+ module Uml
5
+ module Viewer
6
+ module Coverage
7
+ class Unreadable < StandardError; end
8
+
9
+ def self.load(root, scanned_files)
10
+ path = File.join(root, "coverage", ".resultset.json")
11
+ return nil unless File.exist?(path)
12
+
13
+ resultset = JSON.parse(File.read(path))
14
+ raise Unreadable, "coverage file unreadable: #{path}" unless suites?(resultset)
15
+
16
+ parse(resultset, scanned_files)
17
+ rescue JSON::ParserError, SystemCallError
18
+ raise Unreadable, "coverage file unreadable: #{path}"
19
+ end
20
+
21
+ def self.suites?(resultset)
22
+ resultset.is_a?(Hash) && !resultset.empty? && resultset.values.all? do |suite|
23
+ suite.is_a?(Hash) && suite["timestamp"].is_a?(Numeric) && suite["coverage"].is_a?(Hash) &&
24
+ suite["coverage"].values.all? { |entry| hits?(entry.is_a?(Hash) ? entry["lines"] : entry) }
25
+ end
26
+ end
27
+
28
+ def self.hits?(hits)
29
+ hits.is_a?(Array) && hits.all? { |hit| hit.nil? || hit.is_a?(Integer) }
30
+ end
31
+
32
+ def self.parse(resultset, scanned_files)
33
+ wanted = scanned_files.to_set
34
+ lines = {}
35
+ resultset.each_value do |suite|
36
+ root = root_of(suite["coverage"].keys, scanned_files)
37
+ suite["coverage"].each do |absolute, value|
38
+ file = root && absolute.start_with?("#{root}/") ? absolute.delete_prefix("#{root}/") : nil
39
+ next unless wanted.include?(file)
40
+
41
+ lines[file] = add(lines[file], value.is_a?(Hash) ? value["lines"] : value)
42
+ end
43
+ end
44
+ { "time" => resultset.values.filter_map { |suite| suite["timestamp"] }.max, "lines" => lines }
45
+ end
46
+
47
+ def self.root_of(paths, scanned_files)
48
+ by_name = scanned_files.group_by { |file| File.basename(file) }
49
+ candidates = paths.filter_map do |absolute|
50
+ file = by_name.fetch(File.basename(absolute), []).select { |name| absolute.end_with?("/#{name}") }.max_by(&:size)
51
+ file && absolute.delete_suffix("/#{file}")
52
+ end
53
+ candidates.tally.max_by { |_, count| count }&.first
54
+ end
55
+
56
+ def self.add(sum, hits)
57
+ return hits.dup if sum.nil?
58
+
59
+ Array.new([sum.size, hits.size].max) do |index|
60
+ left = sum[index]
61
+ right = hits[index]
62
+ left.nil? && right.nil? ? nil : left.to_i + right.to_i
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,113 @@
1
+ require "json"
2
+ require "set"
3
+ require "time"
4
+
5
+ module Uml
6
+ module Viewer
7
+ module Graph
8
+ def self.prepare(workspace)
9
+ return if File.exist?(workspace.path("policy.yml"))
10
+
11
+ scan = Scanner.new(workspace.root).scan
12
+ PolicyInit.write(workspace, class_ids: scan["classes"].map { |entry| entry["id"] })
13
+ end
14
+
15
+ def self.build(workspace)
16
+ policy = PolicyFile.load(workspace.path("policy.yml"))
17
+ scan = Scanner.new(workspace.root, sources: policy["sources"], skip: policy["skip"]).scan
18
+ applied = Policy.apply(scan, policy)
19
+ files = applied["classes"].flat_map { |entry| entry["files"] }.uniq
20
+ coverage, coverage_error = load_coverage(workspace, files)
21
+ mutation, mutation_error = load_mutation(workspace)
22
+ overlay = Overlay.apply(applied["classes"], coverage: coverage, mutation: mutation, cutoffs: policy["crap_cutoffs"],
23
+ mtimes: files.to_h { |file| [file, mtime(File.join(workspace.root, file))] },
24
+ missing_note: coverage_error || Overlay::NO_COVERAGE)
25
+ summary = summary(scan, applied, policy)
26
+ summary["coverage"] = overlay["coverage"].merge("banner" => coverage_error || overlay["coverage"]["banner"])
27
+ summary["warnings"] += [mutation_error].compact
28
+ graph = {
29
+ "version" => 3,
30
+ "title" => policy["title"].empty? ? File.basename(workspace.root) : policy["title"],
31
+ "proposals" => applied["proposals"].map do |proposal|
32
+ proposal.slice("id", "name").merge("views" => Overlay.boxes(proposal["views"], overlay["classes"], policy["crap_cutoffs"]))
33
+ end,
34
+ "summary" => summary,
35
+ "namespaces" => applied["namespaces"],
36
+ "classes" => overlay["classes"],
37
+ "edges" => applied["edges"],
38
+ "views" => Overlay.boxes(applied["views"], overlay["classes"], policy["crap_cutoffs"])
39
+ }
40
+ workspace.write_atomic("graph.json", JSON.generate(graph))
41
+ graph
42
+ end
43
+
44
+ def self.load_coverage(workspace, files)
45
+ [Coverage.load(workspace.root, files), nil]
46
+ rescue Coverage::Unreadable => error
47
+ [nil, error.message]
48
+ end
49
+
50
+ def self.load_mutation(workspace)
51
+ path = workspace.path("mutation.json")
52
+ return [nil, nil] unless File.exist?(path)
53
+
54
+ data = JSON.parse(File.read(path))
55
+ mutation_snapshot?(data) ? [data, nil] : [nil, "mutation snapshot unreadable: #{path}"]
56
+ rescue JSON::ParserError, SystemCallError
57
+ [nil, "mutation snapshot unreadable: #{path}"]
58
+ end
59
+
60
+ def self.mutation_snapshot?(data)
61
+ data.is_a?(Hash) && data.values.all? do |entry|
62
+ entry.is_a?(Hash) && Overlay::COUNTS.all? { |key| !entry.key?(key) || (entry[key].is_a?(Integer) && entry[key] >= 0) }
63
+ end
64
+ end
65
+
66
+ def self.coverage_line(coverage)
67
+ return "coverage: none" if coverage["time"].nil?
68
+
69
+ "coverage: #{coverage['files']} of #{coverage['scanned']} files from #{Time.at(coverage['time']).utc.iso8601}"
70
+ end
71
+
72
+ def self.summary(scan, applied, policy)
73
+ {
74
+ "classes" => applied["classes"].size,
75
+ "skipped" => scan["skipped"],
76
+ "foreign" => scan["foreign"].reject { |name, _| policy["foreign"].include?(name) }.first(20),
77
+ "unresolved" => scan["unresolved"],
78
+ "polymorphic" => scan["polymorphic"],
79
+ "warnings" => applied["warnings"] + scan["unreadable"].map { |file| "unreadable file: #{file}" }
80
+ }
81
+ end
82
+
83
+ def self.ids(graph)
84
+ {
85
+ "class" => Set.new(graph["classes"].map { |entry| entry["id"] }),
86
+ "box" => box_ids(graph["views"]),
87
+ "proposal_boxes" => graph.fetch("proposals", []).to_h { |proposal| [proposal["id"], box_ids(proposal["views"])] }
88
+ }
89
+ end
90
+
91
+ def self.box_ids(views)
92
+ Set.new(views.values.flat_map { |view| view["nodes"] }.select { |node| Views::BOXES.include?(node["type"]) }.map { |node| node["id"] })
93
+ end
94
+
95
+ def self.inputs(workspace)
96
+ sources = begin
97
+ PolicyFile.load(workspace.path("policy.yml"))["sources"]
98
+ rescue PolicyFile::Invalid
99
+ PolicyFile::DEFAULTS["sources"]
100
+ end
101
+ files = sources.flat_map { |source| Dir.glob("#{source}/**/*.rb", base: workspace.root) }
102
+ extra = [File.join(workspace.root, "coverage", ".resultset.json"), workspace.path("mutation.json")]
103
+ ([workspace.path("policy.yml")] + extra + files.map { |file| File.join(workspace.root, file) }).to_h { |path| [path, mtime(path)] }
104
+ end
105
+
106
+ def self.mtime(path)
107
+ File.mtime(path).to_f
108
+ rescue Errno::ENOENT
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,19 @@
1
+ module Uml
2
+ module Viewer
3
+ module Inflector
4
+ SINGULAR = [
5
+ [/people\z/, "person"], [/children\z/, "child"], [/ies\z/, "y"], [/sses\z/, "ss"], [/xes\z/, "x"],
6
+ [/ches\z/, "ch"], [/shes\z/, "sh"], [/ses\z/, "s"], [/s\z/, ""]
7
+ ].freeze
8
+
9
+ def self.singularize(word)
10
+ pattern, replacement = SINGULAR.find { |rule, _| word.match?(rule) }
11
+ pattern ? word.sub(pattern, replacement) : word
12
+ end
13
+
14
+ def self.camelize(word)
15
+ word.split("_").map(&:capitalize).join
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,79 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Uml
5
+ module Viewer
6
+ class Mailbox
7
+ class QueueFull < StandardError; end
8
+
9
+ CAP = 100
10
+
11
+ def initialize(workspace)
12
+ @workspace = workspace
13
+ end
14
+
15
+ def append(op:, target: nil)
16
+ locked do |state|
17
+ raise QueueFull, "queue full: start Claude or clear the queue" if state["queue"].size >= self.class::CAP
18
+
19
+ id = state["next_id"]
20
+ command = { "id" => id, "op" => op }
21
+ command["target"] = target unless target.nil?
22
+ command["at"] = Time.now.utc.iso8601
23
+ state["queue"] << command
24
+ state["next_id"] = id + 1
25
+ id
26
+ end
27
+ end
28
+
29
+ def peek
30
+ queue.first
31
+ end
32
+
33
+ def queue
34
+ read_state["queue"]
35
+ end
36
+
37
+ def size
38
+ queue.size
39
+ end
40
+
41
+ def done(id, failed: nil)
42
+ locked do |state|
43
+ command = state["queue"].find { |entry| entry["id"] == id }
44
+ if command
45
+ state["queue"].delete(command)
46
+ log_failure(command, failed) if failed
47
+ end
48
+ command
49
+ end
50
+ end
51
+
52
+ def last_failed
53
+ @workspace.read_json("failed.json", []).last
54
+ end
55
+
56
+ private
57
+
58
+ def read_state
59
+ @workspace.read_json("to-agent.json", { "next_id" => 1, "queue" => [] })
60
+ end
61
+
62
+ def locked
63
+ File.open(@workspace.path("to-agent.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
64
+ lock.flock(File::LOCK_EX)
65
+ state = read_state
66
+ result = yield(state)
67
+ @workspace.write_atomic("to-agent.json", JSON.generate(state))
68
+ result
69
+ end
70
+ end
71
+
72
+ def log_failure(command, reason)
73
+ failures = @workspace.read_json("failed.json", [])
74
+ failures << { "id" => command["id"], "op" => command["op"], "reason" => reason, "at" => Time.now.utc.iso8601 }
75
+ @workspace.write_atomic("failed.json", JSON.generate(failures))
76
+ end
77
+ end
78
+ end
79
+ end