poetry-agent 0.0.2
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/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
- data/app/javascript/poetry/agent/adapter.js +77 -0
- data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
- data/app/javascript/poetry/agent/index.js +41 -0
- data/app/javascript/poetry/agent/stream_actions.js +65 -0
- data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
- data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
- data/config/controllers_manifest.json +82 -0
- data/config/importmap.rb +10 -0
- data/exe/poetry-agent +28 -0
- data/lib/poetry/agent/a2ui/catalog.rb +289 -0
- data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
- data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
- data/lib/poetry/agent/a2ui/checks.rb +45 -0
- data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
- data/lib/poetry/agent/a2ui/expression.rb +175 -0
- data/lib/poetry/agent/a2ui/functions.rb +417 -0
- data/lib/poetry/agent/a2ui/markdown.rb +63 -0
- data/lib/poetry/agent/a2ui/pointer.rb +113 -0
- data/lib/poetry/agent/a2ui/protocol.rb +12 -0
- data/lib/poetry/agent/a2ui/renderer.rb +242 -0
- data/lib/poetry/agent/a2ui/session.rb +302 -0
- data/lib/poetry/agent/a2ui/streams.rb +82 -0
- data/lib/poetry/agent/a2ui/surface.rb +352 -0
- data/lib/poetry/agent/a2ui.rb +48 -0
- data/lib/poetry/agent/agui/client.rb +69 -0
- data/lib/poetry/agent/agui/json_patch.rb +137 -0
- data/lib/poetry/agent/agui/relay.rb +105 -0
- data/lib/poetry/agent/agui/run_input.rb +83 -0
- data/lib/poetry/agent/agui/sse.rb +97 -0
- data/lib/poetry/agent/agui/transcript.rb +540 -0
- data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
- data/lib/poetry/agent/agui.rb +87 -0
- data/lib/poetry/agent/config.rb +49 -0
- data/lib/poetry/agent/engine.rb +37 -0
- data/lib/poetry/agent/mcp/bundled.rb +54 -0
- data/lib/poetry/agent/mcp/http.rb +89 -0
- data/lib/poetry/agent/mcp/server.rb +962 -0
- data/lib/poetry/agent/version.rb +8 -0
- data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
- data/lib/poetry/agent/webmcp.rb +37 -0
- data/lib/poetry/agent.rb +66 -0
- data/lib/poetry-agent.rb +4 -0
- metadata +117 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Agent
|
|
7
|
+
# The MCP server projecting the component contract
|
|
8
|
+
# over Model Context Protocol so an agent in Claude Code / Cursor queries
|
|
9
|
+
# the LIVE registry and runs the linter as a tool. Thin - it projects the
|
|
10
|
+
# surfaces already built (Registry + LlmsText + Check), never a second
|
|
11
|
+
# source. Read-only, progressive-disclosure (brief|detailed|full), and
|
|
12
|
+
# verdict-returning (check returns per-finding pass/fail).
|
|
13
|
+
#
|
|
14
|
+
# Two transports, one server: newline-delimited JSON-RPC 2.0 over stdio
|
|
15
|
+
# (the `poetry-agent` exe; own the supply chain - no MCP SDK dependency)
|
|
16
|
+
# and POST JSON-RPC over HTTP ({HTTP}, for the same-origin `/mcp` mount
|
|
17
|
+
# in-page bridges read). {Server#handle} is a pure request->response
|
|
18
|
+
# function (testable without either transport); {Server#serve} is the
|
|
19
|
+
# stdio loop; {Bundled} is the one assembly both transports share.
|
|
20
|
+
#
|
|
21
|
+
# v1 is the read/verify surface. The heavier roadmap - verify_screen
|
|
22
|
+
# running the eval gate array, component:// artifact resources, tag
|
|
23
|
+
# browsing, SSE streaming - is maturity-gated and NOT in this cut.
|
|
24
|
+
module MCP
|
|
25
|
+
# The MCP protocol revision this server negotiates.
|
|
26
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
27
|
+
# The serverInfo payload returned by the initialize handshake.
|
|
28
|
+
SERVER_INFO = { "name" => "poetry-agent", "version" => Poetry::Agent::VERSION }.freeze
|
|
29
|
+
|
|
30
|
+
# The tool roster the server advertises (tools/list): MCP Tool-shaped
|
|
31
|
+
# definitions, read-only by construction.
|
|
32
|
+
TOOLS = [
|
|
33
|
+
{
|
|
34
|
+
"name" => "compose",
|
|
35
|
+
"description" => "CALL THIS FIRST for every brief, before writing any ERB. Give it the task " \
|
|
36
|
+
"text; it routes to the right starting point: a matching vetted block " \
|
|
37
|
+
"(returns the full source to adapt in place - the known winning path for " \
|
|
38
|
+
"pages) or the matching components for single-component work. Composing a " \
|
|
39
|
+
"page from scratch when a block matched is the known losing path.",
|
|
40
|
+
"inputSchema" => {
|
|
41
|
+
"type" => "object",
|
|
42
|
+
"properties" => {
|
|
43
|
+
"brief" => { "type" => "string",
|
|
44
|
+
"description" => "the task text, verbatim (a sentence is enough)" }
|
|
45
|
+
},
|
|
46
|
+
"required" => ["brief"]
|
|
47
|
+
},
|
|
48
|
+
"annotations" => { "readOnlyHint" => true }
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name" => "build_page",
|
|
52
|
+
"description" => "The GUIDED build for a whole SCREEN/page/dashboard - use this over " \
|
|
53
|
+
"compose when the job is a full page. Give the intent verbatim; it runs a " \
|
|
54
|
+
"five-step workflow and each call returns ONE step plus the exact next call: " \
|
|
55
|
+
"probe (host setup) -> plan (page architecture: section order, states, edge " \
|
|
56
|
+
"cases) -> direct (theme-derived creative direction) -> snippets (the block/" \
|
|
57
|
+
"components to start from) -> verify (the executable check gate). The workflow " \
|
|
58
|
+
"is DONE only on a PASS from check - a real verdict, not a claim. Omit step to " \
|
|
59
|
+
"start; the entry routes by your verb (review/harden stay read-only). " \
|
|
60
|
+
"Out-of-order steps are answered, never refused.",
|
|
61
|
+
"inputSchema" => {
|
|
62
|
+
"type" => "object",
|
|
63
|
+
"properties" => {
|
|
64
|
+
"intent" => { "type" => "string",
|
|
65
|
+
"description" => "the page you are building, verbatim (a sentence is enough)" },
|
|
66
|
+
"step" => { "type" => "string", "enum" => %w[probe plan direct snippets verify],
|
|
67
|
+
"description" => "the workflow step to run; omit to start at the routed entry" },
|
|
68
|
+
"source" => { "type" => "string",
|
|
69
|
+
"description" => "verify step only: the ERB you built, to run through check" }
|
|
70
|
+
},
|
|
71
|
+
"required" => ["intent"]
|
|
72
|
+
},
|
|
73
|
+
"annotations" => { "readOnlyHint" => true }
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name" => "list_components",
|
|
77
|
+
"description" => "List every poetry component (name, helper, one-line surface). Start here " \
|
|
78
|
+
"AFTER compose has routed the brief; then describe_component for the full " \
|
|
79
|
+
"contract.",
|
|
80
|
+
"inputSchema" => { "type" => "object", "properties" => {} },
|
|
81
|
+
"annotations" => { "readOnlyHint" => true }
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name" => "describe_component",
|
|
85
|
+
"description" => "The contract for one component. detail: brief (summary), detailed " \
|
|
86
|
+
"(+ options/variants/slots), full (+ Stimulus wiring + agent rules).",
|
|
87
|
+
"inputSchema" => {
|
|
88
|
+
"type" => "object",
|
|
89
|
+
"properties" => {
|
|
90
|
+
"name" => { "type" => "string", "description" => "component name, e.g. button or command_dialog" },
|
|
91
|
+
"detail" => { "type" => "string", "enum" => %w[brief detailed full], "default" => "detailed" }
|
|
92
|
+
},
|
|
93
|
+
"required" => ["name"]
|
|
94
|
+
},
|
|
95
|
+
"annotations" => { "readOnlyHint" => true }
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name" => "check",
|
|
99
|
+
"description" => "Lint ERB source against the poetry contracts WITHOUT rendering - unknown " \
|
|
100
|
+
"components/options/variants/wiring, raw and arbitrary colors (inline styles too), the " \
|
|
101
|
+
"! modifier, fake buttons (an inert element with onclick or role=button), icon names, " \
|
|
102
|
+
"enum values, " \
|
|
103
|
+
"typed-slot props, helper/setter arity, yield-less blocks, setter keywords, " \
|
|
104
|
+
"required content blocks, required slots a call never set, and any-of " \
|
|
105
|
+
"contracts (Button's visible content, Command's accessible name). Returns a " \
|
|
106
|
+
"verdict and findings. Run this as the LAST action after the final edit - " \
|
|
107
|
+
"an edit after your last check is unverified.",
|
|
108
|
+
"inputSchema" => {
|
|
109
|
+
"type" => "object",
|
|
110
|
+
"properties" => { "source" => { "type" => "string", "description" => "the ERB template source" } },
|
|
111
|
+
"required" => ["source"]
|
|
112
|
+
},
|
|
113
|
+
"annotations" => { "readOnlyHint" => true }
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"name" => "list_blocks",
|
|
117
|
+
"description" => "List the vetted composed-screen blocks (name, title, composed components). " \
|
|
118
|
+
"compose(brief) routes to the right one automatically; browse here when " \
|
|
119
|
+
"you want the full catalog, then describe_block for source.",
|
|
120
|
+
"inputSchema" => { "type" => "object", "properties" => {} },
|
|
121
|
+
"annotations" => { "readOnlyHint" => true }
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"name" => "describe_block",
|
|
125
|
+
"description" => "One block's contract AND its full ERB source, ready to adapt - the " \
|
|
126
|
+
"boot-free equivalent of `bin/rails g poetry:block <name>`.",
|
|
127
|
+
"inputSchema" => {
|
|
128
|
+
"type" => "object",
|
|
129
|
+
"properties" => {
|
|
130
|
+
"name" => { "type" => "string", "description" => "block name, e.g. data-index or app-shell" }
|
|
131
|
+
},
|
|
132
|
+
"required" => ["name"]
|
|
133
|
+
},
|
|
134
|
+
"annotations" => { "readOnlyHint" => true }
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"name" => "list_recipes",
|
|
138
|
+
"description" => "List the installable recipes - multi-file payloads beyond components " \
|
|
139
|
+
"(skill bundles, scaffold template sets, screen slices) served by the " \
|
|
140
|
+
"poetry registry. Install one with `bin/rails g poetry:add <name>` or " \
|
|
141
|
+
"any shadcn-compatible client.",
|
|
142
|
+
"inputSchema" => { "type" => "object", "properties" => {} },
|
|
143
|
+
"annotations" => { "readOnlyHint" => true }
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"name" => "get_skill",
|
|
147
|
+
"description" => "A poetry Claude Code skill served at runtime - for hosts where " \
|
|
148
|
+
"the installed .claude/skills files are absent (hosted agents, sessions " \
|
|
149
|
+
"that never ran the generator). name: poetry (component usage, generated " \
|
|
150
|
+
"from this registry), poetry-design (page-composition taste), or " \
|
|
151
|
+
"poetry-component (authoring app-owned components). Returns " \
|
|
152
|
+
"SKILL.md plus the file index; pass file: to fetch one reference " \
|
|
153
|
+
"(e.g. references/forms.md). The installed skills are the same text - " \
|
|
154
|
+
"when .claude/skills/poetry exists, read it there instead.",
|
|
155
|
+
"inputSchema" => {
|
|
156
|
+
"type" => "object",
|
|
157
|
+
"properties" => {
|
|
158
|
+
"name" => { "type" => "string", "enum" => %w[poetry poetry-design poetry-component],
|
|
159
|
+
"default" => "poetry" },
|
|
160
|
+
"file" => { "type" => "string",
|
|
161
|
+
"description" => "one skill file, e.g. references/deciding.md " \
|
|
162
|
+
"(omit for SKILL.md + the file index)" }
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"annotations" => { "readOnlyHint" => true }
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"name" => "guidance",
|
|
169
|
+
"description" => "Curated composition guidance, per topic: 'deciding' = the " \
|
|
170
|
+
"which-component decision tree (interaction model first). The same " \
|
|
171
|
+
"text the installed usage skill carries - reach it here when the " \
|
|
172
|
+
"skill is not installed.",
|
|
173
|
+
"inputSchema" => {
|
|
174
|
+
"type" => "object",
|
|
175
|
+
"properties" => {
|
|
176
|
+
"topic" => { "type" => "string", "description" => "guidance topic; currently: deciding" }
|
|
177
|
+
},
|
|
178
|
+
"required" => ["topic"]
|
|
179
|
+
},
|
|
180
|
+
"annotations" => { "readOnlyHint" => true }
|
|
181
|
+
}
|
|
182
|
+
].freeze
|
|
183
|
+
|
|
184
|
+
# The server: constructed with the registry root (and the host's helper
|
|
185
|
+
# names, so check knows the group/provider helpers - though the
|
|
186
|
+
# registry's own "helpers" section now carries those boot-free).
|
|
187
|
+
# icon_names: the active icon set's names, so the check tool validates
|
|
188
|
+
# icon values by membership, not just shape. Everything read is the
|
|
189
|
+
# live committed registry.
|
|
190
|
+
#
|
|
191
|
+
# @example Serve the committed registry over stdio
|
|
192
|
+
# Poetry::Agent::MCP::Server.from_registry("registry").serve
|
|
193
|
+
class Server
|
|
194
|
+
# skills: skill name => a zero-arg callable returning the skill's
|
|
195
|
+
# {relative path => content} file map (the get_skill tool).
|
|
196
|
+
# Lazy because the usage skill is generated from the registry on
|
|
197
|
+
# first fetch - server boot stays instant.
|
|
198
|
+
def self.from_registry(root, helpers: nil, icon_names: nil, skills: {}, app_root: nil, recipes: [])
|
|
199
|
+
committed = Poetry::Core::Registry.committed(root)
|
|
200
|
+
catalog = Poetry::Core::Check::Catalog.new(committed.entries, helpers: helpers,
|
|
201
|
+
helper_entries: committed.helpers,
|
|
202
|
+
icon_names: icon_names,
|
|
203
|
+
helper_args: committed.helper_args)
|
|
204
|
+
new(entries: committed.entries, catalog: catalog, blocks: committed.blocks || {},
|
|
205
|
+
root: root, skills: skills, app_root: app_root, recipes: recipes)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# app_root: the HOST app directory (where `bundle exec poetry-agent`
|
|
209
|
+
# runs, i.e. Dir.pwd), so build_page's probe/direct steps can read
|
|
210
|
+
# the app's config/theme. nil => the host is not inspected and those
|
|
211
|
+
# steps degrade gracefully; the registry read (root) is unaffected.
|
|
212
|
+
# recipes: registry-item SUMMARIES (content-free) from the owning
|
|
213
|
+
# gem's RecipeItems projection - the exe passes them so this class
|
|
214
|
+
# stays poetry-ui-free.
|
|
215
|
+
def initialize(entries:, catalog:, blocks: {}, root: nil, skills: {}, app_root: nil, recipes: []) # rubocop:disable Metrics/ParameterLists
|
|
216
|
+
@entries = entries
|
|
217
|
+
@catalog = catalog
|
|
218
|
+
@blocks = blocks
|
|
219
|
+
@root = root
|
|
220
|
+
@skills = skills
|
|
221
|
+
@skill_files = {}
|
|
222
|
+
@app_root = app_root
|
|
223
|
+
@recipes = recipes
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# compose routing: the strong-match threshold, and the words
|
|
227
|
+
# too generic to signal a block - connectives, task verbs, and
|
|
228
|
+
# component-anatomy words (title/description/action) every brief
|
|
229
|
+
# uses regardless of scale.
|
|
230
|
+
STRONG_MATCH = 4
|
|
231
|
+
# build_page: the guided workflow's five steps, the themes
|
|
232
|
+
# its probe/direct steps sniff for, and the plan step's match floor
|
|
233
|
+
# (one curated archetype keyword = 2, so 2 is the weakest real hit).
|
|
234
|
+
STEPS = %w[probe plan direct snippets verify].freeze
|
|
235
|
+
# The theme roster build_page's direct step reads.
|
|
236
|
+
THEMES = %w[default vega nova mira rhea maia luma lyra sera].freeze
|
|
237
|
+
# The brief-word -> page-architecture routing table.
|
|
238
|
+
ARCHETYPE_MATCH = 2
|
|
239
|
+
# Tokens compose ignores when scoring a brief.
|
|
240
|
+
STOPWORDS = %w[
|
|
241
|
+
the a an and or with for of to in on at from into by over under this that it its as is are
|
|
242
|
+
be has have should must can will each per when where build create make add show include
|
|
243
|
+
using use page screen view ui app real realistic data
|
|
244
|
+
title titles description descriptions action actions
|
|
245
|
+
].to_set.freeze
|
|
246
|
+
|
|
247
|
+
# A JSON-RPC 2.0 request hash -> a response hash (or nil for a
|
|
248
|
+
# notification, which gets no reply).
|
|
249
|
+
#
|
|
250
|
+
# @param request [Hash] one parsed JSON-RPC 2.0 request
|
|
251
|
+
# @return [Hash, nil] the response hash, or nil for a notification
|
|
252
|
+
def handle(request)
|
|
253
|
+
id = request["id"]
|
|
254
|
+
case request["method"]
|
|
255
|
+
when "initialize" then result(id, initialize_result)
|
|
256
|
+
when "tools/list" then result(id, { "tools" => TOOLS })
|
|
257
|
+
when "tools/call" then result(id, call_tool(request["params"] || {}))
|
|
258
|
+
when %r{\Anotifications/} then nil
|
|
259
|
+
else error(id, -32_601, "method not found: #{request["method"]}")
|
|
260
|
+
end
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
error(id, -32_603, e.message)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# The thin stdio loop: newline-delimited JSON-RPC in, replies out.
|
|
266
|
+
# A malformed line yields a parse error, never a crashed server.
|
|
267
|
+
def serve(input: $stdin, output: $stdout)
|
|
268
|
+
input.each_line do |line|
|
|
269
|
+
line = line.strip
|
|
270
|
+
next if line.empty?
|
|
271
|
+
|
|
272
|
+
response =
|
|
273
|
+
begin
|
|
274
|
+
handle(JSON.parse(line))
|
|
275
|
+
rescue JSON::ParserError => e
|
|
276
|
+
error(nil, -32_700, "parse error: #{e.message}")
|
|
277
|
+
end
|
|
278
|
+
next unless response
|
|
279
|
+
|
|
280
|
+
output.puts(JSON.generate(response))
|
|
281
|
+
output.flush
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private
|
|
286
|
+
|
|
287
|
+
def initialize_result
|
|
288
|
+
{
|
|
289
|
+
"protocolVersion" => PROTOCOL_VERSION,
|
|
290
|
+
"serverInfo" => SERVER_INFO,
|
|
291
|
+
"capabilities" => { "tools" => {} }
|
|
292
|
+
}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def call_tool(params)
|
|
296
|
+
name = params["name"]
|
|
297
|
+
arguments = params["arguments"] || {}
|
|
298
|
+
text =
|
|
299
|
+
case name
|
|
300
|
+
when "compose" then compose(arguments)
|
|
301
|
+
when "build_page" then build_page(arguments)
|
|
302
|
+
when "list_components" then list_components
|
|
303
|
+
when "describe_component" then describe_component(arguments)
|
|
304
|
+
when "check" then check(arguments)
|
|
305
|
+
when "list_blocks" then list_blocks
|
|
306
|
+
when "describe_block" then describe_block(arguments)
|
|
307
|
+
when "list_recipes" then list_recipes
|
|
308
|
+
when "get_skill" then get_skill(arguments)
|
|
309
|
+
when "guidance" then guidance(arguments)
|
|
310
|
+
else return tool_content("unknown tool: #{name}", error: true)
|
|
311
|
+
end
|
|
312
|
+
tool_content(text)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# --- the tools (project the built surfaces) ---
|
|
316
|
+
|
|
317
|
+
def list_components
|
|
318
|
+
@entries.map do |path, entry|
|
|
319
|
+
interactive = entry["controllers"]&.any? ? " [interactive]" : ""
|
|
320
|
+
"- #{title(path)} (`#{helper(path)}`)#{interactive}: #{summary(entry)}"
|
|
321
|
+
end.join("\n")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def describe_component(arguments)
|
|
325
|
+
path = resolve(arguments["name"])
|
|
326
|
+
return "no such component: #{arguments["name"].inspect} - call list_components" unless path
|
|
327
|
+
|
|
328
|
+
entry = @entries.fetch(path)
|
|
329
|
+
detail = arguments.fetch("detail", "detailed")
|
|
330
|
+
lines = ["# #{title(path)} (`#{helper(path)}`)", summary(entry)]
|
|
331
|
+
lines.concat(surface_lines(entry)) if %w[detailed full].include?(detail)
|
|
332
|
+
lines.concat(full_lines(entry)) if detail == "full"
|
|
333
|
+
# The block back-reference: a screen containing this
|
|
334
|
+
# component should start from the vetted composition.
|
|
335
|
+
blocks = @blocks.select { |_name, block| block["components"].include?(title(path)) }.keys
|
|
336
|
+
if blocks.any?
|
|
337
|
+
lines << "- in blocks: #{blocks.join(", ")} - for a screen, start there (describe_block), " \
|
|
338
|
+
"not from scratch"
|
|
339
|
+
end
|
|
340
|
+
lines.join("\n")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def check(arguments)
|
|
344
|
+
findings = Poetry::Core::Check.lint(arguments["source"].to_s, catalog: @catalog)
|
|
345
|
+
errors = findings.count { |finding| finding.severity == :error }
|
|
346
|
+
verdict = errors.zero? ? "PASS" : "FAIL"
|
|
347
|
+
report = findings.map do |finding|
|
|
348
|
+
hint = finding.suggestion ? " (did you mean #{finding.suggestion}?)" : ""
|
|
349
|
+
"- [#{finding.severity}] line #{finding.line}: #{finding.message}#{hint}"
|
|
350
|
+
end
|
|
351
|
+
(["#{verdict} - #{errors} error(s), #{findings.length - errors} warning(s)"] + report).join("\n")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# The compose router: the unconditional first move. Measured runs
|
|
355
|
+
# proved advisory prose does not move behavior - the design skill
|
|
356
|
+
# fired in 24/31 arms and composition did not move, while the
|
|
357
|
+
# blocks surface (the one measured composition win, 21-9) sat
|
|
358
|
+
# untouched in 28/31. compose makes blocks the DEFAULT path: one
|
|
359
|
+
# call routes any brief either into a vetted block (source inline,
|
|
360
|
+
# nothing left to decide) or to the matching components. Scoring
|
|
361
|
+
# is deterministic lexical overlap - curated block keywords count
|
|
362
|
+
# double, title/description tokens count once, all over distinct
|
|
363
|
+
# stems (calibrated against the benchmark brief set: page-scale
|
|
364
|
+
# briefs route to their block, single-component briefs stay on the
|
|
365
|
+
# component path).
|
|
366
|
+
def compose(arguments)
|
|
367
|
+
brief = arguments["brief"].to_s.strip
|
|
368
|
+
return "compose needs the brief text - pass the task description verbatim.\n#{list_blocks}" if brief.empty?
|
|
369
|
+
|
|
370
|
+
tokens = brief_tokens(brief)
|
|
371
|
+
scored = @blocks.map { |name, entry| [name, entry, block_score(entry, tokens)] }
|
|
372
|
+
.sort_by { |name, _entry, score| [-score, name] }
|
|
373
|
+
components = matched_components(tokens)
|
|
374
|
+
best = scored.first
|
|
375
|
+
if best && best[2] >= STRONG_MATCH
|
|
376
|
+
block_route(scored, components)
|
|
377
|
+
else
|
|
378
|
+
component_route(scored, components)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Distinct stems (crude: trailing-s stripped), so "settings" and
|
|
383
|
+
# "setting" are ONE hit, not a double-counted pair.
|
|
384
|
+
def brief_tokens(brief)
|
|
385
|
+
stems = brief.downcase.scan(/[a-z0-9][a-z0-9_-]+/).map { |token| token.delete_suffix("s") }
|
|
386
|
+
stems.uniq.reject { |stem| stem.length < 3 || STOPWORDS.include?(stem) }.to_set
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Curated keywords count double; title/description tokens count
|
|
390
|
+
# once. Component names stay OUT of the corpus - a lone-button
|
|
391
|
+
# brief must not route to a block that happens to compose buttons.
|
|
392
|
+
# Distinct stem hits only - repetition is not signal.
|
|
393
|
+
def block_score(entry, tokens)
|
|
394
|
+
keywords = (entry["keywords"] || []).to_set { |word| word.delete_suffix("s") }
|
|
395
|
+
corpus = "#{entry["title"]} #{entry["description"]}"
|
|
396
|
+
corpus_tokens = corpus.downcase.scan(/[a-z0-9][a-z0-9_-]+/)
|
|
397
|
+
.to_set { |token| token.delete_suffix("s") }
|
|
398
|
+
tokens.sum do |token|
|
|
399
|
+
if keywords.include?(token) then 2
|
|
400
|
+
elsif corpus_tokens.include?(token) then 1
|
|
401
|
+
else 0
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Component titles fully present in the brief (every underscore part
|
|
407
|
+
# matched: date_picker needs both "date" and "picker").
|
|
408
|
+
def matched_components(tokens)
|
|
409
|
+
@entries.keys.map { |path| title(path) }.select do |name|
|
|
410
|
+
name.split("_").all? { |part| tokens.include?(part.delete_suffix("s")) }
|
|
411
|
+
end.sort.first(8)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def block_route(scored, components)
|
|
415
|
+
name, entry, score = scored.first
|
|
416
|
+
runners = scored.drop(1).select { |_n, _e, s| s.positive? }.first(2)
|
|
417
|
+
lines = ["STRONG BLOCK MATCH (score #{score}): start from `#{name}` and adapt it in place.", "",
|
|
418
|
+
"# #{entry["title"]} (`#{name}`)", entry["description"],
|
|
419
|
+
"Composes: #{entry["components"].join(", ")}.",
|
|
420
|
+
"In an app: `bin/rails g poetry:block #{name}` copies it into app/views/blocks/.",
|
|
421
|
+
"", "Source (adapt in place - replace the sample content, keep the structure):", "",
|
|
422
|
+
block_source(entry)]
|
|
423
|
+
if runners.any?
|
|
424
|
+
described = runners.map { |n, e, s| "`#{n}` (#{e["title"]}, score #{s})" }.join(", ")
|
|
425
|
+
lines << ""
|
|
426
|
+
lines << "Runners-up: #{described} - describe_block returns their source."
|
|
427
|
+
end
|
|
428
|
+
matched = components.any? ? " - matched here: #{components.join(", ")}" : ""
|
|
429
|
+
lines << "Fill the gaps with components (describe_component)#{matched}."
|
|
430
|
+
lines << "Finish with the check tool as the LAST action after your final edit."
|
|
431
|
+
lines.join("\n")
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def component_route(scored, components)
|
|
435
|
+
lines = ["No block covers this brief - component-scale work."]
|
|
436
|
+
lines << if components.any?
|
|
437
|
+
"Matched components: #{components.map { |name| "#{name} (`poetry_#{name}`)" }.join(", ")} - " \
|
|
438
|
+
"describe_component for the contracts."
|
|
439
|
+
else
|
|
440
|
+
"No component name matched either - list_components for the catalog."
|
|
441
|
+
end
|
|
442
|
+
if scored.any?
|
|
443
|
+
lines << ""
|
|
444
|
+
lines << "If this grows into a SCREEN, start from a block (describe_block for source):"
|
|
445
|
+
scored.each { |name, entry, _score| lines << "- #{name}: #{entry["title"]} - #{entry["description"]}" }
|
|
446
|
+
end
|
|
447
|
+
lines << "Finish with the check tool as the LAST action after your final edit."
|
|
448
|
+
lines.join("\n")
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def block_source(entry)
|
|
452
|
+
Pathname.new(@root).join(entry.fetch("template")).read
|
|
453
|
+
.sub(/\A<%#\s*poetry:block[^%]*%>\n?/, "").rstrip
|
|
454
|
+
rescue StandardError => e
|
|
455
|
+
"block source unavailable: #{e.message} - use `bin/rails g poetry:block` instead"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# --- build_page: the guided page workflow ---
|
|
459
|
+
#
|
|
460
|
+
# A stateless state machine: the STEP is carried in the arguments,
|
|
461
|
+
# so #handle stays a pure function and the server stays boot-free -
|
|
462
|
+
# no session storage. Each step response ends with the exact next
|
|
463
|
+
# call. With no step, the entry routes on the intent's VERB:
|
|
464
|
+
# review/harden stay read-only so an audit never becomes an edit;
|
|
465
|
+
# shape plans without probing; implement runs the full sequence.
|
|
466
|
+
def build_page(arguments)
|
|
467
|
+
intent = arguments["intent"].to_s.strip
|
|
468
|
+
return "build_page needs the intent - the page you are building, in a sentence." if intent.empty?
|
|
469
|
+
|
|
470
|
+
step = arguments["step"].to_s.strip
|
|
471
|
+
return route_entry(intent) if step.empty?
|
|
472
|
+
|
|
473
|
+
run_step(step, intent, arguments)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# The request-mode router: resolve the mode from the verb BEFORE
|
|
477
|
+
# acting. Only a clear review/harden/shape
|
|
478
|
+
# verb diverts; a page description with no verb is an implement.
|
|
479
|
+
def request_mode(intent)
|
|
480
|
+
text = intent.downcase
|
|
481
|
+
return :review if text.match?(/\b(review|audit|assess|evaluate|inspect|critique)\b/)
|
|
482
|
+
return :harden if text.match?(/\b(harden|a11y|accessib\w*|tighten|secure|polish)\b/)
|
|
483
|
+
if text.match?(/\b(plan|shape|architect|outline|sketch|wireframe)\b/) &&
|
|
484
|
+
!text.match?(/\b(build|create|make|implement|generate|scaffold|add)\b/)
|
|
485
|
+
return :shape
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
:implement
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def route_entry(intent)
|
|
492
|
+
case request_mode(intent)
|
|
493
|
+
when :review then review_route(intent)
|
|
494
|
+
when :harden then harden_route
|
|
495
|
+
when :shape then shape_entry(intent)
|
|
496
|
+
else implement_entry(intent)
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def implement_entry(intent)
|
|
501
|
+
banner = ["GUIDED BUILD - mode: implement.",
|
|
502
|
+
"Steps: probe -> plan -> direct -> snippets -> verify; each ends with the next call.",
|
|
503
|
+
"DONE means a PASS from the check tool (an executable verdict), never a claim.", "", ""]
|
|
504
|
+
banner.join("\n") + run_step("probe", intent, {})
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def shape_entry(intent)
|
|
508
|
+
banner = ["GUIDED BUILD - mode: shape (planning only, no host changes).",
|
|
509
|
+
"You asked to shape, not build - here is the architecture; call step: \"snippets\" " \
|
|
510
|
+
"when you want source.", "", ""]
|
|
511
|
+
banner.join("\n") + run_step("plan", intent, {})
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def review_route(intent)
|
|
515
|
+
["REQUEST MODE: review - staying read-only (an audit does not become an edit).",
|
|
516
|
+
"I will not enter the build sequence to review. To assess existing markup:",
|
|
517
|
+
"- run the `check` tool with the ERB source (verdict + findings), or `bin/rails poetry:check`;",
|
|
518
|
+
"- the visual/a11y critique: get_skill(name: \"poetry-design\", file: \"references/audit.md\");",
|
|
519
|
+
"- wrong-component calls: guidance(topic: \"deciding\").", "",
|
|
520
|
+
%(To BUILD instead, re-call with a build verb, e.g. "build #{intent}".)].join("\n")
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def harden_route
|
|
524
|
+
["REQUEST MODE: harden - read-only recon, no edits from here.",
|
|
525
|
+
"Hardening runs the same executable gate, not a rewrite:",
|
|
526
|
+
"- `check` the current ERB for contract violations (verdict + findings);",
|
|
527
|
+
"- get_skill(name: \"poetry-design\", file: \"references/audit.md\") for the a11y + slop pass;",
|
|
528
|
+
"- fix through tokens/variants/DESIGN.md, never per-instance CSS; re-run check as the LAST action.",
|
|
529
|
+
"", "To build a NEW page instead, re-call with a build verb."].join("\n")
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def run_step(step, intent, arguments)
|
|
533
|
+
case step
|
|
534
|
+
when "probe" then framed(1, "PROBE (host setup)", probe_body, "plan")
|
|
535
|
+
when "plan" then framed(2, "PLAN (page architecture)", plan_body(intent), "direct")
|
|
536
|
+
when "direct" then framed(3, "DIRECT (creative direction)", direct_body, "snippets")
|
|
537
|
+
when "snippets" then framed(4, "SNIPPETS (start-from source)", snippets_body(intent), "verify")
|
|
538
|
+
when "verify" then verify_body(arguments)
|
|
539
|
+
else redirect_step(step)
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def framed(number, label, body, nxt)
|
|
544
|
+
["STEP #{number}/5 - #{label}", "", body, "",
|
|
545
|
+
%(NEXT -> call build_page again with step: "#{nxt}" (same intent).)].join("\n")
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def redirect_step(step)
|
|
549
|
+
"no such step: #{step.inspect} - the workflow is #{STEPS.join(" -> ")}. " \
|
|
550
|
+
"Omit step for the guided entry, or pass one of those."
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Step 1: host doctor. Reads what it can from @app_root and degrades
|
|
554
|
+
# gracefully (the plan step never depends on it). Also the JS-channel
|
|
555
|
+
# fail-fast: a bundler present without the controllers channel
|
|
556
|
+
# leaves interactive components inert.
|
|
557
|
+
def probe_body
|
|
558
|
+
root = @app_root
|
|
559
|
+
return probe_no_host unless root && File.directory?(root)
|
|
560
|
+
|
|
561
|
+
lines = [host_config_line(root), theme_line(detect_theme(root)), css_mode_line(root)]
|
|
562
|
+
lines.concat(js_pipeline_lines(root))
|
|
563
|
+
lines.join("\n")
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def probe_no_host
|
|
567
|
+
["Host app not visible (run `bundle exec poetry-agent` from the app dir for setup checks).",
|
|
568
|
+
"Probe would read: installed components + declared overrides (config/poetry_components.yml),",
|
|
569
|
+
"the installed theme, css_mode, icon set, and importmap-vs-bundler. Assume a standard",
|
|
570
|
+
"poetry install and continue - the plan step does not depend on this."].join("\n")
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def host_config_line(root)
|
|
574
|
+
cfg = read_host_yaml(File.join(root, "config", "poetry_components.yml"))
|
|
575
|
+
return "poetry config: config/poetry_components.yml not found - run `bin/rails g poetry:install`." unless cfg
|
|
576
|
+
|
|
577
|
+
installed = (cfg["components"] || {}).size
|
|
578
|
+
overrides = (cfg["overrides"] || {}).size
|
|
579
|
+
"poetry config: #{installed} component(s) configured, #{overrides} declared cn-* override(s)."
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def theme_line(theme)
|
|
583
|
+
return "theme: not detected - the direct step covers picking one of the nine." unless theme
|
|
584
|
+
|
|
585
|
+
"theme: #{theme} (its tokens ARE your creative direction - step 3)."
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def css_mode_line(root)
|
|
589
|
+
entry = ["app/assets/tailwind/application.css", "tailwind.config.js", "config/tailwind.config.js",
|
|
590
|
+
"app/assets/stylesheets/application.tailwind.css"].any? { |rel| host_exist?(root, rel) }
|
|
591
|
+
if entry
|
|
592
|
+
"css_mode: Tailwind entry found - poetry emits Tailwind classes."
|
|
593
|
+
else
|
|
594
|
+
"css_mode: no Tailwind entry detected - poetry can emit raw BEM when css_mode is set."
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def js_pipeline_lines(root)
|
|
599
|
+
importmap = host_exist?(root, "config/importmap.rb")
|
|
600
|
+
bundler = %w[vite.config.js vite.config.ts config/vite.json package.json].any? do |rel|
|
|
601
|
+
host_exist?(root, rel)
|
|
602
|
+
end
|
|
603
|
+
if importmap && !bundler
|
|
604
|
+
["js: importmap - pin poetry's Stimulus controllers so interactive components wire up."]
|
|
605
|
+
elsif bundler
|
|
606
|
+
["js: a JS bundler is present - poetry's controllers need the @poetry/controllers npm channel",
|
|
607
|
+
"wired, or the interactive components stay inert. Verify the import."]
|
|
608
|
+
else
|
|
609
|
+
["js: no importmap or bundler detected - poetry's interactive components need one wired."]
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Step 2: the intent-matched page architecture (the new IP). Same
|
|
614
|
+
# stem tokenization + weighting as the block router, so plan and
|
|
615
|
+
# compose rank an intent the same way.
|
|
616
|
+
def plan_body(intent)
|
|
617
|
+
tokens = brief_tokens(intent)
|
|
618
|
+
scored = Poetry::Core::PageArchitectures.scored(tokens)
|
|
619
|
+
best, best_score = scored.first
|
|
620
|
+
return no_archetype(scored) if best.nil? || best_score < ARCHETYPE_MATCH
|
|
621
|
+
|
|
622
|
+
[render_archetype(best, best_score), runners_note(scored)].reject(&:empty?).join("\n")
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def render_archetype(entry, score)
|
|
626
|
+
lines = ["MATCH: #{entry["title"]} (`#{entry["name"]}`) - score #{score}.", entry["purpose"], "",
|
|
627
|
+
start_from(entry), "", "Section order:"]
|
|
628
|
+
entry["sections"].each { |section| lines << "- #{section}" }
|
|
629
|
+
lines << ""
|
|
630
|
+
lines << "States a real screen handles (not just the happy path):"
|
|
631
|
+
entry["states"].each { |state| lines << "- #{state}" }
|
|
632
|
+
lines << ""
|
|
633
|
+
lines << "Edge cases that bite:"
|
|
634
|
+
entry["edge_cases"].each { |edge| lines << "- #{edge}" }
|
|
635
|
+
lines << ""
|
|
636
|
+
lines << "Components: #{entry["components"].join(", ")} (describe_component for the contracts)."
|
|
637
|
+
lines.join("\n")
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def start_from(entry)
|
|
641
|
+
if entry["block"]
|
|
642
|
+
"Start from block `#{entry["block"]}` - describe_block returns its source; adapt in place " \
|
|
643
|
+
"(the known winning path). Fill gaps with the components below."
|
|
644
|
+
else
|
|
645
|
+
"No single vetted block covers this yet - compose from the components below, applying the " \
|
|
646
|
+
"section order and the five mechanics (get_skill poetry-design references/compose.md)."
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def runners_note(scored)
|
|
651
|
+
runners = scored.drop(1).select { |_entry, score| score.positive? }.first(2)
|
|
652
|
+
return "" if runners.empty?
|
|
653
|
+
|
|
654
|
+
described = runners.map { |entry, score| "`#{entry["name"]}` (#{entry["title"]}, score #{score})" }
|
|
655
|
+
"\nNearby archetypes: #{described.join(", ")}."
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def no_archetype(scored)
|
|
659
|
+
near = scored.first(3).map { |entry, _score| entry["name"] }.join(", ")
|
|
660
|
+
["No archetype strongly matched this intent. The catalog is a SEED " \
|
|
661
|
+
"(#{Poetry::Core::PageArchitectures.all.size} archetypes; it grows toward the ~50 target).",
|
|
662
|
+
"Fall back to `compose` for block/component routing, and the five composition mechanics " \
|
|
663
|
+
"(get_skill poetry-design references/compose.md).",
|
|
664
|
+
"Nearest archetypes if one fits: #{near}."].join("\n")
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Step 3: theme-derived creative direction. poetry's direction is
|
|
668
|
+
# design-system-constrained by construction - it delegates
|
|
669
|
+
# to the authored per-theme vocabulary rather than restating it.
|
|
670
|
+
def direct_body
|
|
671
|
+
theme = @app_root && detect_theme(@app_root)
|
|
672
|
+
lines = ["poetry's creative direction is THEME-DERIVED, not a freeform trend pick: the installed",
|
|
673
|
+
"theme's typography, radius, motion, and color vocabulary IS the direction. Coherence is",
|
|
674
|
+
"the product; range comes from the nine themes + a brand DESIGN.md, not per-page CSS.", ""]
|
|
675
|
+
lines << if theme
|
|
676
|
+
"Installed theme: `#{theme}`. Build inside its vocabulary - its tokens carry the look."
|
|
677
|
+
else
|
|
678
|
+
"No theme detected here. Pick one of the nine at install (`--theme`); each is distinct."
|
|
679
|
+
end
|
|
680
|
+
lines << "Per-theme vocabulary: get_skill(name: \"poetry-design\", file: \"references/theme.md\")."
|
|
681
|
+
lines << if @app_root && design_md_present?(@app_root)
|
|
682
|
+
"A DESIGN.md is present - it is the brand-override door on top of the theme; honor it."
|
|
683
|
+
else
|
|
684
|
+
"No DESIGN.md here - study a brand into one (poetry-design `study`) to override the theme."
|
|
685
|
+
end
|
|
686
|
+
lines << "Do NOT add gradients, shadows, or arbitrary colors for 'personality' - that is drift."
|
|
687
|
+
lines.join("\n")
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# Step 4: the concrete source, routed exactly as the compose tool
|
|
691
|
+
# (block match inline, or the matching components). compose already
|
|
692
|
+
# closes with "check as the LAST action", which dovetails into verify.
|
|
693
|
+
def snippets_body(intent)
|
|
694
|
+
["The source to start from (routed like the compose tool):", "", compose("brief" => intent)].join("\n")
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Step 5: the executable gate. DONE requires a PASS from the same
|
|
698
|
+
# check tool - not an attestation. This is the structural edge over
|
|
699
|
+
# a workflow whose completion is an LLM's word.
|
|
700
|
+
def verify_body(arguments)
|
|
701
|
+
source = arguments["source"].to_s
|
|
702
|
+
if source.strip.empty?
|
|
703
|
+
return ["STEP 5/5 - VERIFY (the executable gate)", "",
|
|
704
|
+
"Pass your built ERB as `source` to run check here, or run `bin/rails poetry:check`.",
|
|
705
|
+
"The workflow is DONE only on a PASS - an edit after your last check is unverified.",
|
|
706
|
+
%(When ready: build_page(intent: "...", step: "verify", source: "<your ERB>").)].join("\n")
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
report = check("source" => source)
|
|
710
|
+
header = if report.start_with?("PASS")
|
|
711
|
+
"STEP 5/5 - VERIFY: PASS - the guided build is DONE."
|
|
712
|
+
else
|
|
713
|
+
"STEP 5/5 - VERIFY: FAIL - not done. Fix the findings and re-run verify."
|
|
714
|
+
end
|
|
715
|
+
[header, "", report].join("\n")
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# --- host-file probes (best-effort, always graceful) ---
|
|
719
|
+
|
|
720
|
+
def read_host_yaml(path)
|
|
721
|
+
return nil unless File.file?(path)
|
|
722
|
+
|
|
723
|
+
require "yaml"
|
|
724
|
+
YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true)
|
|
725
|
+
rescue StandardError
|
|
726
|
+
nil
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
# Header sniff for the installed theme: the `.style-<name>` scope the
|
|
730
|
+
# docs switcher uses, or a `poetry theme: <name>` marker. First 4KB
|
|
731
|
+
# is enough; any read error just means "not detected".
|
|
732
|
+
def detect_theme(root)
|
|
733
|
+
%w[app/assets/stylesheets/poetry.css app/assets/stylesheets/application.css
|
|
734
|
+
app/assets/tailwind/application.css app/assets/builds/poetry.css].each do |rel|
|
|
735
|
+
path = File.join(root, rel)
|
|
736
|
+
next unless File.file?(path)
|
|
737
|
+
|
|
738
|
+
head = File.read(path, 4096).to_s
|
|
739
|
+
match = head.match(/\bstyle-(#{THEMES.join("|")})\b/) || head.match(/poetry theme:\s*(\w+)/i)
|
|
740
|
+
return match[1] if match
|
|
741
|
+
rescue StandardError
|
|
742
|
+
next
|
|
743
|
+
end
|
|
744
|
+
nil
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
def design_md_present?(root)
|
|
748
|
+
%w[DESIGN.md config/DESIGN.md app/assets/DESIGN.md].any? { |rel| host_exist?(root, rel) }
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def host_exist?(root, rel)
|
|
752
|
+
File.exist?(File.join(root, rel))
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def list_blocks
|
|
756
|
+
return "no blocks in this registry" if @blocks.empty?
|
|
757
|
+
|
|
758
|
+
@blocks.map do |name, entry|
|
|
759
|
+
"- #{name}: #{entry["title"]} - #{entry["description"]} " \
|
|
760
|
+
"[composes: #{entry["components"].join(", ")}]"
|
|
761
|
+
end.join("\n")
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def list_recipes
|
|
765
|
+
return "no recipes in this registry" if @recipes.empty?
|
|
766
|
+
|
|
767
|
+
@recipes.map do |recipe|
|
|
768
|
+
targets = recipe["files"].map { |file| file["target"] }.join(", ")
|
|
769
|
+
"- #{recipe["name"]}: #{recipe["title"]} - #{recipe["description"]} [installs: #{targets}]"
|
|
770
|
+
end.join("\n")
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# Runtime skill delivery: the SAME
|
|
774
|
+
# files `rails g poetry:skill` installs, served over MCP for hosts
|
|
775
|
+
# that cannot write files. SKILL.md alone first - the skill's own
|
|
776
|
+
# progressive-disclosure design; references load one at a time.
|
|
777
|
+
def get_skill(arguments)
|
|
778
|
+
name = arguments["name"] || "poetry"
|
|
779
|
+
loader = @skills[name]
|
|
780
|
+
unless loader
|
|
781
|
+
available = @skills.keys.sort.join(", ")
|
|
782
|
+
listing = available.empty? ? "this host serves none" : "available here: #{available}"
|
|
783
|
+
return "no skill #{name.inspect} - #{listing}. In an app, " \
|
|
784
|
+
"`bin/rails g poetry:skill` installs them as files instead."
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
files = (@skill_files[name] ||= loader.call)
|
|
788
|
+
file = arguments["file"]
|
|
789
|
+
return skill_menu(name, files) unless file
|
|
790
|
+
|
|
791
|
+
files.fetch(file) do
|
|
792
|
+
"no file #{file.inspect} in the #{name} skill - files: #{files.keys.sort.join(", ")}"
|
|
793
|
+
end
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def skill_menu(name, files)
|
|
797
|
+
references = files.keys.reject { |path| path == "SKILL.md" }.sort
|
|
798
|
+
menu = ["", "---", "Files in this skill - fetch one with get_skill(name: #{name.inspect}, " \
|
|
799
|
+
"file: \"...\"):"]
|
|
800
|
+
references.each { |path| menu << "- #{path}" }
|
|
801
|
+
files.fetch("SKILL.md") + menu.join("\n")
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# Curated guidance topics: the same text the installed
|
|
805
|
+
# usage skill carries, reachable when only the MCP is connected.
|
|
806
|
+
def guidance(arguments)
|
|
807
|
+
topic = arguments["topic"].to_s
|
|
808
|
+
topics = { "deciding" => -> { Poetry::Core::SkillText.deciding_reference } }
|
|
809
|
+
entry = topics[topic]
|
|
810
|
+
|
|
811
|
+
return entry.call if entry
|
|
812
|
+
|
|
813
|
+
"no such topic: #{topic.inspect} - topics: #{topics.keys.join(", ")}"
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def describe_block(arguments)
|
|
817
|
+
name = arguments["name"].to_s.tr("_", "-")
|
|
818
|
+
entry = @blocks[name]
|
|
819
|
+
return "no such block: #{arguments["name"].inspect} - call list_blocks" unless entry
|
|
820
|
+
|
|
821
|
+
source = Pathname.new(@root).join(entry.fetch("template")).read
|
|
822
|
+
.sub(/\A<%#\s*poetry:block[^%]*%>\n?/, "").rstrip
|
|
823
|
+
["# #{entry["title"]} (`#{name}`)", entry["description"],
|
|
824
|
+
"Composes: #{entry["components"].join(", ")}.",
|
|
825
|
+
"In an app: `bin/rails g poetry:block #{name}` copies this into app/views/blocks/.",
|
|
826
|
+
"", "Source (adapt freely - the sample content is meant to be replaced):", "", source].join("\n")
|
|
827
|
+
rescue StandardError => e
|
|
828
|
+
"block source unavailable: #{e.message}"
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# --- shared projections ---
|
|
832
|
+
|
|
833
|
+
def surface_lines(entry)
|
|
834
|
+
lines = []
|
|
835
|
+
lines << "- content block REQUIRED (#{entry["requires_content"]})" if entry["requires_content"]
|
|
836
|
+
(entry["required_slots"] || {}).each do |setter, hint|
|
|
837
|
+
lines << "- slot REQUIRED: with_#{setter} (#{hint}) - a call without it raises"
|
|
838
|
+
end
|
|
839
|
+
(entry["requires_any"] || []).each do |group|
|
|
840
|
+
parts = []
|
|
841
|
+
parts << "a content block" if group["content"]
|
|
842
|
+
parts.concat((group["slots"] || []).map { |name| "with_#{name}" })
|
|
843
|
+
parts.concat((group["options"] || []).map { |key| "#{key}:" })
|
|
844
|
+
lines << "- REQUIRED - one of #{parts.join(" / ")} (#{group["hint"]})"
|
|
845
|
+
end
|
|
846
|
+
(entry["styles"] + entry["options"]).each do |prop|
|
|
847
|
+
facets = []
|
|
848
|
+
facets << prop["variants"].join("|") if prop["variants"]
|
|
849
|
+
facets << "required" if prop["required"] && !prop.key?("default")
|
|
850
|
+
facets << "format: #{prop["format"]}" if prop["format"]
|
|
851
|
+
suffix = facets.any? ? " (#{facets.join("; ")})" : ""
|
|
852
|
+
description = prop["description"] ? " - #{prop["description"]}" : ""
|
|
853
|
+
lines << "- #{prop["name"]}: #{prop["type"]}#{suffix}#{description}"
|
|
854
|
+
end
|
|
855
|
+
slots = (entry["slots"] || []).map do |slot|
|
|
856
|
+
facets = []
|
|
857
|
+
facets << slot["description"] if slot["description"]
|
|
858
|
+
if slot["types"]
|
|
859
|
+
args = slot["setter_args"]
|
|
860
|
+
convention = args && slot["types"].all? { |type| args[type]&.zero? } ? " - options as keywords" : ""
|
|
861
|
+
facets << "types #{slot["types"].join("|")}#{convention}"
|
|
862
|
+
end
|
|
863
|
+
facets << "takes #{helper(slot["component"])} props, not a block" if slot["component"]
|
|
864
|
+
# The render-crash seams, stated where agents read them.
|
|
865
|
+
if (yieldless = slot["yieldless"])
|
|
866
|
+
setters = yieldless.map { |name| "with_#{name}" }.join("/")
|
|
867
|
+
facets << "#{setters} #{yieldless.size == 1 ? "yields" : "yield"} NOTHING to the block - no |param|"
|
|
868
|
+
end
|
|
869
|
+
(slot["setter_kwargs"] || {}).each do |setter, keywords|
|
|
870
|
+
facets << "with_#{setter} keywords: #{keywords.map { |keyword| "#{keyword}:" }.join(", ")} ONLY"
|
|
871
|
+
end
|
|
872
|
+
(slot["required_content"] || {}).each do |setter, hint|
|
|
873
|
+
facets << "with_#{setter} REQUIRES a content block (#{hint})"
|
|
874
|
+
end
|
|
875
|
+
(slot["builders"] || {}).each do |setter, surface|
|
|
876
|
+
(surface["required_slots"] || {}).each do |required, hint|
|
|
877
|
+
facets << "each with_#{setter} REQUIRES with_#{required} inside its block (#{hint})"
|
|
878
|
+
end
|
|
879
|
+
end
|
|
880
|
+
"#{slot["name"]}#{" (#{facets.join("; ")})" if facets.any?}"
|
|
881
|
+
end
|
|
882
|
+
lines << "- slots: #{slots.join(", ")}" if slots.any?
|
|
883
|
+
lines
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def full_lines(entry)
|
|
887
|
+
lines = (entry["controllers"] || []).map do |controller|
|
|
888
|
+
"- wiring #{controller["identifier"]}: actions #{(controller["actions"] || []).join(", ")}"
|
|
889
|
+
end
|
|
890
|
+
# The styling contract: every data-slot part with its
|
|
891
|
+
# state attributes and var seams, DOM-verified by the
|
|
892
|
+
# part-contract tier - restyle via [data-slot=...], never by
|
|
893
|
+
# guessing at internal markup.
|
|
894
|
+
(entry["parts"] || []).each { |part| lines << part_line(part) }
|
|
895
|
+
# The operate surface: the tools an in-page agent may invoke on a
|
|
896
|
+
# rendered instance once the call opts in (webmcp: "name").
|
|
897
|
+
(entry["tools"] || []).each { |tool| lines << tool_line(tool) }
|
|
898
|
+
(entry["agent_rules"] || []).each { |rule| lines << "- RULE: #{rule}" }
|
|
899
|
+
lines
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
def tool_line(tool)
|
|
903
|
+
schema = tool["inputSchema"] || {}
|
|
904
|
+
required = schema["required"] || []
|
|
905
|
+
params = (schema["properties"] || {}).map do |name, spec|
|
|
906
|
+
facets = [spec["type"]]
|
|
907
|
+
facets << "required" if required.include?(name)
|
|
908
|
+
facets << "one of #{spec["enum"].join("|")}" if spec["enum"]
|
|
909
|
+
"#{name} (#{facets.join(", ")})"
|
|
910
|
+
end
|
|
911
|
+
hint = tool.dig("annotations", "readOnlyHint") ? "read-only" : "mutating"
|
|
912
|
+
facets = [hint]
|
|
913
|
+
facets << "params: #{params.join(", ")}" if params.any?
|
|
914
|
+
"- tool #{tool["name"]} (#{facets.join("; ")}) - #{tool["description"]} " \
|
|
915
|
+
"[opt in with webmcp: \"name\" on the call; dispatches #{tool["executes"]}]"
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def part_line(part)
|
|
919
|
+
facets = []
|
|
920
|
+
states = (part["states"] || []).map do |state|
|
|
921
|
+
values = state["values"] ? "=#{state["values"].join("|")}" : ""
|
|
922
|
+
"#{state["attr"]}#{values} (#{state["condition"]})"
|
|
923
|
+
end
|
|
924
|
+
facets << "states: #{states.join("; ")}" if states.any?
|
|
925
|
+
vars = (part["vars"] || []).map { |var| "#{var["name"]} (#{var["description"]})" }
|
|
926
|
+
facets << "vars: #{vars.join("; ")}" if vars.any?
|
|
927
|
+
"- part [data-slot=#{part["name"]}] - #{part["description"]}" \
|
|
928
|
+
"#{" | #{facets.join(" | ")}" if facets.any?}"
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def summary(entry)
|
|
932
|
+
return entry["description"] if entry["description"]
|
|
933
|
+
|
|
934
|
+
styles = entry["styles"].map { |style| style["variants"] ? style["variants"].join("|") : style["name"] }
|
|
935
|
+
styles.empty? ? "no style attributes" : styles.join("; ")
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# name may be the title (button, command_dialog) or the full path.
|
|
939
|
+
def resolve(name)
|
|
940
|
+
return name if @entries.key?(name)
|
|
941
|
+
|
|
942
|
+
@entries.keys.find { |path| title(path) == name }
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
def title(path) = path.split("/").drop(2).join("_")
|
|
946
|
+
def helper(path) = "poetry_#{title(path)}"
|
|
947
|
+
|
|
948
|
+
def result(id, value) = { "jsonrpc" => "2.0", "id" => id, "result" => value }
|
|
949
|
+
|
|
950
|
+
def error(id, code,
|
|
951
|
+
message)
|
|
952
|
+
{ "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
|
|
953
|
+
end
|
|
954
|
+
|
|
955
|
+
def tool_content(text,
|
|
956
|
+
error: false)
|
|
957
|
+
{ "content" => [{ "type" => "text", "text" => text }], "isError" => error }
|
|
958
|
+
end
|
|
959
|
+
end
|
|
960
|
+
end
|
|
961
|
+
end
|
|
962
|
+
end
|