agentilda 2.0.3 → 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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/lib/agentilda/agents/agent.rb +79 -0
  3. data/lib/agentilda/agents/registry.rb +121 -0
  4. data/lib/agentilda/agents/roster.rb +92 -0
  5. data/lib/agentilda/agents.rb +11 -113
  6. data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
  7. data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
  8. data/lib/agentilda/cli/base.rb +3 -3
  9. data/lib/agentilda/cli/create/create.rb +17 -18
  10. data/lib/agentilda/cli/docs/docs.rb +1 -1
  11. data/lib/agentilda/cli/index/index.rb +1 -1
  12. data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
  13. data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
  14. data/lib/agentilda/cli/mail/mail.rb +4 -4
  15. data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
  16. data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
  17. data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
  18. data/lib/agentilda/cli/run/run.rb +40 -40
  19. data/lib/agentilda/cli/states/states.rb +1 -1
  20. data/lib/agentilda/cli/unblock/unblock.rb +17 -17
  21. data/lib/agentilda/cli/worktree/worktree.rb +4 -4
  22. data/lib/agentilda/cli.rb +1 -1
  23. data/lib/agentilda/engine/board.rb +93 -0
  24. data/lib/agentilda/engine/dispatcher.rb +646 -0
  25. data/lib/agentilda/engine/progress_log.rb +125 -0
  26. data/lib/agentilda/engine/runner.rb +141 -0
  27. data/lib/agentilda/engine/state_file.rb +176 -0
  28. data/lib/agentilda/engine/tally.rb +177 -0
  29. data/lib/agentilda/engine.rb +19 -0
  30. data/lib/agentilda/execution/child.rb +94 -0
  31. data/lib/agentilda/execution/clock.rb +116 -0
  32. data/lib/agentilda/execution/control.rb +180 -0
  33. data/lib/agentilda/execution/executor.rb +699 -0
  34. data/lib/agentilda/execution/transcript.rb +468 -0
  35. data/lib/agentilda/execution.rb +18 -0
  36. data/lib/agentilda/lifecycle/adoption.rb +197 -0
  37. data/lib/agentilda/lifecycle/brief.rb +285 -0
  38. data/lib/agentilda/lifecycle/creator.rb +125 -0
  39. data/lib/agentilda/lifecycle/resync.rb +428 -0
  40. data/lib/agentilda/lifecycle/unblocker.rb +223 -0
  41. data/lib/agentilda/lifecycle.rb +28 -0
  42. data/lib/agentilda/linear/api.rb +1 -1
  43. data/lib/agentilda/linear/attribution.rb +7 -7
  44. data/lib/agentilda/linear/import.rb +18 -18
  45. data/lib/agentilda/linear/mapping.rb +6 -6
  46. data/lib/agentilda/linear/push.rb +5 -5
  47. data/lib/agentilda/linear/survey.rb +7 -7
  48. data/lib/agentilda/linear/unit.rb +5 -5
  49. data/lib/agentilda/plans/dev_work.rb +56 -0
  50. data/lib/agentilda/plans/feature.rb +131 -0
  51. data/lib/agentilda/plans/index.rb +208 -0
  52. data/lib/agentilda/plans/ledger.rb +235 -0
  53. data/lib/agentilda/plans/mailbox.rb +143 -0
  54. data/lib/agentilda/plans/ordinal.rb +92 -0
  55. data/lib/agentilda/plans/pull_request.rb +29 -0
  56. data/lib/agentilda/plans/pull_requests.rb +229 -0
  57. data/lib/agentilda/plans/state_machine.rb +383 -0
  58. data/lib/agentilda/plans/status.rb +365 -0
  59. data/lib/agentilda/plans/subject.rb +131 -0
  60. data/lib/agentilda/plans/tree.rb +79 -0
  61. data/lib/agentilda/plans.rb +39 -0
  62. data/lib/agentilda/presentation/console.rb +159 -0
  63. data/lib/agentilda/presentation/dashboard.rb +221 -0
  64. data/lib/agentilda/presentation/diagram.rb +149 -0
  65. data/lib/agentilda/presentation/documentation.rb +439 -0
  66. data/lib/agentilda/presentation/keyboard.rb +158 -0
  67. data/lib/agentilda/presentation/reporter.rb +181 -0
  68. data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
  69. data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
  70. data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
  71. data/lib/agentilda/presentation/viewer.rb +62 -0
  72. data/lib/agentilda/presentation.rb +23 -0
  73. data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
  74. data/lib/agentilda/vcs/github.rb +170 -0
  75. data/lib/agentilda/vcs/publisher.rb +177 -0
  76. data/lib/agentilda/vcs/worktree.rb +209 -0
  77. data/lib/agentilda/vcs.rb +19 -0
  78. data/lib/agentilda/version.rb +1 -1
  79. data/lib/agentilda.rb +6 -5
  80. metadata +56 -49
  81. data/lib/agentilda/adoption.rb +0 -197
  82. data/lib/agentilda/agent.rb +0 -77
  83. data/lib/agentilda/board.rb +0 -91
  84. data/lib/agentilda/brief.rb +0 -283
  85. data/lib/agentilda/child.rb +0 -92
  86. data/lib/agentilda/clock.rb +0 -114
  87. data/lib/agentilda/console.rb +0 -157
  88. data/lib/agentilda/control.rb +0 -178
  89. data/lib/agentilda/creator.rb +0 -125
  90. data/lib/agentilda/dashboard.rb +0 -219
  91. data/lib/agentilda/dev_work.rb +0 -56
  92. data/lib/agentilda/diagram.rb +0 -149
  93. data/lib/agentilda/dispatcher.rb +0 -646
  94. data/lib/agentilda/documentation.rb +0 -437
  95. data/lib/agentilda/executor.rb +0 -697
  96. data/lib/agentilda/feature.rb +0 -132
  97. data/lib/agentilda/github.rb +0 -168
  98. data/lib/agentilda/index.rb +0 -206
  99. data/lib/agentilda/keyboard.rb +0 -156
  100. data/lib/agentilda/ledger.rb +0 -233
  101. data/lib/agentilda/mailbox.rb +0 -141
  102. data/lib/agentilda/ordinal.rb +0 -90
  103. data/lib/agentilda/progress_log.rb +0 -126
  104. data/lib/agentilda/publisher.rb +0 -175
  105. data/lib/agentilda/pull_request.rb +0 -27
  106. data/lib/agentilda/pull_requests.rb +0 -230
  107. data/lib/agentilda/reporter.rb +0 -181
  108. data/lib/agentilda/resync.rb +0 -426
  109. data/lib/agentilda/roster.rb +0 -93
  110. data/lib/agentilda/runner.rb +0 -139
  111. data/lib/agentilda/screen/ratatui/bar.rb +0 -57
  112. data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
  113. data/lib/agentilda/screen/ratatui.rb +0 -318
  114. data/lib/agentilda/state_file.rb +0 -174
  115. data/lib/agentilda/state_machine.rb +0 -383
  116. data/lib/agentilda/status.rb +0 -363
  117. data/lib/agentilda/subject.rb +0 -133
  118. data/lib/agentilda/tally.rb +0 -175
  119. data/lib/agentilda/transcript.rb +0 -466
  120. data/lib/agentilda/tree.rb +0 -81
  121. data/lib/agentilda/unblocker.rb +0 -221
  122. data/lib/agentilda/viewer.rb +0 -60
  123. data/lib/agentilda/worktree.rb +0 -207
  124. /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
  125. /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
  126. /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
@@ -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
@@ -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
@@ -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}&nbsp;#{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(" &nbsp; ")
193
-
194
- "\n---\n\n#{tree.subjects.size} #{tree.subjects.size == 1 ? "plan" : "plans"} &nbsp; #{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
@@ -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