agentilda 1.0.3 → 2.0.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/agentilda.gemspec +8 -6
  3. data/agents/hansolo-reviewer.md +42 -13
  4. data/agents/lando-broker.md +40 -46
  5. data/agents/leah-researcher.md +36 -58
  6. data/agents/luke-backend.md +49 -61
  7. data/agents/palpatine-planner.md +39 -21
  8. data/agents/rey-frontend.md +44 -86
  9. data/agents/yoda-writer.md +38 -36
  10. data/bin/setup +26 -0
  11. data/exe/agentilda +13 -92
  12. data/exe/tilda-rs +0 -0
  13. data/lib/agentilda/adoption.rb +7 -2
  14. data/lib/agentilda/agent.rb +40 -99
  15. data/lib/agentilda/agents.rb +122 -0
  16. data/lib/agentilda/board.rb +91 -0
  17. data/lib/agentilda/brief.rb +54 -5
  18. data/lib/agentilda/child.rb +78 -0
  19. data/lib/agentilda/cli/agents/subcommands/describe.rb +8 -4
  20. data/lib/agentilda/cli/base.rb +22 -6
  21. data/lib/agentilda/cli/create/create.rb +46 -19
  22. data/lib/agentilda/cli/docs/docs.rb +6 -2
  23. data/lib/agentilda/cli/index/index.rb +6 -4
  24. data/lib/agentilda/cli/linear/linear.rb +3 -2
  25. data/lib/agentilda/cli/linear/subcommands/import.rb +26 -15
  26. data/lib/agentilda/cli/mail/mail.rb +31 -0
  27. data/lib/agentilda/cli/mail/subcommands/read.rb +43 -0
  28. data/lib/agentilda/cli/mail/subcommands/send.rb +43 -0
  29. data/lib/agentilda/cli/resync/subcommands/dirs.rb +6 -4
  30. data/lib/agentilda/cli/resync/subcommands/prs.rb +21 -13
  31. data/lib/agentilda/cli/run/run.rb +207 -99
  32. data/lib/agentilda/cli/unblock/unblock.rb +24 -13
  33. data/lib/agentilda/cli/worktree/worktree.rb +85 -0
  34. data/lib/agentilda/cli.rb +43 -0
  35. data/lib/agentilda/clock.rb +114 -0
  36. data/lib/agentilda/console.rb +157 -0
  37. data/lib/agentilda/control.rb +63 -0
  38. data/lib/agentilda/creator.rb +7 -2
  39. data/lib/agentilda/dashboard.rb +219 -0
  40. data/lib/agentilda/dev_work.rb +2 -0
  41. data/lib/agentilda/diagram.rb +12 -7
  42. data/lib/agentilda/dispatcher.rb +646 -0
  43. data/lib/agentilda/documentation.rb +14 -6
  44. data/lib/agentilda/executor.rb +362 -204
  45. data/lib/agentilda/feature.rb +14 -135
  46. data/lib/agentilda/frontmatter.rb +4 -2
  47. data/lib/agentilda/github.rb +19 -11
  48. data/lib/agentilda/index.rb +11 -11
  49. data/lib/agentilda/keyboard.rb +76 -8
  50. data/lib/agentilda/launcher.rb +69 -0
  51. data/lib/agentilda/ledger.rb +233 -0
  52. data/lib/agentilda/linear/api.rb +14 -10
  53. data/lib/agentilda/linear/attribution.rb +5 -5
  54. data/lib/agentilda/linear/import.rb +30 -14
  55. data/lib/agentilda/linear/issue.rb +6 -3
  56. data/lib/agentilda/linear/mapping.rb +18 -17
  57. data/lib/agentilda/linear/push.rb +19 -12
  58. data/lib/agentilda/linear/survey.rb +3 -2
  59. data/lib/agentilda/linear/unit.rb +12 -12
  60. data/lib/agentilda/linear.rb +5 -6
  61. data/lib/agentilda/mailbox.rb +141 -0
  62. data/lib/agentilda/markdown.rb +1 -1
  63. data/lib/agentilda/progress_log.rb +5 -1
  64. data/lib/agentilda/publisher.rb +6 -3
  65. data/lib/agentilda/pull_request.rb +0 -186
  66. data/lib/agentilda/pull_requests.rb +230 -0
  67. data/lib/agentilda/reporter.rb +9 -3
  68. data/lib/agentilda/resync.rb +120 -52
  69. data/lib/agentilda/roster.rb +31 -48
  70. data/lib/agentilda/runner.rb +72 -389
  71. data/lib/agentilda/screen/ratatui/bar.rb +57 -0
  72. data/lib/agentilda/screen/ratatui/key_translator.rb +29 -0
  73. data/lib/agentilda/screen/ratatui.rb +318 -0
  74. data/lib/agentilda/state_file.rb +174 -0
  75. data/lib/agentilda/state_machine.rb +60 -32
  76. data/lib/agentilda/status.rb +120 -37
  77. data/lib/agentilda/subject.rb +133 -0
  78. data/lib/agentilda/tally.rb +17 -11
  79. data/lib/agentilda/transcript.rb +47 -16
  80. data/lib/agentilda/tree.rb +10 -6
  81. data/lib/agentilda/ui.rb +204 -190
  82. data/lib/agentilda/unblocker.rb +25 -11
  83. data/lib/agentilda/version.rb +1 -1
  84. data/lib/agentilda/worktree.rb +14 -18
  85. data/lib/agentilda.rb +57 -70
  86. metadata +61 -15
  87. data/Gemfile +0 -26
  88. data/Gemfile.lock +0 -261
  89. data/bin/create-plan-folder +0 -125
  90. data/lib/dry/cli/banner.rb +0 -293
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "agentilda/agent"
4
+ require "agentilda/frontmatter"
5
+
6
+ module Agentilda
7
+ # Loads and indexes the agent definitions.
8
+ class Agents
9
+ # Where definitions live, unless told otherwise.
10
+ DEFAULT_DIR = File.expand_path("../../agents", __dir__)
11
+
12
+ # @param dir [String]
13
+ # @param roster [Array<Agentilda::Agent>, nil] a pre-selected list, used
14
+ # by {#only} and {#without} to derive a narrower roster; nil (the
15
+ # default) loads every definition in `dir`
16
+ def initialize(dir: DEFAULT_DIR, roster: nil)
17
+ @dir = File.expand_path(dir)
18
+ @all = roster
19
+ end
20
+
21
+ # @return [String]
22
+ attr_reader :dir
23
+
24
+ # @return [Array<Agentilda::Agent>] in name order
25
+ def all
26
+ @all ||= Dir.glob(File.join(dir, "*.md")).sort.filter_map { |path| parse(path) }
27
+ end
28
+
29
+ # @param name [String]
30
+ # @return [Agentilda::Agent, nil]
31
+ def find(name) = all.find { |a| a.name == name.to_s }
32
+
33
+ # A roster holding only the agents named — what `run --agent` hands the
34
+ # loop, so a restriction typed at the command line restricts assignments
35
+ # and not merely chaining.
36
+ #
37
+ # @param names [Array<String>]
38
+ # @return [Agentilda::Agents]
39
+ def only(*names)
40
+ wanted = names.flatten.map(&:to_s)
41
+ self.class.new(dir:, roster: all.select { |a| wanted.include?(a.name) })
42
+ end
43
+
44
+ # A roster without the agents named — what `run --skip` hands the loop.
45
+ # A plan sitting in a skipped agent's state is simply never assigned, the
46
+ # same way a state no agent handles is stepped around.
47
+ #
48
+ # @param names [Array<String>]
49
+ # @return [Agentilda::Agents]
50
+ def without(*names)
51
+ unwanted = names.flatten.map(&:to_s)
52
+ self.class.new(dir:, roster: all.reject { |a| unwanted.include?(a.name) })
53
+ end
54
+
55
+ # Every agent the query could mean. An exact name wins outright; failing
56
+ # that the query matches as a prefix, and failing that anywhere in the
57
+ # name, so `leah` finds leah-researcher and `review` finds
58
+ # hansolo-reviewer. A directory or a trailing `.md` is stripped first,
59
+ # because tab completion hands those in.
60
+ #
61
+ # @param query [String]
62
+ # @return [Array<Agentilda::Agent>]
63
+ def match(query)
64
+ wanted = File.basename(query.to_s, ".md")
65
+ exact = all.select { |a| a.name == wanted }
66
+ return exact unless exact.empty?
67
+
68
+ prefixed = all.select { |a| a.name.start_with?(wanted) }
69
+ return prefixed unless prefixed.empty?
70
+
71
+ all.select { |a| a.name.include?(wanted) }
72
+ end
73
+
74
+ # Every agent that will act on a plan in this state, in definition order.
75
+ # A read-only agent is never offered work by the loop — it has nothing to
76
+ # advance, so including it would make every round look productive.
77
+ #
78
+ # @param status [Agentilda::Status]
79
+ # @return [Array<Agentilda::Agent>]
80
+ def for_status(status) = all.select { |a| a.handles?(status) && !a.read_only? }
81
+
82
+ # Everyone on a plan in this state: the agents that work it, plus any
83
+ # agent whose `holds_at` parks the plan here after it finished its half.
84
+ # Rey resuming a plan at 🎨 still needs luke's messages in the mailbox.
85
+ #
86
+ # @param status [Agentilda::Status]
87
+ # @return [Array<Agentilda::Agent>]
88
+ def team_for(status) = all.select { |a| !a.read_only? && (a.handles?(status) || a.holds_at == status.key) }
89
+
90
+ private
91
+
92
+ # @param path [String]
93
+ # @return [Agentilda::Agent, nil]
94
+ def parse(path)
95
+ meta, body = Frontmatter.split(File.read(path, encoding: "UTF-8"))
96
+ return nil if meta["name"].to_s.empty?
97
+
98
+ Agent.new(
99
+ name: meta["name"].to_s,
100
+ description: meta["description"].to_s,
101
+ handles: Array(meta["handles"]).map { |s| s.to_s.to_sym },
102
+ advances_to: meta["advances_to"]&.to_s&.then { |s| s.empty? ? nil : s.to_sym },
103
+ model: meta["model"],
104
+ allowed_tools: Array(meta["allowed_tools"]).map(&:to_s),
105
+ may: Array(meta["may"]).map { |c| c.to_s.strip.squeeze(" ") },
106
+ network: meta["network"] == true,
107
+ timeout: meta["timeout"].to_i.then { |s| s.positive? ? s : nil },
108
+ prompt: body.strip,
109
+ path: path,
110
+ ledger: Array(meta["ledger"]).map(&:to_s),
111
+ rounds: meta["rounds"].to_i.clamp(1, Agent::MAX_ROUNDS),
112
+ effort: meta["effort"]&.to_s,
113
+ starts_as: symbol_or_nil(meta["starts_as"]),
114
+ holds_at: symbol_or_nil(meta["holds_at"])
115
+ )
116
+ end
117
+
118
+ # @param value [Object, nil]
119
+ # @return [Symbol, nil]
120
+ def symbol_or_nil(value) = value.to_s.empty? ? nil : value.to_s.to_sym
121
+ end
122
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # What the screen draws: everything about the run, as values, refreshed
5
+ # once a second by the dispatcher. The screen paints it and knows nothing
6
+ # about threads; the dispatcher builds it and knows nothing about cells.
7
+ #
8
+ # @!attribute [r] rows
9
+ # @return [Array<Agentilda::Board::Row>] one per agent invocation still
10
+ # worth showing: running, or finished within the last few seconds
11
+ Board = Data.define(:started_at,
12
+ :status,
13
+ :plans,
14
+ :up,
15
+ :down,
16
+ :rows,
17
+ :root,
18
+ :running,
19
+ :live_up,
20
+ :live_down,
21
+ :selected,
22
+ :dialog,
23
+ :help,
24
+ :about,
25
+ :hidden) do
26
+ def initialize(selected: nil, dialog: nil, help: false, about: nil, hidden: 0, **rest) = super
27
+ end
28
+
29
+ # @!attribute [r] key
30
+ # @return [String] `"001.00/leah-researcher"`, what selection and the
31
+ # keyboard address a row by
32
+ # @!attribute [r] file
33
+ # @return [String] the document holding the agent's latest ledger line
34
+ # @!attribute [r] pr
35
+ # @return [Hash, nil] `{number:, url:, rejected:}` once the plan is at
36
+ # the review stage
37
+ # @!attribute [r] state
38
+ # @return [Symbol] :running, :done, :failed or :stuck
39
+ # @!attribute [r] phase
40
+ # @return [Symbol, nil] the clock's phase while running
41
+ # @!attribute [r] frame
42
+ # @return [Integer] spinner frame counter
43
+ # @!attribute [r] history
44
+ # @return [Array<String>] what the agent has said, newest first, for
45
+ # `run --scroll-height` to show more than the latest line of
46
+ Board::Row = Data.define(:key,
47
+ :at,
48
+ :ordinal,
49
+ :file,
50
+ :agent,
51
+ :role,
52
+ :round,
53
+ :rounds,
54
+ :model,
55
+ :remaining,
56
+ :phase,
57
+ :up,
58
+ :down,
59
+ :message,
60
+ :state,
61
+ :pr,
62
+ :frame,
63
+ :bold,
64
+ :elapsed,
65
+ :subagents,
66
+ :history) do
67
+ def initialize(pr: nil, phase: nil, remaining: nil, frame: 0, bold: false, message: nil,
68
+ elapsed: 0, subagents: 0, history: [], **rest)
69
+ super
70
+ end
71
+
72
+ # @param history [Array<String>] newest first
73
+ # @param text [String, nil]
74
+ # @return [Array<String>] +text+ on top, unless it is empty or repeats
75
+ # the newest line: an agent restating its activity is not news
76
+ def self.remember(history, text)
77
+ text = text.to_s.strip
78
+ return history if text.empty? || history.first == text
79
+
80
+ [text, *history].first(Board::Row::HISTORY)
81
+ end
82
+
83
+ # @return [Boolean]
84
+ def running? = state == :running
85
+ end
86
+
87
+ # Statuses a row keeps, whatever the screen shows of them. A cap, so a
88
+ # long-running agent's chatter cannot grow without bound. Outside the
89
+ # `Data.define` block, whose constants would land on Agentilda instead.
90
+ Board::Row::HISTORY = 50
91
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tty/command"
4
+
3
5
  module Agentilda
4
6
  # A new plan's opening brief: four questions, and a best-effort first pass
5
7
  # at answering them from what the project already has on disk.
@@ -55,6 +57,49 @@ module Agentilda
55
57
  ALLOWED_TOOLS = %w[Read Grep Glob Edit].freeze
56
58
  DENIED_TOOLS = %w[WebFetch WebSearch Bash].freeze
57
59
 
60
+ # The `stdout:` and `stderr:` sections of a {TTY::Command::ExitError}
61
+ # message. stdout runs until stderr starts; stderr runs to the end, because
62
+ # what `claude` prints there is not guaranteed to be one line.
63
+ STDOUT_SECTION = /^[ \t]*stdout:[ \t]*(.*?)(?=\n[ \t]*stderr:|\z)/m
64
+ STDERR_SECTION = /^[ \t]*stderr:[ \t]*(.*)\z/m
65
+
66
+ # How much of what `claude` said survives into a one-line report.
67
+ REASON_LIMIT = 300
68
+
69
+ # A one-line account of a failed `claude` run, built from the error rather
70
+ # than its first line: that first line is the escaped prompt, several
71
+ # thousand characters of it, and reporting it says an invocation failed,
72
+ # at length, and never why.
73
+ #
74
+ # Both streams are kept because they carry different halves. `claude`
75
+ # reports its own failures on stdout; the line naming the *cause* of a 401
76
+ # (`ANTHROPIC_API_KEY ... takes precedence over your claude.ai login`) is
77
+ # on stderr. {Executor} used to own this; it now reads a child process
78
+ # directly and has no exit error to parse, so the briefer keeps it.
79
+ #
80
+ # @param error [TTY::Command::ExitError]
81
+ # @return [String]
82
+ def self.failure_reason(error)
83
+ status = error.message[/^[ \t]*exit status:[ \t]*(\S+)/, 1]
84
+ outcome = status ? "exited #{status}" : "failed"
85
+ said = [STDOUT_SECTION, STDERR_SECTION]
86
+ .filter_map { |section| tail(error.message[section, 1]) }
87
+ .join(" | ")
88
+
89
+ said.empty? ? "#{outcome} and said nothing" : "#{outcome}: #{said}"
90
+ end
91
+
92
+ # @param text [String, nil]
93
+ # @return [String, nil] the last few meaningful lines, on one line
94
+ def self.tail(text)
95
+ lines = text.to_s.split("\n").map(&:strip).reject { |line| line.empty? || line == "Nothing written" }
96
+ return nil if lines.empty?
97
+
98
+ joined = lines.last(3).join(" ")
99
+ joined.length > REASON_LIMIT ? "#{joined[0, REASON_LIMIT - 1]}..." : joined
100
+ end
101
+ private_class_method :tail
102
+
58
103
  # @param path [String] the new plan folder, absolute
59
104
  # @param title [String] the feature's proper name, e.g. "Tax Rule DSL"
60
105
  # @param root [String] repository root, for project context and `--add-dir`
@@ -121,7 +166,7 @@ module Agentilda
121
166
  rescue TTY::Command::TimeoutExceeded
122
167
  return [false, "timed out after #{@timeout}s"]
123
168
  rescue TTY::Command::ExitError => e
124
- return [false, "claude #{Executor.failure_reason(e)}"]
169
+ return [false, "claude #{self.class.failure_reason(e)}"]
125
170
  end
126
171
 
127
172
  [true, "drafted"]
@@ -132,10 +177,10 @@ module Agentilda
132
177
  # @return [Array<String>]
133
178
  def invocation
134
179
  ["claude",
135
- "-p", prompt, "--add-dir", root,
136
- "--model", BRIEF_MODEL,
137
- "--allowedTools", ALLOWED_TOOLS.join(","),
138
- "--disallowedTools", DENIED_TOOLS.join(",")]
180
+ "-p", prompt, "--add-dir", root,
181
+ "--model", BRIEF_MODEL,
182
+ "--allowedTools", ALLOWED_TOOLS.join(","),
183
+ "--disallowedTools", DENIED_TOOLS.join(",")]
139
184
  end
140
185
 
141
186
  private
@@ -146,6 +191,10 @@ module Agentilda
146
191
  #{author_statement}A new plan folder was just created for a feature that does not exist
147
192
  yet: "#{title}".
148
193
 
194
+ You have 50 seconds. Explore the codebase for the first thirty and
195
+ write for the last twenty; the process is killed at sixty and an
196
+ unwritten draft is lost.
197
+
149
198
  #{spec_path} holds a title and four empty headings:
150
199
 
151
200
  #{HEADINGS.map { |h| " * #{h}" }.join("\n")}
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agentilda
4
+ # One `claude` process the harness started and therefore owns.
5
+ #
6
+ # `TTY::Command` never exposes the pid it spawned, so the executor used to
7
+ # find its child by scraping `ps` for a fresh `claude` under this process
8
+ # and could never safely signal it. Kill (`k`) and the sixty-second
9
+ # backstop after STOP both need a pid that is certainly ours, so the
10
+ # process is spawned here and the pid kept.
11
+ #
12
+ # stderr is folded into the same pipe as stdout: `claude` prints the cause
13
+ # of a failure there, and the transcript keeps non-JSON lines for exactly
14
+ # that reason.
15
+ class Child
16
+ # @param argv [Array<String>]
17
+ # @param chdir [String, nil]
18
+ # @return [Agentilda::Child]
19
+ def self.spawn(argv, chdir: nil)
20
+ reader, writer = IO.pipe
21
+ options = { out: writer, err: writer, in: File::NULL }
22
+ options[:chdir] = chdir if chdir
23
+ pid = Process.spawn(*argv, **options)
24
+ writer.close
25
+ new(pid:, reader:)
26
+ end
27
+
28
+ # @param pid [Integer]
29
+ # @param reader [IO]
30
+ def initialize(pid:, reader:)
31
+ @pid = pid
32
+ @reader = reader
33
+ @status = nil
34
+ end
35
+
36
+ # @return [Integer]
37
+ attr_reader :pid
38
+
39
+ # Read until the process closes its end. Runs on the caller's thread;
40
+ # the executor reads on the same thread it always did.
41
+ #
42
+ # @yieldparam text [String] whatever arrived, possibly a partial line
43
+ # @return [void]
44
+ def each_chunk
45
+ loop { yield @reader.readpartial(65_536) }
46
+ rescue IOError
47
+ @reader.close unless @reader.closed?
48
+ end
49
+
50
+ # @return [Process::Status]
51
+ def wait
52
+ @status ||= begin
53
+ _, status = Process.wait2(pid)
54
+ status
55
+ end
56
+ rescue Errno::ECHILD
57
+ @status
58
+ end
59
+
60
+ # @param signal [String]
61
+ # @return [void]
62
+ def kill(signal = "KILL")
63
+ Process.kill(signal, pid)
64
+ rescue Errno::ESRCH
65
+ # Already gone, which is what was wanted.
66
+ end
67
+
68
+ # @return [Boolean]
69
+ def alive?
70
+ return false if @status
71
+
72
+ Process.kill(0, pid)
73
+ true
74
+ rescue Errno::ESRCH
75
+ false
76
+ end
77
+ end
78
+ end
@@ -10,11 +10,15 @@ module Agentilda
10
10
 
11
11
  desc "Open one specialist's definition in a Markdown viewer"
12
12
 
13
- argument :name, required: false,
14
- desc: "Which specialist, full name or a fragment, e.g. leah (default: every one)"
13
+ argument :name,
14
+ required: false,
15
+ desc: "Which specialist, full name or a fragment, e.g. leah (default: every one)"
15
16
 
16
- option :mdfried, type: :boolean, default: false, aliases: ["-m"],
17
- desc: "Render in the terminal through mdfried instead of the system viewer"
17
+ option :mdfried,
18
+ type: :boolean,
19
+ default: false,
20
+ aliases: ["-m"],
21
+ desc: "Render in the terminal through mdfried instead of the system viewer"
18
22
 
19
23
  example [
20
24
  "leah # leah-researcher, in the system Markdown viewer",
@@ -1,17 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dry/cli"
4
+
3
5
  module Agentilda
4
6
  module CLI
5
7
  # Shared flags and the plumbing every command needs.
6
8
  class Base < Dry::CLI::Command
7
9
  include UI
8
10
 
11
+ # Values of AGENTILDA_AUTOCOMMIT that turn it on.
12
+ AUTOCOMMIT = %w[true yes 1].freeze
13
+
9
14
  def self.inherited(klass)
10
15
  super
11
- klass.option :dir, default: Agentilda::PLANS_DIR, aliases: ["-D"],
12
- desc: "The .plans directory"
13
- klass.option :quiet, type: :boolean, default: false, aliases: ["-q"],
14
- desc: "Suppress progress output on STDERR"
16
+ klass.option :dir,
17
+ default: Agentilda::PLANS_DIR,
18
+ aliases: ["-D"],
19
+ desc: "The .plans directory"
20
+ klass.option :quiet,
21
+ type: :boolean,
22
+ default: false,
23
+ aliases: ["-q"],
24
+ desc: "Suppress progress output on STDERR"
15
25
  end
16
26
 
17
27
  private
@@ -22,7 +32,8 @@ module Agentilda
22
32
  tree = Tree.new(dir: options.fetch(:dir, Agentilda::PLANS_DIR))
23
33
  unless tree.exist?
24
34
  refuse("No #{Agentilda::PLANS_DIR} directory at\n#{tree.dir}\n\n" \
25
- "Run this from the project root, or pass -D.", 66)
35
+ "Run this from the project root, or pass -D.",
36
+ 66)
26
37
  end
27
38
  tree
28
39
  end
@@ -47,9 +58,14 @@ module Agentilda
47
58
  quiet
48
59
  end
49
60
 
61
+ # `--commit`, or AGENTILDA_AUTOCOMMIT=true, which is `--commit` on every
62
+ # command for someone who never wants the dry run.
63
+ #
50
64
  # @param options [Hash]
51
65
  # @return [Boolean]
52
- def commit?(options) = options.fetch(:commit, false)
66
+ def commit?(options)
67
+ options.fetch(:commit, false) || AUTOCOMMIT.include?(ENV.fetch("AGENTILDA_AUTOCOMMIT", "").strip.downcase)
68
+ end
53
69
 
54
70
  # `claude` prefers a credential in the environment to a claude.ai login,
55
71
  # so a project `.env` that direnv loads on `cd` can redirect every agent
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module Agentilda
4
6
  module CLI
5
7
  # `agentilda create <words…>` — backs /spec-create.
@@ -23,7 +25,7 @@ module Agentilda
23
25
  "Create a retroactive plan in the gap after this plan, e.g. 002"
24
26
  option :status,
25
27
  aliases: ["-s"],
26
- desc: "Open in a state other than the default"
28
+ desc: "Open in a state other than the default"
27
29
  option :prs,
28
30
  aliases: ["--pr"],
29
31
  desc:
@@ -46,7 +48,7 @@ module Agentilda
46
48
 
47
49
  # noinspection RubyMismatchedArgumentType
48
50
  example [
49
- "tax rule dsl # 003.00-⚪️--tax-rule-dsl, spec.md scaffolded and drafted",
51
+ "tax rule dsl # 003.00-⚪️ → tax-rule-dsl, spec.md scaffolded and drafted",
50
52
  "tax rule dsl --no-draft --no-open # scaffold only, nothing shelled out, nothing opened",
51
53
  "--after 002 schedule k1 # 002.01-🕰️-schedule-k1 (documented after the fact)",
52
54
  "--after 018 --prs 12,15 verify # …and write spec.md from what those PRs did",
@@ -97,14 +99,14 @@ module Agentilda
97
99
  unless words.empty?
98
100
  refuse(
99
101
  "--from names the topic by its frontmatter title;\n" \
100
- "drop the words, or drop --from",
102
+ "drop the words, or drop --from",
101
103
  64
102
104
  )
103
105
  end
104
106
  if options[:prs]
105
107
  refuse(
106
108
  "--prs reconstructs spec.md from the pull requests, so a seed file\n" \
107
- "would be ignored; use one or the other",
109
+ "would be ignored; use one or the other",
108
110
  64
109
111
  )
110
112
  end
@@ -116,7 +118,7 @@ module Agentilda
116
118
  if title.to_s.strip.empty?
117
119
  refuse(
118
120
  "The seed file needs a frontmatter title, e.g.\n" \
119
- "---\ntitle: Tax Rule DSL\n---",
121
+ "---\ntitle: Tax Rule DSL\n---",
120
122
  65
121
123
  )
122
124
  end
@@ -152,7 +154,7 @@ module Agentilda
152
154
  unless options[:after]
153
155
  refuse(
154
156
  "--prs documents work that already shipped;\n" \
155
- "name the plan it landed after with --after, e.g. --after 018",
157
+ "name the plan it landed after with --after, e.g. --after 018",
156
158
  64
157
159
  )
158
160
  end
@@ -184,8 +186,8 @@ module Agentilda
184
186
  feature = Feature.parse(path)
185
187
  success(
186
188
  "Created #{File.basename(path)}\n\n" \
187
- "#{feature.status.emoji} #{feature.status.label} — #{feature.status.note}\n" \
188
- "#{next_step(path, feature, from_prs:)}"
189
+ "#{feature.status.emoji} #{feature.status.label} — #{feature.status.note}\n" \
190
+ "#{next_step(path, feature, from_prs:)}"
189
191
  )
190
192
  end
191
193
 
@@ -201,10 +203,14 @@ module Agentilda
201
203
  return path
202
204
  root = options[:root] || File.dirname(path, 2)
203
205
 
204
- ok, note =
205
- UI.spinning("Writing spec.md from #{UI.paint(File.basename(path).to_s, :yellow)}") do
206
- Executor.new(root:).call(agent, Subject.new(Feature.parse(path)))
207
- end
206
+ feature = Feature.parse(path)
207
+ ok, note = on_agent_row("Writing spec.md from #{UI.paint(File.basename(path).to_s, :yellow)}",
208
+ agent: agent.name,
209
+ plan: feature.ordinal.to_s,
210
+ root:) do |line|
211
+ handle = line.handle if line.is_a?(Dashboard::Tracker)
212
+ Executor.new(root:).call(agent, Subject.new(feature), handle:, &line)
213
+ end
208
214
  warn_about(note) unless ok
209
215
 
210
216
  settle(path)
@@ -234,7 +240,7 @@ module Agentilda
234
240
  # half-agent reads as one of them on the terminal.
235
241
  label =
236
242
  "#{UI.paint(Brief::AGENT_NAME, :yellow, :bold)} drafting spec.md from #{seed}"
237
- ok, note = UI.spinning(label) { brief.attempt! }
243
+ ok, note = on_agent_row(label, agent: Brief::AGENT_NAME, plan: feature.ordinal.to_s, root:) { brief.attempt! }
238
244
  warn_about_draft(note) unless ok
239
245
  end
240
246
 
@@ -242,6 +248,27 @@ module Agentilda
242
248
  path
243
249
  end
244
250
 
251
+ # One agent writing spec.md, drawn as a row on the ratatui dashboard on
252
+ # a terminal and as a spinner line anywhere else.
253
+ #
254
+ # @param label [String] the spinner line's text
255
+ # @param agent [String]
256
+ # @param plan [String]
257
+ # @param root [String]
258
+ # @yieldparam line [Agentilda::UI::Line]
259
+ # @return [Object] the block's value
260
+ def on_agent_row(label, agent:, plan:, root:, &block)
261
+ Control.on_interrupt do
262
+ UI.concurrently([plan],
263
+ label,
264
+ jobs: 1,
265
+ label: ->(_) { label },
266
+ fields: ->(_) { { plan:, agent: } },
267
+ file: ->(_) { "spec.md" },
268
+ root:) { |_, line| block.call(line) }.first
269
+ end
270
+ end
271
+
245
272
  # @param spec_path [String]
246
273
  # @return [void]
247
274
  def open_spec(spec_path)
@@ -263,9 +290,9 @@ module Agentilda
263
290
  tree = Tree.new(dir: File.dirname(path))
264
291
  change =
265
292
  Agentilda::Resync::Dirs
266
- .new(tree:)
267
- .call(commit: true)
268
- .find { |c| c.source == path }
293
+ .new(tree:)
294
+ .call(commit: true)
295
+ .find { |c| c.source == path }
269
296
  change ? change.target : path
270
297
  end
271
298
 
@@ -274,7 +301,7 @@ module Agentilda
274
301
  def warn_about(note)
275
302
  error(
276
303
  "The folder was created, but spec.md was not written:\n#{note}\n\n" \
277
- "The pull requests are recorded. Run `agentilda run --commit` to retry."
304
+ "The pull requests are recorded. Run `agentilda run --commit` to retry."
278
305
  )
279
306
  end
280
307
 
@@ -283,7 +310,7 @@ module Agentilda
283
310
  def warn_about_draft(note)
284
311
  error(
285
312
  "spec.md was scaffolded, but the drafting attempt did not finish:\n#{note}\n\n" \
286
- "The four headings are there, empty. Fill them in by hand, or hand off to leah-researcher."
313
+ "The four headings are there, empty. Fill them in by hand, or hand off to leah-researcher."
287
314
  )
288
315
  end
289
316
 
@@ -293,7 +320,7 @@ module Agentilda
293
320
  def next_step(path, feature, from_prs:)
294
321
  spec = File.join(File.basename(path), "spec.md")
295
322
  unless feature.status.key == :new &&
296
- File.file?(File.join(path, "spec.md"))
323
+ File.file?(File.join(path, "spec.md"))
297
324
  return "Next: write #{spec}"
298
325
  end
299
326
  if from_prs
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module Agentilda
4
6
  module CLI
5
7
  # `agentilda docs` — regenerate the conventions document.
6
8
  class Docs < Base
7
9
  desc "Generate the conventions document from the state machine itself"
8
10
 
9
- option :output, aliases: ["-o"], desc: "Write here instead of STDOUT",
10
- default: "#{ENV["HOME"]}/.agents/context/workflow.md"
11
+ option :output,
12
+ aliases: ["-o"],
13
+ desc: "Write here instead of STDOUT",
14
+ default: "#{ENV.fetch("HOME", nil)}/.agents/context/workflow.md"
11
15
 
12
16
  example ["", "-o ~/.agents/context/workflow.md"]
13
17
 
@@ -6,10 +6,12 @@ module Agentilda
6
6
  class Index < Base
7
7
  desc "Create or update .plans/INDEX.md — a global index of every plan."
8
8
 
9
- option :output, aliases: ["-o"],
10
- desc: "Write somewhere other than <plans>/INDEX.md; - for STDOUT"
11
- option :project, aliases: ["-p"],
12
- desc: "Heading for the page, default: the repository's directory name"
9
+ option :output,
10
+ aliases: ["-o"],
11
+ desc: "Write somewhere other than <plans>/INDEX.md; - for STDOUT"
12
+ option :project,
13
+ aliases: ["-p"],
14
+ desc: "Heading for the page, default: the repository's directory name"
13
15
 
14
16
  example [
15
17
  " # write .plans/INDEX.md",
@@ -11,8 +11,9 @@ module Agentilda
11
11
  class Team < Base
12
12
  def self.inherited(klass)
13
13
  super
14
- klass.argument :team, required: true,
15
- desc: "The Linear team key that prefixes its issues, e.g. TAX"
14
+ klass.argument :team,
15
+ required: true,
16
+ desc: "The Linear team key that prefixes its issues, e.g. TAX"
16
17
  end
17
18
 
18
19
  private