agentilda 1.0.3
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 +7 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +261 -0
- data/agentilda.gemspec +57 -0
- data/agents/hansolo-reviewer.md +29 -0
- data/agents/lando-broker.md +74 -0
- data/agents/leah-researcher.md +80 -0
- data/agents/luke-backend.md +81 -0
- data/agents/palpatine-planner.md +40 -0
- data/agents/rey-frontend.md +106 -0
- data/agents/yoda-writer.md +54 -0
- data/bin/create-plan-folder +125 -0
- data/bin/plan-number +164 -0
- data/exe/agentilda +111 -0
- data/exe/tilda +1 -0
- data/lib/agentilda/adoption.rb +192 -0
- data/lib/agentilda/agent.rb +136 -0
- data/lib/agentilda/brief.rb +234 -0
- data/lib/agentilda/cli/agents/subcommands/describe.rb +62 -0
- data/lib/agentilda/cli/agents/subcommands/list.rb +20 -0
- data/lib/agentilda/cli/base.rb +88 -0
- data/lib/agentilda/cli/create/create.rb +309 -0
- data/lib/agentilda/cli/docs/docs.rb +30 -0
- data/lib/agentilda/cli/index/index.rb +38 -0
- data/lib/agentilda/cli/linear/linear.rb +35 -0
- data/lib/agentilda/cli/linear/subcommands/import.rb +160 -0
- data/lib/agentilda/cli/linear/subcommands/projects.rb +55 -0
- data/lib/agentilda/cli/list_plans/list_plans.rb +21 -0
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +49 -0
- data/lib/agentilda/cli/resync/subcommands/prs.rb +106 -0
- data/lib/agentilda/cli/run/run.rb +289 -0
- data/lib/agentilda/cli/states/states.rb +15 -0
- data/lib/agentilda/cli/unblock/unblock.rb +227 -0
- data/lib/agentilda/cli/version/version.rb +13 -0
- data/lib/agentilda/cli.rb +74 -0
- data/lib/agentilda/config.rb +44 -0
- data/lib/agentilda/control.rb +115 -0
- data/lib/agentilda/creator.rb +120 -0
- data/lib/agentilda/dev_work.rb +54 -0
- data/lib/agentilda/diagram.rb +144 -0
- data/lib/agentilda/documentation.rb +429 -0
- data/lib/agentilda/executor.rb +539 -0
- data/lib/agentilda/feature.rb +253 -0
- data/lib/agentilda/frontmatter.rb +36 -0
- data/lib/agentilda/github.rb +160 -0
- data/lib/agentilda/index.rb +206 -0
- data/lib/agentilda/keyboard.rb +88 -0
- data/lib/agentilda/linear/api.rb +220 -0
- data/lib/agentilda/linear/attribution.rb +185 -0
- data/lib/agentilda/linear/fuzzy.rb +68 -0
- data/lib/agentilda/linear/import.rb +298 -0
- data/lib/agentilda/linear/issue.rb +184 -0
- data/lib/agentilda/linear/mapping.rb +115 -0
- data/lib/agentilda/linear/push.rb +190 -0
- data/lib/agentilda/linear/survey.rb +173 -0
- data/lib/agentilda/linear/unit.rb +274 -0
- data/lib/agentilda/linear.rb +42 -0
- data/lib/agentilda/markdown.rb +56 -0
- data/lib/agentilda/ordinal.rb +90 -0
- data/lib/agentilda/progress_log.rb +122 -0
- data/lib/agentilda/publisher.rb +172 -0
- data/lib/agentilda/pull_request.rb +213 -0
- data/lib/agentilda/reporter.rb +175 -0
- data/lib/agentilda/resync.rb +358 -0
- data/lib/agentilda/roster.rb +110 -0
- data/lib/agentilda/runner.rb +456 -0
- data/lib/agentilda/state_machine.rb +355 -0
- data/lib/agentilda/status.rb +280 -0
- data/lib/agentilda/tally.rb +169 -0
- data/lib/agentilda/transcript.rb +435 -0
- data/lib/agentilda/tree.rb +77 -0
- data/lib/agentilda/ui.rb +681 -0
- data/lib/agentilda/unblocker.rb +207 -0
- data/lib/agentilda/version.rb +10 -0
- data/lib/agentilda/viewer.rb +60 -0
- data/lib/agentilda/worktree.rb +211 -0
- data/lib/agentilda.rb +155 -0
- data/lib/dry/cli/banner.rb +293 -0
- metadata +349 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# `agentilda states` — the state machine as a terminal picture.
|
|
5
|
+
#
|
|
6
|
+
# {Documentation} already renders this machine as a Markdown table and a
|
|
7
|
+
# mermaid block, for a browser. This is the same machine, walked the same
|
|
8
|
+
# way, shaped for a terminal instead: unicode arrows, no browser required.
|
|
9
|
+
#
|
|
10
|
+
# Every section below is read off {StateMachine} and {STATUSES} rather than
|
|
11
|
+
# drawn by hand, for the reason {Documentation} gives for doing the same
|
|
12
|
+
# thing: three hand-maintained pictures of this machine have already
|
|
13
|
+
# drifted apart once, and a fourth is not the fix.
|
|
14
|
+
class Diagram
|
|
15
|
+
include UI
|
|
16
|
+
|
|
17
|
+
# Drawn between two states on the same line.
|
|
18
|
+
ARROW = "──▶"
|
|
19
|
+
|
|
20
|
+
# @return [String] the whole diagram, newline-terminated
|
|
21
|
+
def render
|
|
22
|
+
[
|
|
23
|
+
heading,
|
|
24
|
+
spine_section,
|
|
25
|
+
rejoin_section,
|
|
26
|
+
other_section,
|
|
27
|
+
terminal_section,
|
|
28
|
+
family_section
|
|
29
|
+
].compact.join("\n\n") + "\n"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# @return [String]
|
|
35
|
+
def heading
|
|
36
|
+
paint("Spec → Plan → Build — the state machine", :bold) + "\n" +
|
|
37
|
+
paint("(derived from lib/agentilda/state_machine.rb — `agentilda docs` for the Markdown form)",
|
|
38
|
+
:bright_black)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The path a bare `promote!` walks: {StateMachine::SPINE}, followed from
|
|
42
|
+
# `:new` until a state has no entry left to follow.
|
|
43
|
+
#
|
|
44
|
+
# @return [String]
|
|
45
|
+
def spine_section
|
|
46
|
+
section("MAIN SPINE", "a bare `promote` walks this path",
|
|
47
|
+
[" " + spine_chain.map { |key| node(key) }.join(" #{ARROW} ")])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# {StateMachine::SPINE} entries whose source never appears on the walk
|
|
51
|
+
# above — states that sit off the spine but whose default `promote!`
|
|
52
|
+
# rejoins it, e.g. a rejected pull request resubmitted for review.
|
|
53
|
+
#
|
|
54
|
+
# @return [String, nil]
|
|
55
|
+
def rejoin_section
|
|
56
|
+
rows = StateMachine::SPINE
|
|
57
|
+
.except(*spine_chain)
|
|
58
|
+
.map { |from, to| " #{node(from)} #{ARROW} #{node(to)}" }
|
|
59
|
+
|
|
60
|
+
section("REJOINING THE SPINE", "off-spine states whose default promotion lands back on it", rows)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Everything {StateMachine} allows that the two sections above do not
|
|
64
|
+
# already draw: blocking, deferring, discarding, review outcomes,
|
|
65
|
+
# rollback — read off {StateMachine.outbound} state by state, in
|
|
66
|
+
# {STATUSES} order, so nothing the machine permits is left undrawn.
|
|
67
|
+
#
|
|
68
|
+
# @return [String, nil]
|
|
69
|
+
def other_section
|
|
70
|
+
drawn = StateMachine::SPINE.to_a
|
|
71
|
+
|
|
72
|
+
groups = STATUSES.filter_map { |s|
|
|
73
|
+
targets = StateMachine.outbound(s.key).reject { |to| drawn.include?([s.key, to]) }
|
|
74
|
+
[s.key, targets] unless targets.empty?
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
section("OTHER TRANSITIONS", "everything else the machine permits", groups.flat_map { |key, targets|
|
|
78
|
+
fan_out(key, targets)
|
|
79
|
+
})
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [String, nil]
|
|
83
|
+
def terminal_section
|
|
84
|
+
rows = STATUSES.select(&:terminal?).map { |s| " #{node(s.key)}" }
|
|
85
|
+
section("TERMINAL", "nothing follows these", rows)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# {StateMachine::FAMILIES} share an invariant on purpose, and a folder's
|
|
89
|
+
# contents alone cannot tell their members apart — worth saying next to a
|
|
90
|
+
# diagram, or the members read like an oversight rather than a rule.
|
|
91
|
+
#
|
|
92
|
+
# @return [String, nil]
|
|
93
|
+
def family_section
|
|
94
|
+
rows = StateMachine::FAMILIES.map { |members| " #{members.map { |key| node(key) }.join(" / ")}" }
|
|
95
|
+
|
|
96
|
+
section("LOOK-ALIKE FAMILIES", "same contents on disk; only the folder name tells them apart", rows)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# `:new`, followed through {StateMachine::SPINE} until nothing follows.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Symbol>]
|
|
102
|
+
def spine_chain
|
|
103
|
+
@spine_chain ||= [].tap { |chain|
|
|
104
|
+
key = :new
|
|
105
|
+
while key
|
|
106
|
+
chain << key
|
|
107
|
+
key = StateMachine::SPINE[key]
|
|
108
|
+
end
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# One state and its outbound arrows, drawn as a small tree so several
|
|
113
|
+
# targets from one source read as a fan-out rather than as unrelated
|
|
114
|
+
# lines that happen to repeat the source.
|
|
115
|
+
#
|
|
116
|
+
# @param key [Symbol]
|
|
117
|
+
# @param targets [Array<Symbol>]
|
|
118
|
+
# @return [Array<String>]
|
|
119
|
+
def fan_out(key, targets)
|
|
120
|
+
lines = targets.each_with_index.map { |to, i|
|
|
121
|
+
connector = (i == targets.size - 1) ? "└─▶" : "├─▶"
|
|
122
|
+
" #{connector} #{node(to)}"
|
|
123
|
+
}
|
|
124
|
+
[" #{node(key)}", *lines]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param key [Symbol]
|
|
128
|
+
# @return [String]
|
|
129
|
+
def node(key)
|
|
130
|
+
status = STATUS_BY_KEY.fetch(key)
|
|
131
|
+
paint("#{status.emoji} #{status.label}", :bold)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param title [String]
|
|
135
|
+
# @param subtitle [String]
|
|
136
|
+
# @param rows [Array<String>]
|
|
137
|
+
# @return [String, nil] nil when there is nothing to show, so {#render} can drop it
|
|
138
|
+
def section(title, subtitle, rows)
|
|
139
|
+
return nil if rows.empty?
|
|
140
|
+
|
|
141
|
+
"#{paint(title, :bold)} #{paint("(#{subtitle})", :bright_black)}\n\n#{rows.join("\n")}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
# Generates the conventions document from the state machine itself.
|
|
5
|
+
#
|
|
6
|
+
# The tables and the diagram are DERIVED — from {STATUSES}, {StateMachine.inbound}
|
|
7
|
+
# and {Ordinal} — so they cannot drift from the tool the way three
|
|
8
|
+
# hand-maintained copies of the status table already did.
|
|
9
|
+
#
|
|
10
|
+
# The prose is not derived, because a machine cannot invent the reasoning: why
|
|
11
|
+
# the tool refuses rather than guesses, why a retroactive plan gets its own
|
|
12
|
+
# number shape, what happens to this scheme if a real issue tracker arrives.
|
|
13
|
+
# That lives in the heredocs below, in one place, and is emitted alongside the
|
|
14
|
+
# generated parts.
|
|
15
|
+
class Documentation
|
|
16
|
+
# @return [String] the whole document
|
|
17
|
+
def render
|
|
18
|
+
[
|
|
19
|
+
header,
|
|
20
|
+
numbering,
|
|
21
|
+
starting,
|
|
22
|
+
status_table,
|
|
23
|
+
blocking,
|
|
24
|
+
transitions,
|
|
25
|
+
diagram,
|
|
26
|
+
files_section,
|
|
27
|
+
pull_requests,
|
|
28
|
+
footer
|
|
29
|
+
].join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# @return [String]
|
|
35
|
+
def header
|
|
36
|
+
<<~MARKDOWN
|
|
37
|
+
# Spec → Plan → Build
|
|
38
|
+
|
|
39
|
+
> [!IMPORTANT]
|
|
40
|
+
> **This file is auto generated.**
|
|
41
|
+
>
|
|
42
|
+
> To regenerate it, run `agentilda docs` (which by the default
|
|
43
|
+
> writes to ${HOME}/.agents/context/workflow.md`). To override
|
|
44
|
+
> the destination, use the -o | --output <file> option.
|
|
45
|
+
>
|
|
46
|
+
> After changing the state machine. Editing it by hand puts it back into the
|
|
47
|
+
> condition it was written to end: three copies of the same table, quietly
|
|
48
|
+
> disagreeing.
|
|
49
|
+
|
|
50
|
+
Every project keeps its plans in a `#{Agentilda::PLANS_DIR}/` directory at its root.
|
|
51
|
+
Each medium-to-large feature gets one folder, and the folder's **name is its
|
|
52
|
+
state**: the number identifies it forever, the emoji says what phase it is in,
|
|
53
|
+
and the slug says what it is.
|
|
54
|
+
|
|
55
|
+
There are three phases, and each one has a file that proves it happened:
|
|
56
|
+
|
|
57
|
+
| Phase | State | The file that proves it |
|
|
58
|
+
| :---- | :---- | :---------------------- |
|
|
59
|
+
| **spec** | #{status(:new).emoji} #{status(:new).label} | `spec.md` |
|
|
60
|
+
| **plan** | #{status(:planned).emoji} #{status(:planned).label} | `plan.md` |
|
|
61
|
+
| **build** | #{status(:building).emoji} → #{status(:ready_for_review).emoji} → #{status(:in_review).emoji} → #{status(:approved).emoji} | `pull-requests.md` |
|
|
62
|
+
|
|
63
|
+
Those files are not paperwork. They are what the tool checks: a folder may not
|
|
64
|
+
claim a phase whose file is missing, and `agentilda resync dirs` renames
|
|
65
|
+
any folder whose emoji its contents do not support.
|
|
66
|
+
|
|
67
|
+
MARKDOWN
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [String]
|
|
71
|
+
def numbering
|
|
72
|
+
<<~MARKDOWN
|
|
73
|
+
## The number
|
|
74
|
+
|
|
75
|
+
A plan's number is its identity. It is set once, when the folder is created,
|
|
76
|
+
and never changes: branch names, pull request titles and every
|
|
77
|
+
`pull-requests.md` join on it, and renumbering breaks all of them silently.
|
|
78
|
+
|
|
79
|
+
The shape is always `NNN.MM`:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
#{Agentilda::PLANS_DIR}/000.00-#{status(:new).emoji}-initial-spec
|
|
83
|
+
#{Agentilda::PLANS_DIR}/001.00-#{status(:approved).emoji}-dev-foundation
|
|
84
|
+
#{Agentilda::PLANS_DIR}/001.01-#{status(:approved).emoji}-schedule-k1-backfill <- shipped between 001 and 002,
|
|
85
|
+
#{Agentilda::PLANS_DIR}/002.00-#{status(:planned).emoji}-tenancy-households specified afterwards
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- **`NNN` counts from `000`.** The first plan of a project is `000.00`; after
|
|
89
|
+
that it is the highest major plus one, zero-padded to three digits.
|
|
90
|
+
- **`MM` is `00` for an ordinary plan** — one specified before it was built.
|
|
91
|
+
- **`MM` from `01` to `99` marks a retroactive plan**: work that shipped with no
|
|
92
|
+
specification, documented after the fact. `agentilda create --after 001`
|
|
93
|
+
takes the next free slot in the gap after 001.
|
|
94
|
+
|
|
95
|
+
`001.01` is a **sibling of 001 that arrived later, not a part of 001**. The dot
|
|
96
|
+
reads as containment in almost every other numbering scheme, and here it does
|
|
97
|
+
not — which is worth saying wherever the scheme is described, because the
|
|
98
|
+
containment reading is the one a new reader brings.
|
|
99
|
+
|
|
100
|
+
Two digits, always. One would sort into the middle of the two-digit range —
|
|
101
|
+
`001.09` < `001.1` < `001.10` — so a single mixed-width folder silently
|
|
102
|
+
reorders the index. Two digits also retire the question of running out: 99
|
|
103
|
+
slots per gap, against a gap that closes the moment the next plan is created.
|
|
104
|
+
|
|
105
|
+
> [!NOTE]
|
|
106
|
+
> Padding every plan to `NNN.MM` is a deliberate choice, and it costs something.
|
|
107
|
+
> A bare `018` next to `018.01` would have told you at a glance which plan was
|
|
108
|
+
> specified in advance and which was written afterwards. With uniform padding
|
|
109
|
+
> that distinction is no longer readable from the number alone — it is carried
|
|
110
|
+
> by `MM > 0`, which you have to know to look for. What padding buys is
|
|
111
|
+
> alignment and one shape to parse everywhere.
|
|
112
|
+
|
|
113
|
+
**A retroactive `spec.md` must open with a dated line saying so**, naming the
|
|
114
|
+
pull requests it describes. It documents what exists; it does not pretend to
|
|
115
|
+
have decided anything in advance. Anchor by *when the work merged*, not by what
|
|
116
|
+
it is about — compare merge dates against folder creation dates
|
|
117
|
+
(`git log --diff-filter=A`). Anchoring by topic invites an argument nobody can
|
|
118
|
+
settle, and the number is a slot, not a claim about subject matter.
|
|
119
|
+
|
|
120
|
+
MARKDOWN
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [String]
|
|
124
|
+
def starting
|
|
125
|
+
headings = Agentilda::Brief::HEADINGS.map { |h| "- `## #{h}`" }.join("\n")
|
|
126
|
+
|
|
127
|
+
<<~MARKDOWN
|
|
128
|
+
## Starting a feature that does not exist yet
|
|
129
|
+
|
|
130
|
+
`agentilda create <two to five words>` mints the folder at #{status(:new).emoji} #{status(:new).label}
|
|
131
|
+
and, for a genuinely new feature, scaffolds `spec.md` with a title and four
|
|
132
|
+
headings, verbatim:
|
|
133
|
+
|
|
134
|
+
#{headings}
|
|
135
|
+
|
|
136
|
+
`#{Agentilda::Brief::AGENT_NAME}` — a half-agent: it runs only inside `create` and
|
|
137
|
+
holds no roster entry — then makes a best-effort attempt at each from what
|
|
138
|
+
the project already has on disk — its own docs, `#{Agentilda::Brief::BACKLOG_FILE}` when
|
|
139
|
+
the project keeps one, anything already downloaded or already built that
|
|
140
|
+
bears on the topic — and opens the result for a human to finish.
|
|
141
|
+
`--no-draft` skips the attempt and leaves the headings bare; `--no-open`
|
|
142
|
+
leaves the file for you to open.
|
|
143
|
+
|
|
144
|
+
**Nothing else belongs in that first pass.** It writes no `## Goals`, no
|
|
145
|
+
`## Non-Goals`, no conclusions — deciding those before `leah-researcher` and
|
|
146
|
+
`yoda-writer` have looked is choosing the answer before the research runs.
|
|
147
|
+
Above all, it writes no heading beginning with the word "Research": that
|
|
148
|
+
heading is not decoration, it is the #{status(:researched).emoji} #{status(:researched).label} invariant, so writing
|
|
149
|
+
one — even empty — flips the folder's state out from under whoever reads it
|
|
150
|
+
next, and the research nobody did gets skipped rather than assigned.
|
|
151
|
+
|
|
152
|
+
This is only for work that does not exist yet. `create --after <plan> --prs
|
|
153
|
+
<n,...>` is the other path — the work already shipped, so `yoda-writer`
|
|
154
|
+
reconstructs the specification from the pull requests instead of guessing at
|
|
155
|
+
a feature that has no facts yet to guess from.
|
|
156
|
+
|
|
157
|
+
MARKDOWN
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [String]
|
|
161
|
+
def status_table
|
|
162
|
+
rows = Agentilda::STATUSES.map do |s|
|
|
163
|
+
required = s.requires.empty? ? "—" : s.requires.map { |f| "`#{f}`" }.join(", ")
|
|
164
|
+
"| #{s.emoji} | **#{s.label}** | `#{s.key}` | #{required} | #{s.note} |"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
<<~MARKDOWN
|
|
168
|
+
## The states
|
|
169
|
+
|
|
170
|
+
| Symbol | Meaning | Key | Files required | Description |
|
|
171
|
+
| :----: | :------ | :-- | :------------- | :---------- |
|
|
172
|
+
#{rows.join("\n")}
|
|
173
|
+
|
|
174
|
+
"Files required" is a **minimum**, not an exact match: a #{status(:new).emoji} folder that
|
|
175
|
+
has grown a `plan.md` still satisfies #{status(:new).emoji}, and is #{status(:planned).emoji} anyway. That is
|
|
176
|
+
why `resync dirs` moves a folder to the furthest state its contents justify
|
|
177
|
+
rather than only fixing outright lies.
|
|
178
|
+
|
|
179
|
+
Some states share their requirements on purpose, and are told apart only by the
|
|
180
|
+
folder name. #{status(:blocked).emoji} #{status(:blocked).label} and #{status(:product_blocked).emoji} #{status(:product_blocked).label} both mean "a human must
|
|
181
|
+
decide before this can move"; *which* human is recorded nowhere but the emoji.
|
|
182
|
+
#{StateMachine::FAMILIES.last.map { |k| status(k).emoji }.join(" ")} all mean "the work exists and pull requests are open"; whether
|
|
183
|
+
anyone has started reviewing is written down nowhere either.
|
|
184
|
+
|
|
185
|
+
So nothing re-derives one of them from a folder's contents — otherwise every
|
|
186
|
+
#{status(:blocked).emoji} would silently become #{status(:product_blocked).emoji} the first time anything resynced. A folder
|
|
187
|
+
falling back into that group from outside lands on its weakest member,
|
|
188
|
+
#{status(StateMachine::FAMILIES.last.first).emoji} #{status(StateMachine::FAMILIES.last.first).label}, because that is all its contents can prove.
|
|
189
|
+
|
|
190
|
+
#{merged_note}
|
|
191
|
+
|
|
192
|
+
MARKDOWN
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @return [String]
|
|
196
|
+
def merged_note
|
|
197
|
+
"🟣 Merged is deliberately **not** a folder state. It describes a pull request, " \
|
|
198
|
+
"and a folder that claimed it would be claiming a pull request's condition as its own."
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Why this is prose rather than another derived table: the mechanism is a
|
|
202
|
+
# rule about the *contents* of one file, and the only part of it a program
|
|
203
|
+
# can see is the invariant. Everything that makes the rule safe — a human
|
|
204
|
+
# types the command, an agent never answers anything — is a decision, and
|
|
205
|
+
# decisions belong in sentences.
|
|
206
|
+
#
|
|
207
|
+
# @return [String]
|
|
208
|
+
def blocking
|
|
209
|
+
settled = StateMachine::SETTLED.map { |k| status(k).emoji }.join(" ")
|
|
210
|
+
|
|
211
|
+
<<~MARKDOWN
|
|
212
|
+
## When a plan is stopped, and how it starts again
|
|
213
|
+
|
|
214
|
+
#{status(:blocked).emoji} #{status(:blocked).label} and #{status(:product_blocked).emoji} #{status(:product_blocked).label} mean an agent hit a question that was not
|
|
215
|
+
its to answer and wrote `blocked.md` instead of guessing past it. Both belong to
|
|
216
|
+
the settled group `agentilda run` leaves alone (#{settled}), so the loop
|
|
217
|
+
never offers a stopped plan to anybody. A loop that could move one would make
|
|
218
|
+
the state mean nothing.
|
|
219
|
+
|
|
220
|
+
Questions are numbered `B1`, `B2`, and that numbering is how the file gets
|
|
221
|
+
referenced in conversation and in pull requests. **`blocked.md` holds open
|
|
222
|
+
questions and nothing else.** That is what the #{status(:blocked).emoji}/#{status(:product_blocked).emoji} invariant reads, so a
|
|
223
|
+
file naming no question justifies neither state and `resync dirs` renames the
|
|
224
|
+
folder out of it.
|
|
225
|
+
|
|
226
|
+
Which is how a block drains, in pieces:
|
|
227
|
+
|
|
228
|
+
1. A human writes each answer under `## Answers` as it arrives, dated and
|
|
229
|
+
attributed to whoever decided.
|
|
230
|
+
1. `agentilda unblock NNN --commit` hands the folder to `lando-broker`.
|
|
231
|
+
Nothing else reaches it: answers arriving is not a fact the tool can
|
|
232
|
+
observe, so a human running the command *is* the signal.
|
|
233
|
+
1. `lando-broker` folds each answered question into the document that question
|
|
234
|
+
was stopping — `spec.md` when the answer changes what we are building or
|
|
235
|
+
why, `plan.md` when it changes how or in what order — and deletes the
|
|
236
|
+
question and its answer from `blocked.md`.
|
|
237
|
+
1. When the last question goes, the file goes with it, and the folder leaves
|
|
238
|
+
#{status(:blocked).emoji} on the resync that follows.
|
|
239
|
+
|
|
240
|
+
An entry that names no decider, carries no date, or restates the options
|
|
241
|
+
instead of choosing one is not an answer, and is left exactly where it is.
|
|
242
|
+
So is any question nobody has answered yet: two answers out of five is a
|
|
243
|
+
successful run, and the plan stays stopped on the other three, which is true.
|
|
244
|
+
`lando-broker` never answers a question itself, and never retires one for
|
|
245
|
+
being stale — dropping a question is a human's call, and it makes the plan
|
|
246
|
+
#{status(:deferred).emoji} #{status(:deferred).label} or #{status(:discarded).emoji} #{status(:discarded).label}, not quietly shorter.
|
|
247
|
+
|
|
248
|
+
MARKDOWN
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @return [String]
|
|
252
|
+
def transitions
|
|
253
|
+
rows = Agentilda::STATUSES.map do |s|
|
|
254
|
+
out = StateMachine.outbound(s.key)
|
|
255
|
+
targets = out.empty? ? "_terminal_" : out.map { |k| status(k).emoji }.join(" ")
|
|
256
|
+
spine = StateMachine::SPINE[s.key]
|
|
257
|
+
"| #{s.emoji} #{s.label} | #{targets} | #{spine ? "#{status(spine).emoji} #{status(spine).label}" : "—"} |"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
<<~MARKDOWN
|
|
261
|
+
## Transitions
|
|
262
|
+
|
|
263
|
+
| From | May become | `promote` goes to |
|
|
264
|
+
| :--- | :--------- | :---------------- |
|
|
265
|
+
#{rows.join("\n")}
|
|
266
|
+
|
|
267
|
+
A bare promote walks the **spine** — spec → plan → build. Everything off it
|
|
268
|
+
(blocking, deferring, rejecting) has to be named explicitly. That is the whole
|
|
269
|
+
reason there is a machine here rather than a rename: a transition is refused
|
|
270
|
+
when the destination's requirements are not already met, and that refusal is
|
|
271
|
+
information — it means the phase has not actually happened yet.
|
|
272
|
+
|
|
273
|
+
MARKDOWN
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The picture is a hand-drawn PNG, not the mermaid block, because mermaid
|
|
277
|
+
# lays fifteen states out in a way nobody wants to read.
|
|
278
|
+
#
|
|
279
|
+
# The mermaid source is still emitted underneath it, and still derived from
|
|
280
|
+
# the machine — which is precisely what makes it the staleness check for a
|
|
281
|
+
# hand-drawn image. Regenerating this document rewrites that block from
|
|
282
|
+
# {STATUSES} and {StateMachine.inbound}, so a diff there means the PNG no
|
|
283
|
+
# longer matches. No diff there, no redraw.
|
|
284
|
+
#
|
|
285
|
+
# @return [String]
|
|
286
|
+
def diagram
|
|
287
|
+
edges = StateMachine.inbound.flat_map { |to, froms|
|
|
288
|
+
froms.map { |from| " #{from} --> #{to}" }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
<<~MARKDOWN
|
|
292
|
+
|
|
293
|
+
<details>
|
|
294
|
+
<summary>Mermaid source for the diagram above</summary>
|
|
295
|
+
|
|
296
|
+
```mermaid
|
|
297
|
+
stateDiagram-v2
|
|
298
|
+
direction LR
|
|
299
|
+
#{Agentilda::STATUSES.map { |s| " #{s.key} : #{s.emoji} #{s.label}" }.join("\n")}
|
|
300
|
+
|
|
301
|
+
#{edges.join("\n")}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
</details>
|
|
305
|
+
|
|
306
|
+
MARKDOWN
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# @return [String]
|
|
310
|
+
def files_section
|
|
311
|
+
known = Agentilda::STATUSES
|
|
312
|
+
.flat_map(&:requires)
|
|
313
|
+
.uniq
|
|
314
|
+
.map { |f| "| `#{f}` | #{holders_of(f)} |" }
|
|
315
|
+
|
|
316
|
+
<<~MARKDOWN
|
|
317
|
+
## Files allowed in a plan folder
|
|
318
|
+
|
|
319
|
+
| File | Required by |
|
|
320
|
+
| :--- | :---------- |
|
|
321
|
+
| `spec.md` | the specification; written first |
|
|
322
|
+
| `plan.md` | the execution plan; written from the spec |
|
|
323
|
+
#{known.reject { |r| r.start_with?("| `spec.md`", "| `plan.md`") }.join("\n")}
|
|
324
|
+
|
|
325
|
+
Nothing else belongs there. A folder holding notes, diagrams or scratch files
|
|
326
|
+
is a folder nobody can audit at a glance.
|
|
327
|
+
|
|
328
|
+
MARKDOWN
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# @param file [String]
|
|
332
|
+
# @return [String]
|
|
333
|
+
def holders_of(file)
|
|
334
|
+
Agentilda::STATUSES
|
|
335
|
+
.select { |s| s.requires.include?(file) }
|
|
336
|
+
.map { |s| "#{s.emoji} #{s.label}" }
|
|
337
|
+
.join(", ")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# @return [String]
|
|
341
|
+
def pull_requests
|
|
342
|
+
<<~MARKDOWN
|
|
343
|
+
## Pull requests carry the number
|
|
344
|
+
|
|
345
|
+
A pull request that implements a plan says so in its title:
|
|
346
|
+
|
|
347
|
+
```
|
|
348
|
+
[003.00] Make the core deterministic and require as_of
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`pull-requests.md` is generated from these titles, so the prefix is the join key
|
|
352
|
+
between a pull request and a plan, not decoration. Name branches
|
|
353
|
+
`<user>/NNN.MM-slug` and the number carries itself from branch creation through
|
|
354
|
+
to a merged, squashed pull request with nobody having to remember it.
|
|
355
|
+
|
|
356
|
+
`agentilda resync prs` fills in missing prefixes. It reads the branch name
|
|
357
|
+
first and falls back to the diff only when that touches exactly one plan folder.
|
|
358
|
+
**It refuses rather than guessing.** A wrong number does not announce itself: it
|
|
359
|
+
files the work under a plan that did not do it, and leaves the plan that did
|
|
360
|
+
looking untouched.
|
|
361
|
+
|
|
362
|
+
### `[#{Agentilda::NO_PLAN_PREFIX}]` when there is no plan
|
|
363
|
+
|
|
364
|
+
Not every pull request implements a feature. Dependency bumps, CI configuration,
|
|
365
|
+
hotfixes and developer tooling implement no plan, and forcing a number onto them
|
|
366
|
+
produces a number chosen to satisfy the rule. Those are titled:
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
[#{Agentilda::NO_PLAN_PREFIX}] Bump json from 2.21.1 to 2.21.2
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
`#{Agentilda::NO_PLAN_PREFIX}` means **"this deliberately belongs to no specification"**, and it
|
|
373
|
+
exists so that "no plan" is *asserted* rather than merely absent. A title with no
|
|
374
|
+
prefix is ambiguous between "no plan applies" and "nobody looked".
|
|
375
|
+
|
|
376
|
+
`resync prs` will propose it, but marks every such title as **assumed** and never
|
|
377
|
+
applies one without you seeing it. Emitting it silently on a failed lookup would
|
|
378
|
+
launder "I could not tell" into "there is definitely none", which is the same
|
|
379
|
+
lie as guessing a number, told in the other direction.
|
|
380
|
+
|
|
381
|
+
### What does not deserve a retroactive plan
|
|
382
|
+
|
|
383
|
+
Most unmatched pull requests. The test is whether **somebody would need to read
|
|
384
|
+
it** — a capability with behaviour, an interface, or invariants that are not
|
|
385
|
+
obvious from the code. "Fix a typo", "remove dead code" and "bump a dependency"
|
|
386
|
+
are `[#{Agentilda::NO_PLAN_PREFIX}]` and always were. Backfilling those produces an index that is
|
|
387
|
+
longer without being more informative, which makes the real plans harder to find.
|
|
388
|
+
|
|
389
|
+
MARKDOWN
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# @return [String]
|
|
393
|
+
def footer
|
|
394
|
+
<<~MARKDOWN
|
|
395
|
+
## If a real issue tracker arrives, this scheme retires
|
|
396
|
+
|
|
397
|
+
This numbering is homegrown because there is nothing else to join on. If the
|
|
398
|
+
work moves to Linear or Jira, **the issue key replaces it**: `[EQL-142] <title>`
|
|
399
|
+
in pull request titles, `EQL-142-<status>-<slug>` for the folder, and the issue
|
|
400
|
+
becomes the thing `pull-requests.md` is generated against.
|
|
401
|
+
|
|
402
|
+
Recording that matters more than it looks. A numbering scheme with no stated
|
|
403
|
+
exit becomes permanent by default: it accretes tooling, the tooling accretes
|
|
404
|
+
rules, and by the time a real tracker shows up, migrating is a project rather
|
|
405
|
+
than a decision. The exit is cheap only while it is written down and unbuilt.
|
|
406
|
+
|
|
407
|
+
Two things to hold to when that day comes:
|
|
408
|
+
|
|
409
|
+
- **Existing numbers are not rewritten.** A plan's number is its identity and
|
|
410
|
+
merged pull request titles are immutable history. `000` stays `000` forever and
|
|
411
|
+
new plans start taking issue keys. A mixed index is ugly for a while and honest
|
|
412
|
+
permanently, which beats a renumbering that breaks every link that ever pointed
|
|
413
|
+
at a plan.
|
|
414
|
+
- **Do not teach the tooling to accept issue keys before the tracker exists.** A
|
|
415
|
+
validator that accepts `[EQL-142]` with nothing to check it against is a guard
|
|
416
|
+
that passes anything shaped like an answer, which is worse than one that fails
|
|
417
|
+
loudly on first use.
|
|
418
|
+
|
|
419
|
+
______________________________________________________________________
|
|
420
|
+
|
|
421
|
+
Generated by `agentilda docs` — version #{Agentilda::VERSION}.
|
|
422
|
+
MARKDOWN
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# @param key [Symbol]
|
|
426
|
+
# @return [Agentilda::Status]
|
|
427
|
+
def status(key) = Agentilda::STATUS_BY_KEY.fetch(key)
|
|
428
|
+
end
|
|
429
|
+
end
|