hecks 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/check_era.rb +3 -39
  10. data/lib/hecks/cli/interview_draft.rb +86 -31
  11. data/lib/hecks/cli/interview_session.rb +77 -26
  12. data/lib/hecks/cli/interview_shape.rb +179 -0
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door.rb +16 -2
  16. data/lib/hecks/doors/mcp_door_scope.rb +45 -5
  17. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  18. data/lib/hecks/doors.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  20. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  21. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  22. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  23. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  24. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  25. data/lib/hecks/gate/stages.yml +113 -8
  26. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  28. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  29. data/lib/hecks/hecks/adapters/git.rb +59 -0
  30. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  31. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  32. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  33. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  34. data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
  35. data/lib/hecks/hecks/codebase.behaviors +6 -0
  36. data/lib/hecks/hecks/codebase.bluebook +21 -0
  37. data/lib/hecks/hecks/custodian.behaviors +6 -0
  38. data/lib/hecks/hecks/custodian.bluebook +217 -0
  39. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  40. data/lib/hecks/hecks/hecks.world +8 -3
  41. data/lib/hecks/hecks/site.world +5 -2
  42. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  43. data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
  44. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  45. data/lib/hecks/language/oidc.json +5 -0
  46. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  47. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  48. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  49. data/lib/hecks/projections/deploy/box/settings.rb +40 -6
  50. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
  51. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  52. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  53. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  54. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  55. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  56. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  57. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  58. data/lib/hecks/projections/deploy/box.rb +28 -4
  59. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  60. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  61. data/lib/hecks/projections/model/deviations.rb +1 -1
  62. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  63. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  64. data/lib/hecks/projections/site/edge.rb +25 -11
  65. data/lib/hecks/projections/site/live_edge.rb +162 -0
  66. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  67. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  68. data/lib/hecks/projections/site/table/checks.rb +53 -7
  69. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  70. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  71. data/lib/hecks/projections/site/table.rb +16 -5
  72. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  73. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  74. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  75. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  76. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  77. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  78. data/lib/hecks/runtime/interpreting.rb +3 -22
  79. data/lib/hecks/runtime/needs.rb +48 -0
  80. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  81. data/lib/hecks/runtime/registry/verification.rb +3 -3
  82. data/lib/hecks/rust_build/host.rb +147 -0
  83. data/lib/hecks/rust_build/kernel_input.rb +51 -17
  84. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  85. data/lib/hecks/rust_build/project_rust.rb +7 -140
  86. data/lib/hecks/rust_build.rb +1 -0
  87. data/lib/hecks/site/bluebook/site.bluebook +80 -14
  88. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  89. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  90. data/lib/hecks/storehouse.rb +35 -1
  91. data/lib/hecks/tools/comment_style.rb +4 -2
  92. data/lib/hecks/tools/gate.rb +1 -1
  93. data/lib/hecks/tools/rust_comment_style.rb +2 -4
  94. data/lib/hecks/tools/site_routes.rb +69 -28
  95. data/lib/hecks/tools/tools_doc.rb +204 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +152 -1
  99. data/lib/hecks.rb +1 -0
  100. data/qa/settings.yml +1 -2
  101. data/rust/build/src/lineage_pass.rs +31 -16
  102. data/rust/build/src/main.rs +4 -2
  103. data/rust/build/src/pipeline.rs +9 -20
  104. data/rust/codegen/src/attr.rs +1 -1
  105. data/rust/codegen/src/bridging.rs +8 -8
  106. data/rust/codegen/src/commands.rs +1 -1
  107. data/rust/codegen/src/constraints.rs +1 -1
  108. data/rust/codegen/src/dependency_planning.rs +1 -1
  109. data/rust/codegen/src/domain_generator.rs +2 -2
  110. data/rust/codegen/src/exemplar.rs +1 -1
  111. data/rust/codegen/src/expr_emitter.rs +1 -1
  112. data/rust/codegen/src/fielded.rs +1 -1
  113. data/rust/codegen/src/json.rs +214 -23
  114. data/rust/codegen/src/json_codec.rs +1 -1
  115. data/rust/codegen/src/literal.rs +6 -3
  116. data/rust/codegen/src/main.rs +23 -13
  117. data/rust/codegen/src/mutations.rs +1 -1
  118. data/rust/codegen/src/naming.rs +5 -3
  119. data/rust/codegen/src/optional_pass.rs +226 -0
  120. data/rust/codegen/src/ports.rs +1 -1
  121. data/rust/codegen/src/queries.rs +3 -3
  122. data/rust/codegen/src/reactions.rs +1 -1
  123. data/rust/codegen/src/read_models.rs +2 -2
  124. data/rust/codegen/src/reference_specs.rs +1 -1
  125. data/rust/codegen/src/registry.rs +4 -4
  126. data/rust/codegen/src/shared.rs +1 -1
  127. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  128. data/rust/codegen/src/skip_reason.rs +1 -1
  129. data/rust/codegen/src/types.rs +4 -4
  130. data/rust/host/HECKS_RELEASE +1 -1
  131. data/rust/host/src/api.rs +1 -1
  132. data/rust/host/src/dispatch.rs +5 -0
  133. data/rust/host/src/needs.rs +193 -29
  134. data/rust/host/src/resend.rs +4 -4
  135. data/rust/host/src/ui_schema.rs +1 -1
  136. data/rust/parser/src/emit.rs +12 -0
  137. data/rust/parser/src/ir.rs +2 -0
  138. data/rust/parser/src/keywords.rs +2 -0
  139. data/rust/parser/src/main.rs +1 -0
  140. data/rust/parser/src/parse/command.rs +2 -44
  141. data/rust/parser/src/parse/mod.rs +1 -0
  142. data/rust/parser/src/parse/needs.rs +60 -0
  143. data/rust/parser/src/parse/query.rs +3 -0
  144. data/rust/src/exemplar/commands.rs +1 -1
  145. data/rust/src/exemplar/constraints.rs +1 -1
  146. data/rust/src/exemplar/fielded.rs +1 -1
  147. data/rust/src/exemplar/json.rs +1 -1
  148. data/rust/src/exemplar/mod.rs +1 -1
  149. data/rust/src/exemplar/mutations.rs +1 -1
  150. data/rust/src/exemplar/queries.rs +1 -1
  151. data/rust/src/exemplar/reactions.rs +1 -1
  152. data/rust/src/exemplar/read_models.rs +1 -1
  153. data/rust/src/exemplar/registry.rs +1 -1
  154. data/rust/src/exemplar/types.rs +1 -1
  155. data/rust/src/kernel/cli.rs +18 -8
  156. data/rust/src/kernel/needs.rs +104 -9
  157. data/rust/src/kernel/read_model.rs +1 -1
  158. data/rust/src/lib.rs +1 -1
  159. metadata +24 -30
  160. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  161. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  162. data/lib/hecks/three_zero/forms.yml +0 -127
  163. data/lib/hecks/three_zero.rb +0 -12
  164. data/rust/build/src/json.rs +0 -421
  165. data/rust/build/src/optional_pass.rs +0 -150
  166. data/rust/project/bridging.rb +0 -257
  167. data/rust/project/commands.rb +0 -877
  168. data/rust/project/constraints.rb +0 -86
  169. data/rust/project/dependency_planning.rb +0 -178
  170. data/rust/project/domain_generator.rb +0 -983
  171. data/rust/project/exemplar.rb +0 -148
  172. data/rust/project/expr_emitter.rb +0 -72
  173. data/rust/project/fielded.rb +0 -141
  174. data/rust/project/json_codec.rb +0 -641
  175. data/rust/project/mutations.rb +0 -695
  176. data/rust/project/naming.rb +0 -223
  177. data/rust/project/ports.rb +0 -65
  178. data/rust/project/queries.rb +0 -493
  179. data/rust/project/reactions.rb +0 -317
  180. data/rust/project/read_models.rb +0 -690
  181. data/rust/project/reference_specs.rb +0 -27
  182. data/rust/project/registry.rb +0 -396
  183. data/rust/project/skip_reason.rb +0 -29
  184. data/rust/project/types.rb +0 -225
  185. data/rust/project/write_if_changed.rb +0 -7
  186. data/rust/project.rb +0 -30
  187. data/rust/project_rust_pipeline.rb +0 -391
@@ -24,36 +24,59 @@ module Hecks
24
24
 
25
25
  TASK = "You interview a subject matter expert, through the developer who sits beside them, about the " \
26
26
  "business domain called %<subject>s, so the developer can model it. Ask one plain-language " \
27
- "question at a time about what the business keeps track of, what happens to it, and what must " \
28
- "never happen. Use the expert's own words. Do not assume what kind of business it is, or an " \
27
+ "question at a time about what the business keeps track of, what each thing has, what happens to " \
28
+ "it and who does it, how it moves from one state to the next, and what must never happen. Use the " \
29
+ "expert's own words. Do not assume what kind of business it is, or an " \
29
30
  "industry, from the name of the domain: learn it from what the expert says. When you interpret " \
30
31
  "an answer, propose findings only with the verbs listed under `verbs`, using the argument " \
31
32
  "names given there, and nothing else.".freeze
32
33
 
33
34
  VERBS = {
34
- "SME::Interview.ProposeThing" => {
35
+ "SME::Interview.ProposeThing" => {
35
36
  "meaning" => "a thing the business keeps track of, and the field that identifies one of it",
36
37
  "arguments" => %w[name identifier]
37
38
  },
38
- "SME::Interview.ProposeAction" => {
39
- "meaning" => "something that happens to a thing, and the event it announces, in the past tense",
40
- "arguments" => %w[name thing event creates]
39
+ "SME::Interview.ProposeAction" => {
40
+ "meaning" => "something that happens to a thing, and the event it announces, in the past tense; " \
41
+ "creates is true for the action that brings the thing into being; takes lists, comma " \
42
+ "separated, the fields it needs to be told; by says who does it",
43
+ "arguments" => %w[name thing event creates takes by]
41
44
  },
42
- "SME::Interview.ProposeRule" => {
45
+ "SME::Interview.ProposeField" => {
46
+ "meaning" => "something a thing has; values lists, comma separated, what it may be when the " \
47
+ "expert gave a closed set, and is left out for free text",
48
+ "arguments" => %w[thing name values]
49
+ },
50
+ "SME::Interview.ProposeTransition" => {
51
+ "meaning" => "the state an action leaves a thing in, and the state it had to be in before; the " \
52
+ "action that creates the thing has no from",
53
+ "arguments" => %w[thing action to from]
54
+ },
55
+ "SME::Interview.ProposeRule" => {
43
56
  "meaning" => "a rule the expert stated, in their own words",
44
57
  "arguments" => %w[statement]
45
58
  }
46
59
  }.freeze
47
60
 
48
- # What each finding verb needs, and the SME commands that carry it out and decide it.
61
+ # What each finding verb needs, the fields it may leave out, and the SME commands that
62
+ # carry it out and decide it.
49
63
  KINDS = {
50
- "SME::Interview.ProposeThing" => { kind: "thing", fields: %w[name identifier], propose: :propose_thing!,
51
- entity: "ThingFinding", accept: "AcceptThing", reject: "RejectThing" },
52
- "SME::Interview.ProposeAction" => { kind: "action", fields: %w[name thing event creates],
53
- propose: :propose_action!, entity: "ActionFinding",
54
- accept: "AcceptAction", reject: "RejectAction" },
55
- "SME::Interview.ProposeRule" => { kind: "rule", fields: %w[statement], propose: :propose_rule!,
56
- entity: "RuleFinding", accept: "AcceptRule", reject: "RejectRule" }
64
+ "SME::Interview.ProposeThing" => { kind: "thing", fields: %w[name identifier], optional: [],
65
+ propose: :propose_thing!, entity: "ThingFinding",
66
+ accept: "AcceptThing", reject: "RejectThing" },
67
+ "SME::Interview.ProposeAction" => { kind: "action", fields: %w[name thing event creates takes by],
68
+ optional: %w[creates takes by], propose: :propose_action!,
69
+ entity: "ActionFinding", accept: "AcceptAction", reject: "RejectAction" },
70
+ "SME::Interview.ProposeRule" => { kind: "rule", fields: %w[statement], optional: [],
71
+ propose: :propose_rule!, entity: "RuleFinding",
72
+ accept: "AcceptRule", reject: "RejectRule" },
73
+ "SME::Interview.ProposeField" => { kind: "field", fields: %w[thing name values], optional: %w[values],
74
+ propose: :propose_field!, entity: "FieldFinding",
75
+ accept: "AcceptField", reject: "RejectField" },
76
+ "SME::Interview.ProposeTransition" => { kind: "transition", fields: %w[thing action to from],
77
+ optional: %w[from], propose: :propose_transition!,
78
+ entity: "TransitionFinding", accept: "AcceptTransition",
79
+ reject: "RejectTransition" }
57
80
  }.freeze
58
81
 
59
82
  TRUE_WORDS = %w[true yes y 1].freeze
@@ -153,7 +176,7 @@ module Hecks
153
176
  return say("Ignored a proposal for #{proposal.verb}: not a finding I know.") unless config
154
177
 
155
178
  fields = fields_of(proposal, config)
156
- missing = config[:fields].reject { |f| f == "creates" || fields[f].to_s.strip != "" }
179
+ missing = config[:fields].reject { |f| config[:optional].include?(f) || fields[f].to_s.strip != "" }
157
180
  return say("Ignored a #{config[:kind]} proposal with no #{missing.join(', ')}.") unless missing.empty?
158
181
 
159
182
  number = propose(config, fields) or return
@@ -166,7 +189,7 @@ module Hecks
166
189
  rows = proposal.arguments.to_h { |row| [row[:name].to_s, row[:value]] }
167
190
  fields = rows.slice(*config[:fields])
168
191
  fields["creates"] = TRUE_WORDS.include?(fields["creates"].to_s.downcase) if fields.key?("creates")
169
- fields
192
+ fields.reject { |key, value| key != "creates" && config[:optional].include?(key) && value.to_s.strip.empty? }
170
193
  end
171
194
 
172
195
  def propose(config, fields)
@@ -192,19 +215,27 @@ module Hecks
192
215
  case config[:kind]
193
216
  when "thing" then "thing: #{fields['name']}, identified by #{fields['identifier']}"
194
217
  when "action" then "action: #{fields['name']} on #{fields['thing']}, announcing #{fields['event']}" \
195
- "#{', creating it' if fields['creates']}"
218
+ "#{', creating it' if fields['creates']}#{action_extras(fields)}"
219
+ when "field" then "field: #{fields['name']} of #{fields['thing']}#{", one of #{fields['values']}" if fields['values']}"
220
+ when "transition" then "transition: #{fields['action']} leaves #{fields['thing']} #{fields['to']}" \
221
+ "#{", from #{fields['from']}" if fields['from']}"
196
222
  else "rule: #{fields['statement']}"
197
223
  end
198
224
  end
199
225
 
226
+ # @api private
227
+ def action_extras(fields)
228
+ "#{", taking #{fields['takes']}" if fields['takes']}#{", by #{fields['by']}" if fields['by']}"
229
+ end
230
+
200
231
  # The plain prompt: findings typed by the developer, accepted as they are entered.
201
232
  def manual_findings
202
233
  loop do
203
- kind = ask("Add a finding from that answer? thing, action or rule (enter to skip): ")
234
+ kind = ask("Add a finding from that answer? thing, action, rule, field or transition (enter to skip): ")
204
235
  kind = kind.to_s.strip.downcase
205
236
  break if kind.empty?
206
237
 
207
- typed = typed_finding(kind) or next say(" I only know thing, action and rule.")
238
+ typed = typed_finding(kind) or next say(" I only know thing, action, rule, field and transition.")
208
239
  config = KINDS["SME::Interview.Propose#{kind.capitalize}"]
209
240
  number = propose(config, typed) or next
210
241
  decide(config, number, true)
@@ -216,6 +247,11 @@ module Hecks
216
247
  when "thing" then named("Name" => "name", "Identified by" => "identifier")
217
248
  when "action" then typed_action
218
249
  when "rule" then named("The rule, in the expert's words" => "statement")
250
+ when "field" then named({ "On which thing" => "thing", "Name" => "name",
251
+ "Values it may take, comma separated (enter for any)" => "values" }, %w[values])
252
+ when "transition" then named({ "On which thing" => "thing", "Caused by which action" => "action",
253
+ "Leaves it in the state" => "to",
254
+ "From the state (enter if it starts there)" => "from" }, %w[from])
219
255
  end
220
256
  end
221
257
 
@@ -224,9 +260,11 @@ module Hecks
224
260
  fields.merge("creates" => TRUE_WORDS.include?(ask(" Does it create the thing? [y/N] ").to_s.strip.downcase))
225
261
  end
226
262
 
227
- def named(prompts)
263
+ def named(prompts, optional = [])
228
264
  fields = prompts.to_h { |label, key| [key, ask(" #{label}: ").to_s.strip] }
229
- fields.values.all? { |v| !v.empty? } ? fields : nil
265
+ return nil unless fields.all? { |key, value| optional.include?(key) || !value.empty? }
266
+
267
+ fields.reject { |key, value| optional.include?(key) && value.empty? }
230
268
  end
231
269
 
232
270
  def suggest_enough
@@ -271,9 +309,11 @@ module Hecks
271
309
  end
272
310
 
273
311
  def accepted_state(plain)
274
- { things: InterviewDraft.accepted(plain, :things).map { |f| f.slice(:name, :identifier) },
275
- actions: InterviewDraft.accepted(plain, :actions).map { |f| f.slice(:name, :thing, :event, :creates) },
276
- rules: InterviewDraft.accepted(plain, :rules).map { |f| f.slice(:statement) } }
312
+ { things: InterviewDraft.accepted(plain, :things).map { |f| f.slice(:name, :identifier) },
313
+ actions: InterviewDraft.accepted(plain, :actions).map { |f| f.slice(:name, :thing, :event, :creates, :takes, :by) },
314
+ fields: InterviewDraft.accepted(plain, :fields).map { |f| f.slice(:thing, :name, :values) },
315
+ transitions: InterviewDraft.accepted(plain, :transitions).map { |f| f.slice(:thing, :action, :to, :from) },
316
+ rules: InterviewDraft.accepted(plain, :rules).map { |f| f.slice(:statement) } }
277
317
  end
278
318
 
279
319
  # What the interview has not yet pinned down, in words a model can act on.
@@ -283,7 +323,18 @@ module Hecks
283
323
  gaps = things.reject { |t| actions.any? { |a| a[:thing] == t } }.map { |t| "nothing is yet said to happen to #{t}" }
284
324
  gaps += things.reject { |t| actions.any? { |a| a[:thing] == t && a[:creates] } }.map { |t| "nothing yet creates #{t}" }
285
325
  unplaced = actions.reject { |a| things.include?(a[:thing].to_s) }
286
- gaps + unplaced.map { |a| "#{a[:name]} names #{a[:thing]}, not yet a thing" }
326
+ gaps + unplaced.map { |a| "#{a[:name]} names #{a[:thing]}, not yet a thing" } + shape_gaps(plain, things, actions)
327
+ end
328
+
329
+ # What a thing is not yet said to have, and how it moves from one state to the next.
330
+ def shape_gaps(plain, things, actions)
331
+ fields = InterviewDraft.accepted(plain, :fields)
332
+ steps = InterviewDraft.accepted(plain, :transitions)
333
+ bare = things.reject { |t| fields.any? { |f| f[:thing] == t } }
334
+ gaps = bare.map { |t| "nothing is yet said #{t} has, beyond its identifier" }
335
+ changing = things.select { |t| actions.count { |a| a[:thing] == t && !a[:creates] } > 1 }
336
+ unmoved = changing.reject { |t| steps.any? { |s| s[:thing] == t } }
337
+ gaps + unmoved.map { |t| "nothing yet says how #{t} moves from one state to the next" }
287
338
  end
288
339
 
289
340
  def reason(error) = error.message.sub(/\A[A-Z]\w* refused\s+[—-]\s+/, "").strip
@@ -0,0 +1,179 @@
1
+ require_relative "../naming"
2
+
3
+ module Hecks
4
+ module CLI
5
+ # What an interview's fields, command inputs and state changes become in a drafted aggregate
6
+ # (ADR 0088): its attributes and their value objects, the fields a command is told, a note of
7
+ # who does it, and the lifecycle.
8
+ #
9
+ # Pure, like `InterviewDraft`, which calls it: each method answers lines of bluebook text and
10
+ # writes none. A name is written as `InterviewDraft.word` writes it.
11
+ module InterviewShape
12
+ module_function
13
+
14
+ # The accepted fields of one thing, each with the type it will be written under. The
15
+ # identifier is already a field, and a lifecycle owns `status`, so neither is repeated.
16
+ #
17
+ # @param found [Array<Hash>] the accepted field findings that name the thing
18
+ # @param name [String] the thing's name, which no type may repeat
19
+ # @param identifier [String] the identifying field, snake-cased
20
+ # @param identifier_type [String] the type the identifier is written under
21
+ # @param lifecycle [Boolean] whether the thing has a lifecycle
22
+ # @return [Array<Hash>] each field's `name`, `type` and `values`
23
+ def fields(found, name, identifier, identifier_type, lifecycle:)
24
+ taken = [name, identifier_type]
25
+ found.filter_map { |finding| field_row(finding, taken, identifier, lifecycle) }.uniq { |row| row[:name] }
26
+ end
27
+
28
+ # The attribute lines of the fields: required when the creating action takes one, else
29
+ # optional.
30
+ #
31
+ # @param fields [Array<Hash>] the thing's fields, from `fields`
32
+ # @param required [Array<String>] the field names the creating actions take
33
+ # @return [Array<String>] one line per field
34
+ def attributes(fields, required)
35
+ fields.map do |field|
36
+ suffix = required.include?(field[:name]) ? "" : ", optional: true"
37
+ " attribute :#{field[:name]}, #{field[:type]}#{suffix}"
38
+ end
39
+ end
40
+
41
+ # The value object of each field, a blank line before each: a closed set when the expert
42
+ # listed two or more values, and free text otherwise.
43
+ #
44
+ # @param fields [Array<Hash>] the thing's fields, from `fields`
45
+ # @return [Array<String>] the lines
46
+ def value_objects(fields)
47
+ fields.flat_map do |field|
48
+ rule = field[:values].size > 1 ? ", one_of: #{field[:values].inspect}" : ""
49
+ ["", " value_object #{field[:type].inspect} do", " attribute :value, String#{rule}", " end"]
50
+ end
51
+ end
52
+
53
+ # The field names an action says it is told.
54
+ #
55
+ # @param action [Hash] an accepted action
56
+ # @return [Array<String>] snake-cased field names
57
+ def takes(action)
58
+ action[:takes].to_s.split(/\s*,\s*/).map { |text| Naming.snake(InterviewDraft.word(text)) }.reject(&:empty?)
59
+ end
60
+
61
+ # What an action is told that the thing has as a field, and what it names that the thing
62
+ # does not. The identifier is never counted: a command already carries it.
63
+ #
64
+ # @param action [Hash] an accepted action
65
+ # @param fields [Array<Hash>] the thing's fields, from `fields`
66
+ # @param identifier [String] the identifying field, snake-cased
67
+ # @return [Array(Array<Hash>, Array<String>)] the fields it is told, and the names that have
68
+ # no field
69
+ def inputs(action, fields, identifier)
70
+ known, unknown = (takes(action) - [identifier]).partition { |text| fields.any? { |field| field[:name] == text } }
71
+ [known.map { |text| fields.find { |field| field[:name] == text } }, unknown]
72
+ end
73
+
74
+ # @param inputs [Array<Hash>] the fields a command is told
75
+ # @return [Array<String>] a blank line and an attribute line for each, or none
76
+ def input_lines(inputs)
77
+ inputs.empty? ? [] : ["", *inputs.map { |f| " attribute :#{f[:name]}, #{f[:type]}" }]
78
+ end
79
+
80
+ # @param inputs [Array<Hash>] the fields a command is told
81
+ # @return [Array<String>] a blank line and a `sets` line for each, or none
82
+ def assignment_lines(inputs)
83
+ inputs.empty? ? [] : ["", *inputs.map { |f| " sets :#{f[:name]}" }]
84
+ end
85
+
86
+ # @param unknown [Array<String>] names an action is told that no accepted field has
87
+ # @return [Array<String>] a note for each
88
+ def unknown_lines(unknown)
89
+ unknown.map { |text| " # TODO: takes #{text}, but no accepted field of that name." }
90
+ end
91
+
92
+ # Who may do an action, as the expert said it. It is a note, not a role: a role is only
93
+ # checked once the domain attaches Governance.
94
+ #
95
+ # @param action [Hash] an accepted action
96
+ # @return [Array<String>] the note, or none
97
+ def who_lines(action)
98
+ by = action[:by].to_s.strip
99
+ by.empty? ? [] : [" # Who: #{by}. Declare it as a role once Governance is attached."]
100
+ end
101
+
102
+ # The lifecycle the accepted transitions describe. The state a creating action's step
103
+ # leads to is where a thing starts; a step for an action the thing does not have is kept
104
+ # as a comment.
105
+ #
106
+ # @param steps [Array<Hash>] the thing's accepted transitions
107
+ # @param creating [Array<Hash>] the accepted actions that create it
108
+ # @param rest [Array<Hash>] its other accepted actions
109
+ # @return [Array<String>] the lines, or none when no step and start state can be placed
110
+ def lifecycle(steps, creating, rest)
111
+ births = creating.map { |action| InterviewDraft.word(action[:name]) }
112
+ has = ->(names, step) { names.include?(InterviewDraft.word(step[:action])) }
113
+ starts, moves = steps.partition { |step| has.call(births, step) }
114
+ heres = rest.map { |action| InterviewDraft.word(action[:name]) }
115
+ placed, loose = moves.partition { |step| has.call(heres, step) }
116
+ placed = merged(placed)
117
+ start = beginning(starts, placed)
118
+ return [] if start.empty? || placed.empty?
119
+
120
+ ["", " lifecycle :status, default: #{start.inspect} do", *placed.map { |step| transition_line(step) },
121
+ " end", *loose.map { |step| loose_line(step) }]
122
+ end
123
+
124
+ # @api private
125
+ def field_row(finding, taken, identifier, lifecycle)
126
+ field = Naming.snake(InterviewDraft.word(finding[:name]))
127
+ return if field == identifier || (lifecycle && field == "status")
128
+
129
+ type = Naming.pascal(field)
130
+ type = "#{type}Value" while taken.include?(type)
131
+ taken << type
132
+ { name: field, type: type, values: finding[:values].to_s.split(/\s*,\s*|\s+or\s+/).map(&:strip).reject(&:empty?).uniq }
133
+ end
134
+
135
+ # One step for each action and state it leads to: the states it starts from are joined, and a
136
+ # step with no state to start from means it may be taken from any.
137
+ # @api private
138
+ def merged(steps)
139
+ steps.group_by { |step| [InterviewDraft.word(step[:action]), state_word(step[:to])] }.values.map do |group|
140
+ froms = group.map { |step| step[:from].to_s.strip }
141
+ group.first.merge(from: froms.any?(&:empty?) ? "" : froms.join(","))
142
+ end
143
+ end
144
+
145
+ # The state a thing starts in: where its creating action's step leads, else the first state a
146
+ # change is said to start from.
147
+ # @api private
148
+ def beginning(starts, placed)
149
+ from = placed.map { |step| step[:from].to_s.split(",").first }.find { |text| !text.to_s.strip.empty? }
150
+ state_word(starts.first&.fetch(:to) || from)
151
+ end
152
+
153
+ # @api private
154
+ def transition_line(step)
155
+ " transition #{InterviewDraft.word(step[:action]).inspect} => #{state_word(step[:to]).inspect}" \
156
+ "#{from_clause(step)}"
157
+ end
158
+
159
+ # @api private
160
+ def loose_line(step)
161
+ " # UNPLACED transition: #{InterviewDraft.word(step[:action])} to #{state_word(step[:to])}; " \
162
+ "no accepted action has that name."
163
+ end
164
+
165
+ # The states a step may start from: none, one, or several.
166
+ # @api private
167
+ def from_clause(step)
168
+ states = step[:from].to_s.split(/\s*,\s*/).map { |text| state_word(text) }.reject(&:empty?)
169
+ return "" if states.empty?
170
+
171
+ states.one? ? ", from: #{states.first.inspect}" : ", from: %w[#{states.join(' ')}]"
172
+ end
173
+
174
+ # A state as a lowercase word: `Not Yet Triaged` is `not_yet_triaged`.
175
+ # @api private
176
+ def state_word(text) = text.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
177
+ end
178
+ end
179
+ end
@@ -188,6 +188,7 @@ module Hecks
188
188
  # @param launcher [Hash, nil] the chapter's `launcher` world setting; nil when not opted in
189
189
  def dispatch(runtime, spec, name, rest, program, asking, bluebook: nil, launcher: nil)
190
190
  rest, wait = LauncherOptions.take_wait(spec, rest) if launcher
191
+ wait ||= LauncherOptions.settled?(launcher, spec)
191
192
  args = CliDoor.arguments(spec, rest)
192
193
 
193
194
  return answer_query(runtime, spec, args, wait) if spec[:kind] == :query
@@ -252,11 +253,23 @@ module Hecks
252
253
  events: (events.empty? ? handle.events : events).map(&:name) }.merge(extra)
253
254
  if LauncherOptions.failed?(aggregate, state, launcher)
254
255
  field = aggregate.lifecycle.field
255
- why += ["#{aggregate.hecks_name} ended in the failure state #{state[field.to_sym].to_s.inspect}"]
256
+ why += [failure_sentence(aggregate, state, field)]
256
257
  end
257
258
  finish(JSON.pretty_generate(answer), why)
258
259
  end
259
260
 
261
+ # The sentence for a record that ended in a failure state: the state, then the record's own
262
+ # `refusal` when it keeps one, without the class name an adapter's failure carries.
263
+ def failure_sentence(aggregate, state, field)
264
+ sentence = "#{aggregate.hecks_name} ended in the failure state #{state[field.to_sym].to_s.inspect}"
265
+ refusal = state[:refusal]
266
+ refusal = refusal.value if refusal.respond_to?(:value)
267
+ refusal = refusal[:value] if refusal.is_a?(Hash)
268
+ return sentence if refusal.to_s.strip.empty?
269
+
270
+ "#{sentence}: #{refusal.to_s.strip.sub(/\A(\w+::)+\w+: /, '')}"
271
+ end
272
+
260
273
  # The answer and its status: 0 when nothing failed, else 1 with the reasons joined.
261
274
  def finish(text, reasons)
262
275
  reasons.empty? ? [text, 0] : [text, 1, reasons.join("\n")]
@@ -10,11 +10,10 @@ module Hecks
10
10
  # A `.world` that declares a `launcher` setting switches these on for that chapter; a
11
11
  # domain without one keeps the launcher's plain forms exactly:
12
12
  #
13
- # launcher "Launcher", run_keys: true, failure_states: %w[flagged failed],
14
- # names: { "mcp" => "serve_mcp" }
15
- #
16
13
  # - **run_keys** mints the `run` key of a creating command that was given none.
17
14
  # - **failure_states** are the lifecycle states `--wait` reports as a failure (exit 1).
15
+ # - **settled** lists the commands (`aggregate.command`) that always behave as if `--wait` was
16
+ # given, so a script gets their outcome in its exit status.
18
17
  # - **names** maps a launcher name to the command it stands for (see `CliProjector`).
19
18
  # - **streams** lists the questions `--stream` may tail, one JSON line per new entry.
20
19
  module LauncherOptions
@@ -87,6 +86,17 @@ module Hecks
87
86
  [rest, wait]
88
87
  end
89
88
 
89
+ # Whether a command always waits for its reactions, as if `--wait` had been given.
90
+ #
91
+ # @param launcher [Hash, nil] the chapter's `launcher` setting
92
+ # @param spec [Hash] the command's projected spec
93
+ # @return [Boolean] true for a command the setting lists under `settled`
94
+ def settled?(launcher, spec)
95
+ return false unless launcher && spec[:kind] == :command
96
+
97
+ Array(launcher[:settled]).map(&:to_s).include?(spec[:short].to_s)
98
+ end
99
+
90
100
  # Whether a question may be tailed with `--stream`: the chapter's `launcher` setting lists it
91
101
  # under `streams`, by the question's own name.
92
102
  #
@@ -343,7 +343,20 @@ module Hecks
343
343
  def answer_tool(name, args)
344
344
  return scope.refusal(name) unless scope.permits_tool?(name)
345
345
 
346
- call_tool(name, args)
346
+ call_tool(name, scope.with_default_domain(args))
347
+ end
348
+
349
+ # One line per allowed command: what it takes and the role it declares, read from the booted
350
+ # domain the door serves. Nil when the door serves no single domain or it will not boot, so
351
+ # `tools/list` still answers, with the commands as a bare list.
352
+ def command_guide
353
+ return unless scope.commands_mode? && scope.default_domain
354
+
355
+ McpGuideCache.remember(scope.default_domain, scope.allowed_commands) do
356
+ Storehouse.command_guide(boot(scope.default_domain), scope.allowed_commands)
357
+ end
358
+ rescue StandardError
359
+ nil
347
360
  end
348
361
 
349
362
  def tool_result(payload)
@@ -374,7 +387,8 @@ module Hecks
374
387
  nil # a notification — no id, no response
375
388
  when "tools/list"
376
389
  served = TOOLS.select { |tool| scope.permits_tool?(tool[:name]) }
377
- send_response(id, { tools: served.map { |tool| scope.present(tool) } })
390
+ guide = command_guide
391
+ send_response(id, { tools: served.map { |tool| scope.present(tool, guide) } })
378
392
  when "tools/call"
379
393
  send_response(id, tool_result(answer_tool(params["name"], params["arguments"] || {})))
380
394
  when "ping"
@@ -28,7 +28,8 @@ module Hecks
28
28
  # switch to, or flips a command from a preview to a change of state.
29
29
  DENIED_ARGUMENTS = %w[
30
30
  adapter artifact confirm expected from gem_only header health_path host no_wait npm_local
31
- npm_only output path payload payload_file port rust_binary scheme state_path stdio url write
31
+ npm_only output path payload payload_file port rust_binary scheme stage_dir state_path stdio url
32
+ write
32
33
  ].freeze
33
34
 
34
35
  # Argument names whose values are paths (a comma-separated list for some). A restricted door
@@ -201,18 +202,40 @@ module Hecks
201
202
  end
202
203
  end
203
204
 
204
- # The tool as `tools/list` shows it: on a commands door, `dispatch` names the commands it
205
- # serves, so a caller sees them as an enum instead of finding out by being refused.
205
+ # The one domain a restricted door serves, when it serves exactly one: a call may leave
206
+ # `domain:` out and get it. Nil for an unrestricted door and for one that names several.
207
+ #
208
+ # @return [String, nil] the domain path as `HECKS_DOOR_DOMAINS` named it, resolved
209
+ def default_domain
210
+ @allowed_domains.first if restricted? && @allowed_domains.size == 1
211
+ end
212
+
213
+ # The arguments with the default domain filled in when the caller left it out.
214
+ #
215
+ # @param args [Hash] a tool call's arguments
216
+ # @return [Hash] `args`, with `"domain"` set when the door has a default and none was given
217
+ def with_default_domain(args)
218
+ return args unless default_domain && args["domain"].to_s.strip.empty?
219
+
220
+ args.merge("domain" => default_domain)
221
+ end
222
+
223
+ # The tool as `tools/list` shows it. A door that serves one domain makes `domain:` optional.
224
+ # On a commands door, `dispatch` also names the commands it serves as an enum, so a caller
225
+ # sees them as typed choices instead of finding out by being refused, and says what each
226
+ # takes and which role it declares.
206
227
  #
207
228
  # @param tool [Hash] a tool definition from `McpDoor::TOOLS`
229
+ # @param guide [Array<String>, nil] one line per allowed command (`Storehouse.command_guide`)
208
230
  # @return [Hash] the definition, narrowed for this door
209
- def present(tool)
231
+ def present(tool, guide = nil)
232
+ tool = domain_optional(tool) if default_domain
210
233
  return tool unless commands_mode? && tool[:name] == "dispatch"
211
234
 
212
235
  properties = tool[:inputSchema][:properties]
213
236
  narrowed = properties.merge(command: with_enum(properties[:command]),
214
237
  steps: properties[:steps].merge(items: step_items(properties[:steps][:items])))
215
- tool.merge(description: "#{tool[:description]} This door dispatches only: #{allowed_commands.join(', ')}.",
238
+ tool.merge(description: dispatch_description(tool[:description], guide),
216
239
  inputSchema: tool[:inputSchema].merge(properties: narrowed))
217
240
  end
218
241
 
@@ -287,6 +310,23 @@ module Hecks
287
310
  File.join(File.realpath(current), *missing)
288
311
  end
289
312
 
313
+ def domain_optional(tool)
314
+ schema = tool[:inputSchema]
315
+ return tool unless schema[:properties].key?(:domain)
316
+
317
+ note = "Optional: this door serves one domain, #{default_domain}, and uses it when you leave this out."
318
+ properties = schema[:properties].merge(domain: schema[:properties][:domain].merge(description: note))
319
+ tool.merge(inputSchema: schema.merge(properties: properties, required: schema[:required] - ["domain"]))
320
+ end
321
+
322
+ def dispatch_description(base, guide)
323
+ intro = "#{base} This door dispatches only the commands below, each with the role it declares: pass " \
324
+ "that role as `role`. A command that takes `run` takes a key you choose, and the call answers " \
325
+ "the record as it stands once its reactions have run; an argument marked * is required."
326
+ entries = Array(guide).empty? ? allowed_commands : guide
327
+ ([intro] + entries.map { |entry| "- #{entry}" }).join("\n")
328
+ end
329
+
290
330
  def with_enum(property)
291
331
  property.merge(enum: allowed_commands)
292
332
  end
@@ -0,0 +1,75 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "json"
4
+ require_relative "../cache_dir"
5
+ require_relative "../storehouse"
6
+ require_relative "../bluebook/meta_validator/verdict_cache"
7
+
8
+ module Hecks
9
+ module Doors
10
+ # Remembers the command guide a commands door builds for `tools/list`, so a door that starts
11
+ # again does not boot its domain to describe its commands. A client waits only a moment for a
12
+ # server's tools, and a boot at that point costs more than the wait.
13
+ #
14
+ # An entry is keyed by the domain directory's files, a digest of the hecks code, and the
15
+ # allowed commands, so a change to any of them builds a new guide. The file is JSON, read only
16
+ # from a directory and a file that only this user can write (the verdict cache's own check),
17
+ # and only when it is a list of strings: a guide is text an agent reads as instructions.
18
+ module McpGuideCache
19
+ module_function
20
+
21
+ # The guide for a domain and its allowed commands, from the cache or else from the block,
22
+ # which is then remembered. A cache that cannot be written is not an error: the next start
23
+ # builds the guide again.
24
+ #
25
+ # @param domain [String] the domain directory the guide describes
26
+ # @param commands [Array<String>] the allowed command names
27
+ # @yieldreturn [Array<String>, nil] the guide, built when none is remembered
28
+ # @return [Array<String>, nil] the guide
29
+ def remember(domain, commands)
30
+ file = File.join(dir, "#{key(domain, commands)}.json")
31
+ cached = read(file)
32
+ return cached if cached
33
+
34
+ guide = yield
35
+ write(file, guide) if guide.is_a?(Array) && guide.any?
36
+ guide
37
+ end
38
+
39
+ # @return [String] the directory entries live in
40
+ def dir = Hecks::CacheDir.path("mcp_door_guides")
41
+
42
+ # @param domain [String] the domain directory
43
+ # @param commands [Array<String>] the allowed command names
44
+ # @return [String] a hex SHA-256 over the domain's files, the hecks code and the commands
45
+ def key(domain, commands)
46
+ parts = [Storehouse.fingerprint(domain), Bluebook::MetaValidator::VerdictCache.code_digest,
47
+ commands.sort.join(",")]
48
+ Digest::SHA256.hexdigest(parts.join("\0"))
49
+ end
50
+
51
+ # @param file [String] an entry's path
52
+ # @return [Array<String>, nil] the guide, or nil when absent, untrusted or malformed
53
+ def read(file)
54
+ return unless File.file?(file) && Bluebook::MetaValidator::VerdictCache.trusted?(file)
55
+
56
+ guide = JSON.parse(File.read(file))
57
+ guide if guide.is_a?(Array) && guide.all?(String)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ # @param file [String] an entry's path
63
+ # @param guide [Array<String>] the guide to remember
64
+ # @return [void]
65
+ def write(file, guide)
66
+ FileUtils.mkdir_p(dir, mode: 0o700)
67
+ temp = "#{file}.#{Process.pid}.tmp"
68
+ File.write(temp, JSON.generate(guide), perm: 0o600)
69
+ File.rename(temp, file)
70
+ rescue StandardError
71
+ FileUtils.rm_f(temp) if temp
72
+ end
73
+ end
74
+ end
75
+ end
data/lib/hecks/doors.rb CHANGED
@@ -2,7 +2,7 @@ module Hecks
2
2
  # The class-free public surface, installed per boot by `Runtime::Loader.bind_runtime`.
3
3
  module Doors
4
4
  # The removal release of the `Facade` and `install_facade:` spellings.
5
- REMOVAL = "3.2.0".freeze
5
+ REMOVAL = "3.4.0".freeze
6
6
 
7
7
  module_function
8
8