agentilda 2.0.1 → 2.0.4
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 +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
data/lib/agentilda/feature.rb
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "agentilda/ordinal"
|
|
4
|
-
require "agentilda/status"
|
|
5
|
-
|
|
6
|
-
module Agentilda
|
|
7
|
-
# Lowercase in the middle of a title, capitalised at the front.
|
|
8
|
-
SMALL_WORDS = %w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
|
|
9
|
-
|
|
10
|
-
# Slug words that are really acronyms and should shout.
|
|
11
|
-
ACRONYMS = %w[
|
|
12
|
-
abac ai api aws cdn ci cd cli cms cors cpu crm css csv db dns dsl e2e ec2 etl gcp gdpr gpu gui
|
|
13
|
-
html http https iam id ide jwt json k8s llm ml mcp mvp npm oauth orm otp pdf pii poc pr prs qa
|
|
14
|
-
rbac rds rest rls rpc rss s3 saas sdk seo sns spa sql sqs sre ssh sso ssl ssr tls tui ts tsx ui
|
|
15
|
-
ux uuid vpc vpn xml yaml
|
|
16
|
-
].freeze
|
|
17
|
-
|
|
18
|
-
# Words with a house spelling that neither capitalize nor upcase gets right.
|
|
19
|
-
SPECIAL_CASE = {
|
|
20
|
-
"github" => "GitHub", "gitlab" => "GitLab", "graphql" => "GraphQL", "ios" => "iOS",
|
|
21
|
-
"javascript" => "JavaScript", "macos" => "macOS", "nodejs" => "Node.js", "oauth" => "OAuth",
|
|
22
|
-
"openai" => "OpenAI", "postgres" => "PostgreSQL", "postgresql" => "PostgreSQL",
|
|
23
|
-
"typescript" => "TypeScript", "uuidv7" => "UUIDv7", "websocket" => "WebSocket"
|
|
24
|
-
}.freeze
|
|
25
|
-
|
|
26
|
-
# Turn a kebab slug into a proper name: `law-as-data` → "Law as Data".
|
|
27
|
-
#
|
|
28
|
-
# @param slug [String]
|
|
29
|
-
# @return [String]
|
|
30
|
-
def self.titleize(slug)
|
|
31
|
-
words = slug.to_s.split(/[-_\s.]+/).reject(&:empty?)
|
|
32
|
-
return "" if words.empty?
|
|
33
|
-
|
|
34
|
-
words.each_with_index.map { |word, i|
|
|
35
|
-
lower = word.downcase
|
|
36
|
-
if SPECIAL_CASE.key?(lower)
|
|
37
|
-
SPECIAL_CASE[lower]
|
|
38
|
-
elsif ACRONYMS.include?(lower)
|
|
39
|
-
lower.upcase
|
|
40
|
-
elsif i.positive? && SMALL_WORDS.include?(lower)
|
|
41
|
-
lower
|
|
42
|
-
elsif lower.match?(/\A\d+\z/)
|
|
43
|
-
lower
|
|
44
|
-
else
|
|
45
|
-
lower.sub(/\A./, &:upcase)
|
|
46
|
-
end
|
|
47
|
-
}.join(" ")
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# The one place a plan folder's name is spelled out: `NNN.MM-<emoji> → <slug>`.
|
|
51
|
-
#
|
|
52
|
-
# The spaced arrow after the emoji is deliberate: an emoji renders two cells
|
|
53
|
-
# wide and visually swallows a bare dash beside it, so `🔎-refactor` reads
|
|
54
|
-
# as if the emoji and the slug were touching. Everything that mints or
|
|
55
|
-
# renames a folder goes through here; {Feature.parse} accepts the older
|
|
56
|
-
# `--` and single-dash spellings too, and `resync dirs` migrates them on
|
|
57
|
-
# contact.
|
|
58
|
-
#
|
|
59
|
-
# @param ordinal [Agentilda::Ordinal, String]
|
|
60
|
-
# @param status [Agentilda::Status]
|
|
61
|
-
# @param slug [String]
|
|
62
|
-
# @return [String]
|
|
63
|
-
def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji} → #{slug}"
|
|
64
|
-
|
|
65
|
-
# One `NNN.MM-<emoji> → <slug>` folder, decoded.
|
|
66
|
-
#
|
|
67
|
-
# @!attribute [r] ordinal
|
|
68
|
-
# @return [Agentilda::Ordinal]
|
|
69
|
-
# @!attribute [r] status
|
|
70
|
-
# @return [Agentilda::Status]
|
|
71
|
-
# @!attribute [r] slug
|
|
72
|
-
# @return [String]
|
|
73
|
-
# @!attribute [r] dirname
|
|
74
|
-
# @return [String] exactly as it is on disk
|
|
75
|
-
# @!attribute [r] path
|
|
76
|
-
# @return [String] absolute
|
|
77
|
-
Feature = Data.define(:ordinal, :status, :slug, :dirname, :path) do
|
|
78
|
-
include Comparable
|
|
79
|
-
|
|
80
|
-
# Decode a folder name, or return nil when it is not a plan folder.
|
|
81
|
-
#
|
|
82
|
-
# @param path [String] absolute path to a candidate directory
|
|
83
|
-
# @return [Agentilda::Feature, nil]
|
|
84
|
-
def self.parse(path)
|
|
85
|
-
dirname = File.basename(path)
|
|
86
|
-
ordinal = Ordinal.from_dirname(dirname) or return nil
|
|
87
|
-
|
|
88
|
-
rest = dirname.sub(/\A[\d.]+[-_]/, "")
|
|
89
|
-
head, tail = rest.split(/\s*→\s*|[-_]/, 2)
|
|
90
|
-
|
|
91
|
-
# A leading segment with no ASCII word character is the status emoji.
|
|
92
|
-
# The slug strips any further separators: the canonical spelling puts
|
|
93
|
-
# a spaced arrow after the emoji, and folders from before that rule put
|
|
94
|
-
# two dashes, or one.
|
|
95
|
-
status = (Agentilda.status_for_emoji(head) if tail && !head.to_s.empty? && !head.match?(/[A-Za-z0-9]/))
|
|
96
|
-
slug = status ? tail.sub(/\A[-_\s]+/, "") : rest
|
|
97
|
-
|
|
98
|
-
new(ordinal:, status: status || STATUS_BY_KEY.fetch(:new), slug:, dirname:, path:)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# @return [String] proper name, e.g. "Law as Data"
|
|
102
|
-
def title = Agentilda.titleize(slug)
|
|
103
|
-
|
|
104
|
-
# The folder name this feature would carry in a given state — the slug
|
|
105
|
-
# never moves, and the number is always rendered canonically, so this is
|
|
106
|
-
# also what repairs a folder written `018-⚪️--foo` before the `NNN.MM` rule.
|
|
107
|
-
#
|
|
108
|
-
# @param status [Agentilda::Status]
|
|
109
|
-
# @return [String]
|
|
110
|
-
def dirname_as(status) = Agentilda.plan_dirname(ordinal, status, slug)
|
|
111
|
-
|
|
112
|
-
# The number exactly as the folder writes it, which is not always the
|
|
113
|
-
# canonical rendering: `018-⚪️--foo` yields "018" where {#ordinal} renders
|
|
114
|
-
# "018.00".
|
|
115
|
-
#
|
|
116
|
-
# @return [String]
|
|
117
|
-
def dirname_ordinal = dirname.to_s[/\A[\d.]+/].to_s
|
|
118
|
-
|
|
119
|
-
# @return [Boolean] whether the number is written in full `NNN.MM` form
|
|
120
|
-
def padded? = dirname_ordinal == ordinal.to_s
|
|
121
|
-
|
|
122
|
-
# Whether the folder is already named the way this tool would name it —
|
|
123
|
-
# number padded, emoji matching the state it claims, slug unchanged.
|
|
124
|
-
#
|
|
125
|
-
# @return [Boolean]
|
|
126
|
-
def canonical? = dirname == dirname_as(status)
|
|
127
|
-
|
|
128
|
-
# @param other [Object]
|
|
129
|
-
# @return [Integer, nil]
|
|
130
|
-
def <=>(other) = other.is_a?(self.class) ? [ordinal, dirname] <=> [other.ordinal, other.dirname] : nil
|
|
131
|
-
end
|
|
132
|
-
end
|
data/lib/agentilda/github.rb
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "tty/command"
|
|
5
|
-
|
|
6
|
-
module Agentilda
|
|
7
|
-
# The `gh` CLI, wrapped thinly.
|
|
8
|
-
#
|
|
9
|
-
# It is a seam rather than a convenience: every example in the suite injects
|
|
10
|
-
# a double here, so nothing in the tests reaches the network or a real
|
|
11
|
-
# repository.
|
|
12
|
-
class GitHub
|
|
13
|
-
# Fields asked of `gh pr list`.
|
|
14
|
-
FIELDS = %w[number title url headRefName files state isDraft mergedAt].freeze
|
|
15
|
-
|
|
16
|
-
# @param command [TTY::Command] runner, injectable for tests
|
|
17
|
-
# @param limit [Integer] how many pull requests to fetch
|
|
18
|
-
def initialize(command: TTY::Command.new(printer: :null), limit: 200)
|
|
19
|
-
@command = command
|
|
20
|
-
@limit = limit
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Every pull request, normalised into plain hashes.
|
|
24
|
-
#
|
|
25
|
-
# @param state [String] "open", "closed", "merged" or "all"
|
|
26
|
-
# @return [Array<Hash>] `{number:, title:, url:, branch:, files:}`
|
|
27
|
-
def pulls(state: "all")
|
|
28
|
-
out = UI.spinning("Fetching pull requests from GitHub") {
|
|
29
|
-
@command.run("gh",
|
|
30
|
-
"pr",
|
|
31
|
-
"list",
|
|
32
|
-
"--state",
|
|
33
|
-
state,
|
|
34
|
-
"--limit",
|
|
35
|
-
@limit.to_s,
|
|
36
|
-
"--json",
|
|
37
|
-
FIELDS.join(",")).out
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
# `gh` can exit 0 having printed NOTHING — most often when it cannot reach
|
|
41
|
-
# the credential store, as in a non-interactive shell that has no keyring
|
|
42
|
-
# access, or when GH_TOKEN is set to something invalid and shadows a
|
|
43
|
-
# working login. Left alone this parses as a JSON error and reports as
|
|
44
|
-
# "bad output", sending you to look at the wrong thing entirely.
|
|
45
|
-
raise Error, no_output_message if out.to_s.strip.empty?
|
|
46
|
-
|
|
47
|
-
JSON.parse(out).map do |pr|
|
|
48
|
-
{
|
|
49
|
-
number: pr["number"],
|
|
50
|
-
title: pr["title"].to_s,
|
|
51
|
-
url: pr["url"],
|
|
52
|
-
branch: pr["headRefName"].to_s,
|
|
53
|
-
files: Array(pr["files"]).map { |f| f["path"] }.compact,
|
|
54
|
-
state: self.class.state_label(pr),
|
|
55
|
-
open: pr["mergedAt"].nil? && pr["state"].to_s.upcase == "OPEN"
|
|
56
|
-
}
|
|
57
|
-
end
|
|
58
|
-
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
59
|
-
raise Error, "could not list pull requests via `gh`: #{e.message.lines.first.to_s.strip}"
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# @return [String] the diagnosis for a silent `gh`
|
|
63
|
-
def no_output_message
|
|
64
|
-
<<~MESSAGE.strip
|
|
65
|
-
`gh` produced no output and did not report an error.
|
|
66
|
-
|
|
67
|
-
That is almost always authentication rather than an empty repository:
|
|
68
|
-
|
|
69
|
-
- Check `gh auth status`. An invalid GH_TOKEN in the environment
|
|
70
|
-
shadows a working keyring login and fails without saying so.
|
|
71
|
-
|
|
72
|
-
- A non-interactive shell may have no access to the system keyring
|
|
73
|
-
even when an interactive one does.
|
|
74
|
-
|
|
75
|
-
Verify with: gh pr list --state all --limit 1
|
|
76
|
-
MESSAGE
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Fields asked of `gh pr view`, which unlike `pr list` can be told about
|
|
80
|
-
# one pull request in another repository.
|
|
81
|
-
VIEW_FIELDS = %w[number title url state isDraft mergedAt body].freeze
|
|
82
|
-
|
|
83
|
-
# A reference to one pull request: a bare number, a `#`-prefixed number,
|
|
84
|
-
# or a full URL to a GitHub pull request or a GitLab merge request.
|
|
85
|
-
REF = %r{\A(?:\#?\d+|https?://\S+?/(?:pull|merge_requests)/\d+/?)\z}
|
|
86
|
-
|
|
87
|
-
# Split and validate a `--prs` value before any of it reaches the network,
|
|
88
|
-
# so a typo fails in a hundredth of a second with the offending token named
|
|
89
|
-
# rather than after four round trips with a `gh` diagnostic.
|
|
90
|
-
#
|
|
91
|
-
# @param text [String] e.g. "12,15,18" or "https://…/pull/12, #15"
|
|
92
|
-
# @return [Array<String>] references, in the order given, de-duplicated
|
|
93
|
-
# @raise [Agentilda::Error] on anything that is not a reference
|
|
94
|
-
def self.parse_refs(text)
|
|
95
|
-
refs = text.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
96
|
-
raise Error, "no pull requests given" if refs.empty?
|
|
97
|
-
|
|
98
|
-
bad = refs.reject { |r| r.match?(REF) }
|
|
99
|
-
unless bad.empty?
|
|
100
|
-
raise Error, "not a pull request number or URL: #{bad.join(", ")}"
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
refs.uniq
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# One pull request, by number or URL.
|
|
107
|
-
#
|
|
108
|
-
# @param ref [String] "12", "#12" or "https://github.com/o/r/pull/12"
|
|
109
|
-
# @return [Hash] `{number:, title:, url:, state:, body:}`
|
|
110
|
-
# @raise [Agentilda::Error]
|
|
111
|
-
def pull_request(ref)
|
|
112
|
-
out = @command.run("gh", "pr", "view", ref.to_s, "--json", VIEW_FIELDS.join(",")).out
|
|
113
|
-
raise Error, no_output_message if out.to_s.strip.empty?
|
|
114
|
-
|
|
115
|
-
pr = JSON.parse(out)
|
|
116
|
-
{
|
|
117
|
-
number: pr["number"],
|
|
118
|
-
title: pr["title"].to_s,
|
|
119
|
-
url: pr["url"].to_s,
|
|
120
|
-
state: self.class.state_label(pr),
|
|
121
|
-
body: pr["body"].to_s
|
|
122
|
-
}
|
|
123
|
-
rescue TTY::Command::ExitError, JSON::ParserError => e
|
|
124
|
-
raise Error, "could not read pull request #{ref}: #{e.message.lines.first.to_s.strip}"
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Several pull requests, in the order asked for.
|
|
128
|
-
#
|
|
129
|
-
# @param refs [Array<String>]
|
|
130
|
-
# @return [Array<Hash>]
|
|
131
|
-
def pull_requests(refs)
|
|
132
|
-
UI.stepping(refs, "Fetching pull requests") { |ref| ref }
|
|
133
|
-
refs.map { |ref| pull_request(ref) }
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# `gh` speaks in enums; `pull-requests.md` speaks in the words the state
|
|
137
|
-
# machine parses. Translate once, here, rather than at each call site.
|
|
138
|
-
#
|
|
139
|
-
# @param pr [Hash] a decoded `gh pr view` payload
|
|
140
|
-
# @return [String] one of the labels in {PullRequests::STATES}
|
|
141
|
-
def self.state_label(pr)
|
|
142
|
-
return "Merged 🟣" if pr["mergedAt"]
|
|
143
|
-
return "WIP 🟡" if pr["isDraft"]
|
|
144
|
-
|
|
145
|
-
case pr["state"].to_s.upcase
|
|
146
|
-
when "OPEN" then "Open 🟡"
|
|
147
|
-
when "CLOSED" then "Closed 🔴"
|
|
148
|
-
else "Unknown"
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Change a pull request's title.
|
|
153
|
-
#
|
|
154
|
-
# @param number [Integer]
|
|
155
|
-
# @param title [String]
|
|
156
|
-
# @return [void]
|
|
157
|
-
def retitle(number:, title:)
|
|
158
|
-
@command.run("gh", "pr", "edit", number.to_s, "--title", title)
|
|
159
|
-
rescue TTY::Command::ExitError => e
|
|
160
|
-
raise Error, "could not retitle ##{number}: #{e.message.lines.first.to_s.strip}"
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# @return [Boolean] whether `gh` is installed and authenticated
|
|
164
|
-
def available?
|
|
165
|
-
@command.run!("gh", "auth", "status").success?
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
end
|
data/lib/agentilda/index.rb
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "cgi"
|
|
4
|
-
require "erb"
|
|
5
|
-
|
|
6
|
-
module Agentilda
|
|
7
|
-
# `agentilda index` — the project's plans as one browsable page.
|
|
8
|
-
#
|
|
9
|
-
# {Reporter} answers "what is the state of things" in a terminal, in columns,
|
|
10
|
-
# for someone standing at a prompt. This answers "what is this project, and
|
|
11
|
-
# where is everything" for someone arriving at the repository on the web: the
|
|
12
|
-
# goal of each plan in its own words, every pull request as a link, and every
|
|
13
|
-
# document in the folder one click away.
|
|
14
|
-
#
|
|
15
|
-
# It is generated for the same reason the conventions document is. A
|
|
16
|
-
# hand-written index of twenty plans cannot survive a rename, and renaming is
|
|
17
|
-
# exactly what `resync dirs` exists to do — the first hand-made INDEX.md this
|
|
18
|
-
# replaced had all 82 of its links pointing at folder names that had been
|
|
19
|
-
# padded to `NNN.MM` underneath it.
|
|
20
|
-
class Index
|
|
21
|
-
# The file this writes, relative to the `.plans` directory.
|
|
22
|
-
FILENAME = "INDEX.md"
|
|
23
|
-
|
|
24
|
-
# Documents that get a proper name rather than their filename.
|
|
25
|
-
ARTIFACT_NAMES = {
|
|
26
|
-
"spec.md" => "Spec",
|
|
27
|
-
"plan.md" => "Plan",
|
|
28
|
-
"pull-requests.md" => "Pull Requests",
|
|
29
|
-
"linear.md" => "Linear",
|
|
30
|
-
"blocked.md" => "Blocked",
|
|
31
|
-
"delayed.md" => "Deferred",
|
|
32
|
-
"rewrite.md" => "Rewrite",
|
|
33
|
-
"deployed.md" => "Deployed",
|
|
34
|
-
"rollback.md" => "Rollback",
|
|
35
|
-
"discarded.md" => "Discarded"
|
|
36
|
-
}.freeze
|
|
37
|
-
|
|
38
|
-
# @param tree [Agentilda::Tree]
|
|
39
|
-
# @param project [String, nil] the heading; defaults to the repository name
|
|
40
|
-
def initialize(tree:, project: nil)
|
|
41
|
-
@tree = tree
|
|
42
|
-
@project = project
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# @return [Agentilda::Tree]
|
|
46
|
-
attr_reader :tree
|
|
47
|
-
|
|
48
|
-
# @return [String] the project's name, titleized from its directory
|
|
49
|
-
def project = @project ||= Agentilda.titleize(File.basename(File.dirname(tree.dir)))
|
|
50
|
-
|
|
51
|
-
# @return [String] the whole document
|
|
52
|
-
def render
|
|
53
|
-
[heading, *tree.subjects.map { |subject| section(subject) }].join("\n") + footer
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Write it next to the plans it describes.
|
|
57
|
-
#
|
|
58
|
-
# @param path [String, nil] override the destination
|
|
59
|
-
# @return [String] where it was written
|
|
60
|
-
def write(path = nil)
|
|
61
|
-
path ||= File.join(tree.dir, FILENAME)
|
|
62
|
-
File.write(path, render)
|
|
63
|
-
path
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
# @return [String]
|
|
69
|
-
def heading
|
|
70
|
-
<<~MARKDOWN
|
|
71
|
-
# Project #{project}
|
|
72
|
-
|
|
73
|
-
> [!IMPORTANT]
|
|
74
|
-
> **This file is auto generated.** To regenerate it, run `agentilda index`.
|
|
75
|
-
> Editing it by hand lasts until the next `resync dirs` renames a folder.
|
|
76
|
-
|
|
77
|
-
## Current specifications and their status
|
|
78
|
-
|
|
79
|
-
MARKDOWN
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# One plan: a heading that carries its number and state, then a table.
|
|
83
|
-
#
|
|
84
|
-
# Raw HTML rather than a markdown table because the cells are not one-liners
|
|
85
|
-
# — a goal is a paragraph or two and a plan may have a dozen pull requests,
|
|
86
|
-
# neither of which a pipe-delimited row can hold.
|
|
87
|
-
#
|
|
88
|
-
# @param subject [Agentilda::Subject]
|
|
89
|
-
# @return [String]
|
|
90
|
-
def section(subject)
|
|
91
|
-
feature = subject.feature
|
|
92
|
-
|
|
93
|
-
<<~MARKDOWN
|
|
94
|
-
## #{feature.ordinal} — #{feature.status.emoji} #{escape(feature.title)}
|
|
95
|
-
|
|
96
|
-
<table>
|
|
97
|
-
<thead>
|
|
98
|
-
<tr>
|
|
99
|
-
<th align="left">Status</th>
|
|
100
|
-
<th align="left">Pull Requests</th>
|
|
101
|
-
<th align="left">What it is</th>
|
|
102
|
-
<th align="left">Artifacts</th>
|
|
103
|
-
</tr>
|
|
104
|
-
</thead>
|
|
105
|
-
<tbody>
|
|
106
|
-
<tr>
|
|
107
|
-
<td valign="top">#{status_cell(subject)}</td>
|
|
108
|
-
<td valign="top">#{pulls_cell(subject)}</td>
|
|
109
|
-
<td valign="top">#{goal_cell(subject)}</td>
|
|
110
|
-
<td valign="top">#{artifacts_cell(feature)}</td>
|
|
111
|
-
</tr>
|
|
112
|
-
</tbody>
|
|
113
|
-
</table>
|
|
114
|
-
|
|
115
|
-
MARKDOWN
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# The emoji and the words, kept on one line — a status that wraps mid-label
|
|
119
|
-
# makes the column look like two states rather than one.
|
|
120
|
-
#
|
|
121
|
-
# @param subject [Agentilda::Subject]
|
|
122
|
-
# @return [String]
|
|
123
|
-
def status_cell(subject)
|
|
124
|
-
cell = "#{subject.status.emoji} #{escape(subject.status.label)}"
|
|
125
|
-
return cell if subject.consistent?
|
|
126
|
-
|
|
127
|
-
"#{cell}<br><sub>⚠️ #{escape(subject.violation)}</sub>"
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# @param subject [Agentilda::Subject]
|
|
131
|
-
# @return [String]
|
|
132
|
-
def pulls_cell(subject)
|
|
133
|
-
prs = subject.pull_requests
|
|
134
|
-
return "<em>—</em>" if prs.empty?
|
|
135
|
-
|
|
136
|
-
items = prs.map { |pr|
|
|
137
|
-
"<li>#{link(pr.url, pr.label)} #{escape(pr.state)}</li>"
|
|
138
|
-
}
|
|
139
|
-
"<ul>#{items.join}</ul>"
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# @param subject [Agentilda::Subject]
|
|
143
|
-
# @return [String]
|
|
144
|
-
def goal_cell(subject)
|
|
145
|
-
paragraphs = subject.goal
|
|
146
|
-
return "<em>No specification yet.</em>" if paragraphs.empty?
|
|
147
|
-
|
|
148
|
-
paragraphs.map { |p| "<p>#{inline(p)}</p>" }.join
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
# Markdown inside a raw HTML block is not parsed — GitHub stops parsing
|
|
152
|
-
# markdown the moment it sees a block-level tag — so a goal quoted verbatim
|
|
153
|
-
# would show its own `**` and backticks. Only the three that carry meaning
|
|
154
|
-
# are translated; single-underscore emphasis is deliberately left alone,
|
|
155
|
-
# because `deleted_at` is more common in these documents than italics.
|
|
156
|
-
#
|
|
157
|
-
# @param text [String] markdown, unescaped
|
|
158
|
-
# @return [String] HTML
|
|
159
|
-
def inline(text)
|
|
160
|
-
escape(text)
|
|
161
|
-
.gsub(/\[([^\]]+)\]\((https?:[^)\s]+)\)/) { %(<a href="#{$2}">#{$1}</a>) }
|
|
162
|
-
.gsub(/\*\*([^*]+)\*\*/) { "<strong>#{$1}</strong>" }
|
|
163
|
-
.gsub(/`([^`]+)`/) { "<code>#{$1}</code>" }
|
|
164
|
-
.tr("\n", " ")
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
# Every document in the folder, linked. The folder name is percent-encoded
|
|
168
|
-
# because it contains an emoji, and a raw one in an href is a broken link
|
|
169
|
-
# on GitHub.
|
|
170
|
-
#
|
|
171
|
-
# @param feature [Agentilda::Feature]
|
|
172
|
-
# @return [String]
|
|
173
|
-
def artifacts_cell(feature)
|
|
174
|
-
files = Dir.children(feature.path).select { |f| File.file?(File.join(feature.path, f)) }
|
|
175
|
-
return "<em>empty</em>" if files.empty?
|
|
176
|
-
|
|
177
|
-
# The documents that carry the lifecycle come first, in lifecycle order;
|
|
178
|
-
# whatever else the folder holds follows alphabetically.
|
|
179
|
-
known = ARTIFACT_NAMES.keys
|
|
180
|
-
files = files.sort_by { |f| [known.index(f) || known.size, f] }
|
|
181
|
-
|
|
182
|
-
items = files.map { |file|
|
|
183
|
-
href = "#{ERB::Util.url_encode(feature.dirname)}/#{ERB::Util.url_encode(file)}"
|
|
184
|
-
"<li>#{link(href, ARTIFACT_NAMES.fetch(file, file))}</li>"
|
|
185
|
-
}
|
|
186
|
-
"<ul>#{items.join}</ul>"
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
# @return [String]
|
|
190
|
-
def footer
|
|
191
|
-
counts = tree.subjects.each_with_object(Hash.new(0)) { |s, h| h[s.status] += 1 }
|
|
192
|
-
.map { |status, n| "#{status.emoji} #{n}" }.join(" ")
|
|
193
|
-
|
|
194
|
-
"\n---\n\n#{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} #{counts}\n"
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# @param href [String]
|
|
198
|
-
# @param text [String]
|
|
199
|
-
# @return [String]
|
|
200
|
-
def link(href, text) = %(<a href="#{escape(href)}">#{escape(text)}</a>)
|
|
201
|
-
|
|
202
|
-
# @param text [String]
|
|
203
|
-
# @return [String]
|
|
204
|
-
def escape(text) = CGI.escapeHTML(text.to_s)
|
|
205
|
-
end
|
|
206
|
-
end
|
data/lib/agentilda/keyboard.rb
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "io/console"
|
|
4
|
-
|
|
5
|
-
module Agentilda
|
|
6
|
-
# Listens for single keypresses while a run is in flight, and turns them
|
|
7
|
-
# into {Control} broadcasts.
|
|
8
|
-
#
|
|
9
|
-
# The listener is a thread reading STDIN in raw mode, which only makes
|
|
10
|
-
# sense when STDIN is a terminal: piped input must never be eaten one byte
|
|
11
|
-
# at a time, and a run under cron has nobody at the keys. {.listen} returns
|
|
12
|
-
# nil in both cases and every caller treats nil as "no keyboard".
|
|
13
|
-
#
|
|
14
|
-
# Raw mode swallows Ctrl-C along with everything else, so ETX is forwarded
|
|
15
|
-
# to the main thread as the Interrupt it would have been — the listener
|
|
16
|
-
# must never make a run harder to kill than it was without one.
|
|
17
|
-
class Keyboard
|
|
18
|
-
# Key → what it does, rendered by {#help} and dispatched by {#handle}.
|
|
19
|
-
BINDINGS = [
|
|
20
|
-
["h", "this help"],
|
|
21
|
-
["?", "about agentilda"],
|
|
22
|
-
["s", "select the next running agent; arrows move the selection"],
|
|
23
|
-
["k", "mark the selected agent to be killed (STOP, 15s, then kill -9)"],
|
|
24
|
-
["x", "extend the selected agent's clock by 10 minutes, per press"],
|
|
25
|
-
["ENTER", "apply the pending changes in the dialog"],
|
|
26
|
-
["ESC", "discard the dialog, then clear the selection"],
|
|
27
|
-
["w", "ask every running agent to wrap up as fast as possible"],
|
|
28
|
-
["n", "ask agents to write out what they have and stop; the loop continues"],
|
|
29
|
-
["q", "write out, stop everything, and quit after a #{Control::GRACE}s grace"],
|
|
30
|
-
["ctrl-c", "agents leave a resume note and stop, then quit; press again to abort now"]
|
|
31
|
-
].freeze
|
|
32
|
-
|
|
33
|
-
# @param input [IO]
|
|
34
|
-
# @param sink [Agentilda::Console, nil] where the table keys go; nil
|
|
35
|
-
# when there is no screen, in which case they do nothing
|
|
36
|
-
# @return [Agentilda::Keyboard, nil] a running listener, or nil when
|
|
37
|
-
# STDIN is not a terminal
|
|
38
|
-
def self.listen(input: $stdin, sink: nil)
|
|
39
|
-
return nil unless input.tty?
|
|
40
|
-
|
|
41
|
-
new(input:, sink:).start
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# @param input [IO]
|
|
45
|
-
# @param sink [Agentilda::Console, nil]
|
|
46
|
-
def initialize(input: $stdin, sink: nil)
|
|
47
|
-
@input = input
|
|
48
|
-
@sink = sink
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# @return [self]
|
|
52
|
-
def start
|
|
53
|
-
@thread = Thread.new do
|
|
54
|
-
Thread.current.report_on_exception = false
|
|
55
|
-
loop { handle(read_key) }
|
|
56
|
-
rescue IOError, Errno::EIO
|
|
57
|
-
# STDIN went away; a keyboard with no keys just stops listening.
|
|
58
|
-
end
|
|
59
|
-
self
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# @return [void]
|
|
63
|
-
def stop
|
|
64
|
-
@thread&.kill
|
|
65
|
-
@thread = nil
|
|
66
|
-
restore
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# `getch` puts the terminal in raw mode for the length of one read and
|
|
70
|
-
# restores it afterwards — but the listener spends almost all its life
|
|
71
|
-
# blocked inside that read, and {#stop} kills it there, which leaves
|
|
72
|
-
# the mode behind. Raw mode has output post-processing off, so a "\n"
|
|
73
|
-
# written after it drops a line without returning the cursor to column
|
|
74
|
-
# zero: the run report then starts wherever the previous line ended and
|
|
75
|
-
# staircases down the screen. Nothing else puts the mode back, so this
|
|
76
|
-
# does.
|
|
77
|
-
#
|
|
78
|
-
# @return [void]
|
|
79
|
-
def restore
|
|
80
|
-
@input.cooked! if @input.tty?
|
|
81
|
-
rescue IOError, SystemCallError
|
|
82
|
-
# No terminal left to put back; there is nothing to restore it to.
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# @param key [String, nil]
|
|
86
|
-
# @return [void]
|
|
87
|
-
def handle(key)
|
|
88
|
-
case key
|
|
89
|
-
when "h" then @sink ? @sink.toggle_help : UI.popup("Keys", help)
|
|
90
|
-
when "?" then @sink ? @sink.toggle_about : UI.popup("About", about)
|
|
91
|
-
when "s", "\e[B" then @sink&.select_next
|
|
92
|
-
when "\e[A" then @sink&.select_prev
|
|
93
|
-
when "k" then @sink&.toggle_kill
|
|
94
|
-
when "x" then @sink&.extend
|
|
95
|
-
when "\r", "\n" then @sink&.apply
|
|
96
|
-
when "\e" then @sink&.escape
|
|
97
|
-
when "w" then acted("w — agents asked to wrap up") { Control.wrap_up! }
|
|
98
|
-
when "n" then acted("n — agents asked to write out and stop") { Control.stop! }
|
|
99
|
-
when "q" then acted("q — quitting; #{Control::GRACE}s grace to write out") { Control.quit! }
|
|
100
|
-
when "\u0003" then interrupt
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# @return [String] the bindings, one per line, widest key first
|
|
105
|
-
def help
|
|
106
|
-
width = BINDINGS.map { |key, _| key.length }.max
|
|
107
|
-
BINDINGS.map { |key, does| "#{key.ljust(width)} #{does}" }.join("\n")
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# @return [String] the version and a reminder of how to leave
|
|
111
|
-
def about = "Agentilda Version #{Agentilda::VERSION}\n\nThanks for using this! Press 'q' to exit and stop all agents."
|
|
112
|
-
|
|
113
|
-
private
|
|
114
|
-
|
|
115
|
-
# ESC alone and ESC-[-A are the same first byte. Wait a few
|
|
116
|
-
# milliseconds for the rest before deciding it was a bare ESC.
|
|
117
|
-
#
|
|
118
|
-
# @return [String]
|
|
119
|
-
def read_key
|
|
120
|
-
key = @input.getch
|
|
121
|
-
return key unless key == "\e"
|
|
122
|
-
|
|
123
|
-
if IO.select([@input], nil, nil, 0.05)
|
|
124
|
-
rest = @input.read_nonblock(2, exception: false)
|
|
125
|
-
return "\e#{rest}" if rest.is_a?(String)
|
|
126
|
-
end
|
|
127
|
-
key
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
# Raw mode swallows the signal, so the key stands in for it: the first
|
|
131
|
-
# press lets agents write down where they got to, the second aborts.
|
|
132
|
-
#
|
|
133
|
-
# @return [void]
|
|
134
|
-
def interrupt
|
|
135
|
-
return Thread.main.raise(Interrupt) if Control.interrupted?
|
|
136
|
-
|
|
137
|
-
acted("ctrl-c — agents leaving resume notes; #{Control::GRACE}s grace, ctrl-c again to abort") { Control.interrupt! }
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# A keypress with no acknowledgement looks like a keypress that did
|
|
141
|
-
# nothing, so each one says what it just asked for.
|
|
142
|
-
#
|
|
143
|
-
# @param note [String]
|
|
144
|
-
# @return [void]
|
|
145
|
-
def acted(note)
|
|
146
|
-
yield
|
|
147
|
-
UI.log(note)
|
|
148
|
-
# Under the dashboard, RatatuiRuby.run owns the alternate screen and
|
|
149
|
-
# repaints it continuously, so this raw `$stderr` write either never
|
|
150
|
-
# appears or is immediately overwritten — `UI.log` above is the only
|
|
151
|
-
# guaranteed record of w/n/q there. The line still matters without a
|
|
152
|
-
# screen (a dry run, a pipe), where this is all the feedback there is.
|
|
153
|
-
UI.line(note)
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|