Module: Agentilda
- Defined in:
- lib/agentilda.rb,
lib/agentilda/ui.rb,
lib/agentilda/cli.rb,
lib/agentilda/tree.rb,
lib/agentilda/agent.rb,
lib/agentilda/brief.rb,
lib/agentilda/index.rb,
lib/agentilda/tally.rb,
lib/agentilda/config.rb,
lib/agentilda/github.rb,
lib/agentilda/linear.rb,
lib/agentilda/resync.rb,
lib/agentilda/roster.rb,
lib/agentilda/runner.rb,
lib/agentilda/status.rb,
lib/agentilda/viewer.rb,
lib/agentilda/control.rb,
lib/agentilda/creator.rb,
lib/agentilda/diagram.rb,
lib/agentilda/feature.rb,
lib/agentilda/ordinal.rb,
lib/agentilda/version.rb,
lib/agentilda/adoption.rb,
lib/agentilda/cli/base.rb,
lib/agentilda/dev_work.rb,
lib/agentilda/executor.rb,
lib/agentilda/keyboard.rb,
lib/agentilda/markdown.rb,
lib/agentilda/reporter.rb,
lib/agentilda/worktree.rb,
lib/agentilda/publisher.rb,
lib/agentilda/unblocker.rb,
lib/agentilda/linear/api.rb,
lib/agentilda/transcript.rb,
lib/agentilda/cli/run/run.rb,
lib/agentilda/frontmatter.rb,
lib/agentilda/linear/push.rb,
lib/agentilda/linear/unit.rb,
lib/agentilda/linear/fuzzy.rb,
lib/agentilda/linear/issue.rb,
lib/agentilda/progress_log.rb,
lib/agentilda/pull_request.rb,
lib/agentilda/cli/docs/docs.rb,
lib/agentilda/documentation.rb,
lib/agentilda/linear/import.rb,
lib/agentilda/linear/survey.rb,
lib/agentilda/state_machine.rb,
lib/agentilda/linear/mapping.rb,
lib/agentilda/cli/index/index.rb,
lib/agentilda/cli/create/create.rb,
lib/agentilda/cli/linear/linear.rb,
lib/agentilda/cli/states/states.rb,
lib/agentilda/linear/attribution.rb,
lib/agentilda/cli/unblock/unblock.rb,
lib/agentilda/cli/version/version.rb,
lib/agentilda/cli/list_plans/list_plans.rb,
lib/agentilda/cli/resync/subcommands/prs.rb,
lib/agentilda/cli/agents/subcommands/list.rb,
lib/agentilda/cli/resync/subcommands/dirs.rb,
lib/agentilda/cli/linear/subcommands/import.rb,
lib/agentilda/cli/agents/subcommands/describe.rb,
lib/agentilda/cli/linear/subcommands/projects.rb
Overview
Alone in its own file, requiring nothing, so the gemspec can read the version without loading the library — and therefore without the library's own dependencies having to be installed before the gemspec can be evaluated.
© 2026 Konstantin Gredeskoul
Defined Under Namespace
Modules: CLI, Control, DevWork, Frontmatter, Linear, Markdown, ProgressLog, Resync, UI Classes: Adoption, Agent, Agents, Brief, Config, Creator, Diagram, Documentation, Error, Executor, Feature, GitHub, Index, Keyboard, Ordinal, Publisher, PullRequest, PullRequests, Reporter, Roster, Runner, StateMachine, Status, Subject, Tally, Transcript, Tree, Unblocker, Viewer, Worktree
Constant Summary collapse
- PLANS_DIR =
The folder every project keeps its plans in.
".plans"- NO_PLAN_PREFIX =
Prefix for a pull request that deliberately implements no plan — dependency bumps, CI work, hotfixes, developer tooling.
Lower case, and not a number, so it cannot be mistaken for one. It used to be
DEV.00, which read as a plan number and sorted among them. "dev"- STALE_NO_PLAN_PREFIX =
What it used to be. Titles still wearing it are rewritten rather than skipped as already-prefixed.
"DEV.00"- NONE_PREFIX =
Prefix for a pull request that implements something nothing could name. Unlike
devthis asserts nothing; it marks a question left open. "none"- RETROACTIVE_WRITER =
The agent that writes a specification for work that already shipped. Named here rather than in the CLI so the definition file stays the single source of truth about who does what.
"yoda-writer"- RESEARCH_CHAPTER =
What proves a specification has been researched rather than merely written: the chapter
leah-researchercontributes. It is a section ofspec.mdrather than a file of its own because research is not a separate document — it is the first half of the specification, and splitting it would leaveyoda-writerreading two files to write one. /^[ \t]{0,3}\#{2,3}[ \t]+Research\b/i- OPEN_BLOCK =
A question that is still somebody's to answer, written as its own heading:
## B1,## B2. The number is whatblocked.mdgets referenced by in conversation and in pull requests, so it is also the only part of the file a program can count.Whoever writes
blocked.mdMUST use this notation. A question written any other way is invisible to every part of this tool: the folder never becomes ⭕️, andunblockreports a file with nothing in it to drain. ANSWER_BLOCK is the other half of the pair. /^[ \t]{0,3}\#{0,4}[ \t]*\**B(\d+)\b/- ANSWER_BLOCK =
The answer to the question of the same number:
## A1settles## B1.Answers are an inbox, not a record.
lando-brokerfolds each one into the document its question was stopping and deletes both, so ablocked.mdholding nothing but history must not go on stopping the folder. AnAnever matches OPEN_BLOCK, which is what lets the two live in one file. /^[ \t]{0,3}\#{0,4}[ \t]*\**A(\d+)\b/- STATUSES =
Every state a plan folder may be in, in lifecycle order. That order drives the legend, the generated documentation and the
--tohelp text.🟣 Merged is deliberately absent: it is a pull request's status, not a folder's, and giving it a folder state invites folders that claim a pull request's condition as their own.
Adding a state here, or changing an emoji, makes the hand-drawn
docs/img/plan-spec-build.pngstale —just docswill show you, because the mermaid source in the generated document moves with this list. [ Status.new( key: :new, emoji: "⚪️", label: "New", requires: %w[spec.md], note: "a specification exists; it has not been planned yet", invariant: nil ), Status.new( key: :researched, emoji: "🔎", label: "Researched", requires: %w[spec.md], note: "the topic has been researched; `spec.md` carries a `## Research` chapter", invariant: lambda { |s| body = s.read("spec.md").to_s "Researched, but `spec.md` has no `## Research` chapter" unless body.match?(RESEARCH_CHAPTER) } ), Status.new( key: :planned, emoji: "⭐️", label: "Planned", requires: %w[spec.md plan.md], note: "specified and planned; nobody has started building", invariant: nil ), # `pull-requests.md` is deliberately absent from `requires` on both of the # building states, though it names the file they are about. That file is # written by opening a pull request, which happens once, when the last # implementer advances the plan to Ready for Review. Requiring the file to # *enter* Building would mean nothing could ever justify entering it, and # `palpatine-planner` finishing `plan.md` would loop forever between itself # and a state it can never actually reach. Ready for Review is where the # file becomes a real requirement, once something has had the chance to # write it. Status.new( key: :building, emoji: "🟡", label: "Building", requires: %w[spec.md plan.md], note: "the back end is under way: data, domain and the API the interface will call", invariant: nil ), # Two building states rather than one, because the order is not a # preference: an interface is written against an API that already answers. # Splitting them lets each half be built by someone who only has to be good # at that half, and gives the second a working system to build on rather # than a description of one. Status.new( key: :building_ui, emoji: "🎨", label: "Building UI", requires: %w[spec.md plan.md], note: "the back end holds; the interface is being built against it", invariant: nil ), Status.new( key: :ready_for_review, emoji: "🟢", label: "Ready for Review", requires: %w[spec.md plan.md pull-requests.md], note: "every pull request is green on CI and waiting for a reviewer", invariant: open_pull_request("Ready for Review") ), Status.new( key: :in_review, emoji: "👀", label: "In Review", requires: %w[spec.md plan.md pull-requests.md], note: "a reviewer has picked it up and has not ruled yet", invariant: open_pull_request("In Review") ), Status.new( key: :rejected, emoji: "🔴", label: "Changes Requested", requires: %w[spec.md plan.md pull-requests.md], note: "the review asked for fixes; resubmit once they are made", invariant: open_pull_request("Changes Requested") ), Status.new( key: :approved, emoji: "✅", label: "Approved & Merged", requires: %w[pull-requests.md], note: "reviewed, approved, and every pull request merged", invariant: lambda { |s| return "Approved & Merged, but no pull requests are recorded" if s.pull_requests.empty? open = s.pull_requests.count(&:open?) "Approved & Merged, but #{Agentilda.pull_request_count(open)} still open" if open.positive? } ), Status.new( key: :deployed, emoji: "😎", label: "Deployed", requires: %w[deployed.md], note: "live in production; `deployed.md` names the release, date and SHA", invariant: nil ), Status.new( key: :rolled_back, emoji: "😱", label: "Rolled Back", requires: %w[rollback.md], note: "it shipped and was pulled; `rollback.md` names what broke", invariant: nil ), Status.new( key: :shit, emoji: "💩", label: "Scrapped by Review", requires: %w[rewrite.md], note: "the review scrapped the work; the plan survives, the pull requests do not", invariant: nil ), Status.new( key: :blocked, emoji: "⭕️", label: "Technical Block", requires: %w[blocked.md], note: "cannot proceed; `blocked.md` names what an engineer or the CTO must decide", invariant: open_block("Technical Block") ), Status.new( key: :product_blocked, emoji: "🅱️", label: "Product Block", requires: %w[blocked.md], note: "cannot proceed; `blocked.md` names what a product manager must decide", invariant: open_block("Product Block") ), Status.new( key: :deferred, emoji: "☢️", label: "Deferred", requires: %w[delayed.md], note: "could proceed and chose not to yet; `delayed.md` must name the trigger", invariant: lambda { |s| body = s.read("delayed.md").to_s "Deferred, but `delayed.md` names no trigger" unless body.match?(/trigger|revisit|when\b|until\b|once\b/i) } ), Status.new( key: :retroactive, emoji: "🕰️", label: "Retroactive", requires: [], note: "the feature is live, but has neither a specification nor a plan", invariant: lambda { |s| next "Retroactive, but a `spec.md` already exists — it has been documented" if s.file?("spec.md") "Retroactive, but no pull requests are recorded" if s.pull_requests.empty? } ), Status.new( key: :discarded, emoji: "❌", label: "Discarded", requires: %w[discarded.md], note: "dropped for good; `discarded.md` says why. A terminal state", invariant: nil ) ].freeze
- STATUS_BY_EMOJI =
Emoji => status, folded so ⚪ and ⚪️ both resolve.
STATUSES.each_with_object({}) { |s, h| h[fold_emoji(s.emoji)] ||= s }.freeze
- STATUS_BY_KEY =
Symbol => status.
STATUSES.to_h { |s| [s.key, s] }.freeze
- SMALL_WORDS =
Lowercase in the middle of a title, capitalised at the front.
%w[a an and as at but by for from in into nor of on or per the to via vs with].freeze
- ACRONYMS =
Slug words that are really acronyms and should shout.
%w[ abac ai api aws cdn ci cd cli cms cors cpu crm css csv db dns dsl e2e ec2 etl gcp gdpr gpu gui html http https iam id ide jwt json k8s llm ml mcp mvp npm oauth orm otp pdf pii poc pr prs qa rbac rds rest rls rpc rss s3 saas sdk seo sns spa sql sqs sre ssh sso ssl ssr tls tui ts tsx ui ux uuid vpc vpn xml yaml ].freeze
- SPECIAL_CASE =
Words with a house spelling that neither capitalize nor upcase gets right.
{ "github" => "GitHub", "gitlab" => "GitLab", "graphql" => "GraphQL", "ios" => "iOS", "javascript" => "JavaScript", "macos" => "macOS", "nodejs" => "Node.js", "oauth" => "OAuth", "openai" => "OpenAI", "postgres" => "PostgreSQL", "postgresql" => "PostgreSQL", "typescript" => "TypeScript", "uuidv7" => "UUIDv7", "websocket" => "WebSocket" }.freeze
- VERSION =
"1.0.3"
Class Method Summary collapse
-
.block_numbers(text, pattern) ⇒ Array<Integer>
The numbers named by every heading in
textmatchingpattern. -
.fold_emoji(str) ⇒ String
Variation selectors make ⚪️ and ⚪ different strings that mean the same thing to a human.
-
.inflector ⇒ Dry::Inflector
Shared inflector.
-
.move_directory(source, target) ⇒ Boolean
Move a directory, preferring
git mvso its history follows it. -
.open_block(label) ⇒ Proc
⭕️ Technical Block and 🅱️ Product Block are the same thing on disk: a
blocked.mdstill naming at least one question nobody has answered. -
.open_pull_request(label) ⇒ Proc
🟢 Ready for Review, 👀 In Review and 🔴 Changes Requested are the same thing on disk — the work exists and at least one pull request is still open.
-
.plan_dirname(ordinal, status, slug) ⇒ String
The one place a plan folder's name is spelled out:
NNN.MM-<emoji>--<slug>. -
.plans_on_ref(root, ref) ⇒ Array<Agentilda::Ordinal>
Plan numbers visible in
.planson a git ref, without checking it out. -
.pull_request_count(count) ⇒ String
"1 pull request", "3 pull requests".
-
.status(text) ⇒ Agentilda::Status?
Resolve a state from the only two names it has: its key, and its emoji.
- .status_for_emoji(emoji) ⇒ Agentilda::Status?
-
.titleize(slug) ⇒ String
Turn a kebab slug into a proper name:
law-as-data→ "Law as Data".
Class Method Details
.block_numbers(text, pattern) ⇒ Array<Integer>
The numbers named by every heading in text matching pattern.
104 105 106 |
# File 'lib/agentilda/status.rb', line 104 def self.block_numbers(text, pattern) text.to_s.lines.filter_map { |line| line[pattern, 1]&.to_i }.uniq end |
.fold_emoji(str) ⇒ String
Variation selectors make ⚪️ and ⚪ different strings that mean the same thing to a human. Compare with them removed.
63 |
# File 'lib/agentilda/status.rb', line 63 def self.fold_emoji(str) = str.to_s.gsub(/[\u{FE0E}\u{FE0F}\u{200D}]/, "") |
.inflector ⇒ Dry::Inflector
Returns shared inflector.
61 |
# File 'lib/agentilda.rb', line 61 def self.inflector = @inflector ||= Dry::Inflector.new |
.move_directory(source, target) ⇒ Boolean
Move a directory, preferring git mv so its history follows it.
Both the state machine and resync dirs move plan folders, and a folder
that loses its history because one of them used FileUtils is a folder
nobody can git log.
72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/agentilda.rb', line 72 def self.move_directory(source, target) return false if File.exist?(target) parent = File.dirname(source) tracked = system("git", "-C", parent, "ls-files", "--error-unmatch", source, out: File::NULL, err: File::NULL) moved = tracked && system("git", "-C", parent, "mv", source, target, out: File::NULL, err: File::NULL) FileUtils.mv(source, target) unless moved true end |
.open_block(label) ⇒ Proc
⭕️ Technical Block and 🅱️ Product Block are the same thing on disk: a
blocked.md still naming at least one question nobody has answered. Which
human is owed the answer lives in the folder name and nowhere else, so the
two share one invariant rather than two that could drift apart.
This is what lets a block drain in pieces. Answers land in blocked.md
one at a time, lando-broker moves each into spec.md or plan.md and
deletes it here, and the folder leaves ⭕️ on the pass that empties the
file, by content rather than by anyone remembering to delete it.
120 121 122 123 124 |
# File 'lib/agentilda/status.rb', line 120 def self.open_block(label) lambda { |subject| "#{label}, but `blocked.md` names no open question" unless subject.read("blocked.md").to_s.match?(OPEN_BLOCK) } end |
.open_pull_request(label) ⇒ Proc
🟢 Ready for Review, 👀 In Review and 🔴 Changes Requested are the same thing on disk — the work exists and at least one pull request is still open. Which of the three it is lives in the folder name and nowhere else, so they share one invariant rather than three that could drift apart.
72 73 74 75 76 77 78 |
# File 'lib/agentilda/status.rb', line 72 def self.open_pull_request(label) lambda { |subject| return "#{label} requires an open pull request; none are recorded" if subject.pull_requests.empty? "#{label}, but every pull request is already merged" if subject.pull_requests.none?(&:open?) } end |
.plan_dirname(ordinal, status, slug) ⇒ String
The one place a plan folder's name is spelled out: NNN.MM-<emoji>--<slug>.
The double dash after the emoji is deliberate: an emoji renders two cells
wide and visually swallows a single dash beside it, so 🔎-refactor reads
as if the emoji and the slug were touching. Everything that mints or
renames a folder goes through here; Agentilda::Feature.parse accepts the older
single-dash spelling too, and resync dirs normalises it on contact.
59 |
# File 'lib/agentilda/feature.rb', line 59 def self.plan_dirname(ordinal, status, slug) = "#{ordinal}-#{status.emoji}--#{slug}" |
.plans_on_ref(root, ref) ⇒ Array<Agentilda::Ordinal>
Plan numbers visible in .plans on a git ref, without checking it out.
A pull request's branch is the only place that knows how far the sequence
had got when the work started, and branches are not all rebased onto the
same main. Reading the ref directly costs one ls-tree and no worktree.
93 94 95 96 97 98 99 100 101 |
# File 'lib/agentilda.rb', line 93 def self.plans_on_ref(root, ref) ["origin/#{ref}", ref].each do |candidate| out = `git -C #{root.shellescape} ls-tree -d --name-only #{candidate.shellescape} #{PLANS_DIR}/ 2>/dev/null` next if out.to_s.strip.empty? return out.lines.filter_map { |line| Ordinal.from_dirname(File.basename(line.strip)) } end [] end |
.pull_request_count(count) ⇒ String
"1 pull request", "3 pull requests".
49 |
# File 'lib/agentilda/status.rb', line 49 def self.pull_request_count(count) = "#{count} pull request#{"s" unless count == 1}" |
.status(text) ⇒ Agentilda::Status?
Resolve a state from the only two names it has: its key, and its emoji.
There is deliberately no synonym table. A state that also answers to "star", "green" and "completed" has four names to keep in step with the folder, the generated docs and the agent frontmatter — and the synonym table that used to live here went stale pointing six words at a state that no longer existed.
273 274 275 276 277 278 279 |
# File 'lib/agentilda/status.rb', line 273 def self.status(text) return nil if text.nil? return text if text.is_a?(Status) key = text.to_s.strip STATUS_BY_KEY[key.to_sym] || status_for_emoji(key) end |
.status_for_emoji(emoji) ⇒ Agentilda::Status?
261 |
# File 'lib/agentilda/status.rb', line 261 def self.status_for_emoji(emoji) = STATUS_BY_EMOJI[fold_emoji(emoji)] |
.titleize(slug) ⇒ String
Turn a kebab slug into a proper name: law-as-data → "Law as Data".
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
# File 'lib/agentilda/feature.rb', line 27 def self.titleize(slug) words = slug.to_s.split(/[-_\s.]+/).reject(&:empty?) return "" if words.empty? words.each_with_index.map { |word, i| lower = word.downcase if SPECIAL_CASE.key?(lower) SPECIAL_CASE[lower] elsif ACRONYMS.include?(lower) lower.upcase elsif i.positive? && SMALL_WORDS.include?(lower) lower elsif lower.match?(/\A\d+\z/) lower else lower.sub(/\A./, &:upcase) end }.join(" ") end |