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 dev this 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-researcher contributes. It is a section of spec.md rather 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 leave yoda-writer reading 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 what blocked.md gets 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.md MUST use this notation. A question written any other way is invisible to every part of this tool: the folder never becomes ⭕️, and unblock reports 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: ## A1 settles ## B1.

Answers are an inbox, not a record. lando-broker folds each one into the document its question was stopping and deletes both, so a blocked.md holding nothing but history must not go on stopping the folder. An A never 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 --to help 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.png stale — just docs will 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

Class Method Details

.block_numbers(text, pattern) ⇒ Array<Integer>

The numbers named by every heading in text matching pattern.

Parameters:

Returns:

  • (Array<Integer>)

    in the order they appear, without duplicates



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.

Parameters:

  • str (String, nil)

Returns:

  • (String)


63
# File 'lib/agentilda/status.rb', line 63

def self.fold_emoji(str) = str.to_s.gsub(/[\u{FE0E}\u{FE0F}\u{200D}]/, "")

.inflectorDry::Inflector

Returns shared inflector.

Returns:

  • (Dry::Inflector)

    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.

Parameters:

  • source (String)

    absolute

  • target (String)

    absolute

Returns:

  • (Boolean)

    false when the target was already occupied



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.

Parameters:

  • label (String)

Returns:

  • (Proc)


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.

Parameters:

  • label (String)

Returns:

  • (Proc)


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.

Parameters:

Returns:

  • (String)


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.

Parameters:

  • root (String)

    repository root

  • ref (String)

    a branch name; origin/ is tried first

Returns:



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".

Parameters:

  • count (Integer)

Returns:

  • (String)


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.

Parameters:

Returns:



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?

Parameters:

  • emoji (String, nil)

Returns:



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".

Parameters:

  • slug (String)

Returns:

  • (String)


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