hecks 3.0.5 → 3.1.1

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +24 -12
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +32 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  12. data/lib/hecks/cli/doc_coverage.rb +1 -1
  13. data/lib/hecks/cli/domain_stub.rb +128 -0
  14. data/lib/hecks/cli/domain_writer.rb +39 -0
  15. data/lib/hecks/cli/interview_agent.rb +59 -0
  16. data/lib/hecks/cli/interview_draft.rb +246 -0
  17. data/lib/hecks/cli/interview_run.rb +87 -0
  18. data/lib/hecks/cli/interview_session.rb +299 -0
  19. data/lib/hecks/cli/model_check.rb +1 -1
  20. data/lib/hecks/cli/project_cli.rb +51 -29
  21. data/lib/hecks/cli/reference.rb +2 -1
  22. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  23. data/lib/hecks/cli/seed_semantics_corpus.rb +3 -3
  24. data/lib/hecks/cli/spec_example.rb +1 -1
  25. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  26. data/lib/hecks/corpus.rb +12 -8
  27. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  28. data/lib/hecks/deploy/oidc.json +5 -0
  29. data/lib/hecks/doc/reference.rb +4 -3
  30. data/lib/hecks/doors/cli_door.rb +13 -13
  31. data/lib/hecks/doors/cli_runner.rb +53 -33
  32. data/lib/hecks/doors/launcher_options.rb +5 -5
  33. data/lib/hecks/doors/mcp_door.rb +40 -18
  34. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  35. data/lib/hecks/doors/usage_cache.rb +123 -0
  36. data/lib/hecks/doors.rb +1 -1
  37. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  38. data/lib/hecks/forms/banking_presentation.rb +1 -1
  39. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  40. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  41. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  42. data/lib/hecks/framework.rb +1 -1
  43. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  44. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  45. data/lib/hecks/fuzzing/differential.rb +4 -2
  46. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  47. data/lib/hecks/fuzzing/form_census.rb +2 -2
  48. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  49. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  50. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  51. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  52. data/lib/hecks/gate/stages.yml +5 -5
  53. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  54. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  55. data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
  56. data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
  57. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  58. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  59. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  60. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  61. data/lib/hecks/hecks/adapters/terminal.rb +15 -0
  62. data/lib/hecks/hecks/codebase.bluebook +4 -3
  63. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  64. data/lib/hecks/hecks/custodian.bluebook +81 -3
  65. data/lib/hecks/hecks/hecks.bluebook +18 -0
  66. data/lib/hecks/hecks/hecks.hecksagon +16 -4
  67. data/lib/hecks/hecks/hecks.world +7 -6
  68. data/lib/hecks/hecks/site.behaviors +17 -0
  69. data/lib/hecks/hecks/site.world +10 -0
  70. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  71. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  72. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  73. data/lib/hecks/projections/deploy/box/settings.rb +324 -0
  74. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +268 -0
  75. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +68 -0
  76. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  77. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +181 -0
  78. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
  79. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  80. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
  81. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
  82. data/lib/hecks/projections/deploy/box.rb +553 -0
  83. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  84. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  85. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  86. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  87. data/lib/hecks/projections/diagrams.rb +2 -2
  88. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  89. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  90. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  91. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  92. data/lib/hecks/projections/site/edge.rb +136 -0
  93. data/lib/hecks/projections/site/regions.rb +44 -0
  94. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  95. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  96. data/lib/hecks/projections/site/table/checks.rb +124 -0
  97. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  98. data/lib/hecks/projections/site/table.rb +98 -0
  99. data/lib/hecks/projections.rb +2 -0
  100. data/lib/hecks/projector/cli_projector.rb +153 -111
  101. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  102. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  103. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  104. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  105. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  106. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  107. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  108. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  109. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  110. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  111. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  112. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  113. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  114. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  115. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  116. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  117. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  118. data/lib/hecks/runtime/loader.rb +31 -8
  119. data/lib/hecks/runtime/registry/verification.rb +15 -29
  120. data/lib/hecks/runtime/registry.rb +14 -20
  121. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  122. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  123. data/lib/hecks/rust_build/domain_name.rb +26 -0
  124. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  125. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  126. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  127. data/lib/hecks/rust_build/project_rust.rb +57 -24
  128. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  129. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  130. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  131. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  132. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  133. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  134. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  135. data/lib/hecks/storehouse.rb +37 -7
  136. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  137. data/lib/hecks/three_zero/forms.yml +88 -88
  138. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  139. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  140. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  141. data/lib/hecks/tools/ci_gates.rb +1 -1
  142. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  143. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  144. data/lib/hecks/tools/sequence_script.rb +2 -2
  145. data/lib/hecks/tools/site_routes.rb +152 -0
  146. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  147. data/lib/hecks/tools.rb +1 -0
  148. data/lib/hecks/version.rb +1 -1
  149. data/lib/hecks.rb +1 -0
  150. data/rust/build/src/cargo_sync.rs +1 -1
  151. data/rust/build/src/pipeline.rs +23 -16
  152. data/rust/build/src/resolve.rs +39 -6
  153. data/rust/codegen/src/json_codec.rs +3 -0
  154. data/rust/codegen/src/naming.rs +4 -0
  155. data/rust/codegen/src/types.rs +45 -0
  156. data/rust/host/HECKS_RELEASE +1 -1
  157. data/rust/host/src/api.rs +1 -1
  158. data/rust/host/src/dispatch.rs +87 -7
  159. data/rust/host/src/needs.rs +111 -0
  160. data/rust/parser/src/keywords.rs +7 -6
  161. data/rust/parser/src/main.rs +4 -3
  162. data/rust/parser/src/parse/chapter.rs +1 -1
  163. data/rust/parser/src/parse/hecksagon.rs +25 -10
  164. data/rust/project/mutations.rb +4 -30
  165. data/rust/project/naming.rb +8 -28
  166. data/rust/project/write_if_changed.rb +4 -80
  167. data/rust/project_rust_pipeline.rb +16 -12
  168. data/rust/src/kernel/cli.rs +2 -0
  169. data/rust/src/kernel/needs.rs +74 -1
  170. metadata +51 -2
@@ -0,0 +1,39 @@
1
+ require "fileutils"
2
+
3
+ module Hecks
4
+ module CLI
5
+ # Writes the files `hecks init` and `hecks interview` produce, never replacing one (ADR 0087).
6
+ #
7
+ # Every target is checked before the first is written, so a refusal leaves nothing half-written.
8
+ module DomainWriter
9
+ module_function
10
+
11
+ # @param files [Hash{String => String}] each file's path under the target, and its text
12
+ # @param target [String] the absolute path of the domain directory
13
+ # @return [Array<String>] the paths written, relative to the target
14
+ # @raise [ArgumentError] when any file, or a bluebook the files would add, is already there
15
+ def write!(files, target)
16
+ taken = taken(files, target)
17
+ raise ArgumentError, "nothing written; already there: #{taken.join(', ')}" unless taken.empty?
18
+
19
+ files.each do |path, text|
20
+ full = File.join(target, path)
21
+ FileUtils.mkdir_p(File.dirname(full))
22
+ File.write(full, text)
23
+ end
24
+ files.keys
25
+ end
26
+
27
+ # @param files [Hash{String => String}] the files about to be written
28
+ # @param target [String] the domain directory
29
+ # @return [Array<String>] the paths in the way, relative to the target
30
+ def taken(files, target)
31
+ taken = files.keys.select { |path| File.exist?(File.join(target, path)) }
32
+ if files.keys.any? { |path| path.match?(%r{\Abluebook/[^/]+\.bluebook\z}) }
33
+ taken |= Dir.glob(File.join(target, "bluebook", "*.bluebook")).map { |full| full.delete_prefix("#{target}/") }
34
+ end
35
+ taken
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,59 @@
1
+ require_relative "../runtime/registry"
2
+ require_relative "../ports/agent"
3
+
4
+ module Hecks
5
+ module CLI
6
+ # The interviewer an interview session talks to: the agent port, resolved against a registry
7
+ # that holds one adapter (ADR 0088). The session never sees the adapter, only validated
8
+ # questions and proposals, so a spec can stand a scripted adapter in for the real one.
9
+ class InterviewAgent
10
+ PORT = File.expand_path("../ports/agent.port", __dir__)
11
+ CLAUDE_CODE = File.expand_path("../adapters/driven/claude_code.adapter", __dir__)
12
+
13
+ # The agent a real interview uses: the developer's own `claude`, no key or model of hecks's.
14
+ #
15
+ # @return [InterviewAgent] one resolved against the Claude Code adapter
16
+ def self.claude
17
+ require_relative "../adapters/driven/claude_code"
18
+ new(registry_with(CLAUDE_CODE))
19
+ end
20
+
21
+ # @param adapter_files [Array<String>] adapter declarations to load beside the agent port
22
+ # @return [Runtime::Registry] a registry in which exactly those adapters implement the port
23
+ def self.registry_with(*adapter_files)
24
+ registry = Runtime::Registry.new
25
+ Hecks.with_registry(registry) do
26
+ Kernel.load(PORT)
27
+ adapter_files.each { |file| Kernel.load(file) }
28
+ end
29
+ registry
30
+ end
31
+
32
+ # @return [Boolean] whether a `claude` executable is on the path, so a model can be tried
33
+ def self.claude_available?
34
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "claude")) }
35
+ end
36
+
37
+ # @param registry [Runtime::Registry] a registry with the agent port and one adapter for it
38
+ def initialize(registry)
39
+ @registry = registry
40
+ end
41
+
42
+ # @param state [Hash] the interview so far, as the session describes it
43
+ # @param asked [Array<String>] the questions already asked
44
+ # @return [Ports::Agent::Question, nil] the next question, or nil if the adapter offered none
45
+ # @raise [Ports::Agent::Unavailable, Ports::Agent::ValidationError] when it could not answer
46
+ def question(state:, asked:)
47
+ Ports::Agent.ask(@registry, state: state, asked: asked).first
48
+ end
49
+
50
+ # @param prose [String] what the expert just said, as the developer typed it
51
+ # @param state [Hash] the interview so far
52
+ # @return [Array<Ports::Agent::Proposal>] the findings the sentence names; none for a question
53
+ # @raise [Ports::Agent::Unavailable, Ports::Agent::ValidationError] when it could not answer
54
+ def proposals(prose:, state:)
55
+ Ports::Agent.interpret(@registry, prose: prose, state: state)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,246 @@
1
+ require_relative "../naming"
2
+ require_relative "domain_stub"
3
+
4
+ module Hecks
5
+ module CLI
6
+ # Turns an interview's accepted findings into the files of a first domain (ADR 0088).
7
+ #
8
+ # Pure: it answers the text of each file and writes none, so the command that runs an interview
9
+ # can check every target before it writes the first, and a spec can boot the result. It shares
10
+ # the files around a bluebook with `hecks init` (`DomainStub.support_files`) and renders the
11
+ # bluebook itself, from what the developer accepted, where `init` writes a fixed stub.
12
+ #
13
+ # Only `accepted` findings reach the bluebook; every finding reaches the record. A rule is never
14
+ # turned into code from prose: it is a comment citing its source, to become a `given` by hand.
15
+ module InterviewDraft
16
+ ACCEPTED = "accepted".freeze
17
+
18
+ # The pattern every generated string field carries: it refuses a blank value.
19
+ PATTERN = %q('[^ \t\n\r]').freeze
20
+
21
+ # What stands in for a creating command nothing in the interview named.
22
+ STUB_NOTE = " # TODO: no action that creates was accepted; replace Create with the real one.".freeze
23
+ private_constant :PATTERN, :STUB_NOTE
24
+
25
+ module_function
26
+
27
+ # The files a first interview writes: the bluebook, the files around it, and the record.
28
+ #
29
+ # @param interview [Hash] the interview, with at least one accepted thing
30
+ # @param adapter [String, nil] an adapter in `DomainStub::ADAPTERS`; its default when nil
31
+ # @return [Hash{String => String}] each file's path under the domain directory, and its text
32
+ # @raise [ArgumentError] when nothing was accepted, or the subject or adapter is refused
33
+ def files(interview, adapter: nil)
34
+ name = interview.fetch(:subject)
35
+ support = DomainStub.support_files(name: name, adapter: adapter)
36
+ { "bluebook/#{Naming.snake(name)}.bluebook" => bluebook(interview) }
37
+ .merge(support)
38
+ .merge(record_file(interview))
39
+ end
40
+
41
+ # What a later interview writes: its record, ending in the aggregates and commands it would
42
+ # add, for a developer to merge. An existing domain is never touched (ADR 0088).
43
+ #
44
+ # @param interview [Hash] the interview
45
+ # @return [Hash{String => String}] the one file, `interviews/<reference>.md`
46
+ def additions(interview)
47
+ record_file(interview, "\n## Proposed additions\n\nMerge by hand into the domain's bluebook.\n\n" \
48
+ "```ruby\n#{fragment(interview)}```\n")
49
+ end
50
+
51
+ # The interview's record: every exchange in order, and every finding with how it was decided.
52
+ #
53
+ # @param interview [Hash] the interview
54
+ # @return [String] Markdown
55
+ def record(interview)
56
+ lines = ["# Interview #{interview.fetch(:reference)}: #{interview.fetch(:subject)}", "",
57
+ "Expert: #{interview.fetch(:expert)}", "", "## Exchanges", ""]
58
+ interview.fetch(:exchanges).each_with_index { |ex, i| lines.concat(exchange_lines(ex, i + 1)) }
59
+ lines << "" << "## Findings" << ""
60
+ lines.concat(finding_lines(interview))
61
+ "#{lines.join("\n")}\n"
62
+ end
63
+
64
+ # Reads the plain interview out of an `Interview` record booted from the SME chapter.
65
+ #
66
+ # @param interview [Object] the booted record
67
+ # @return [Hash] the plain interview this module takes: `reference`, `subject`, `expert`,
68
+ # `exchanges` (`question`, `answer`, `topic`), and `things`, `actions` and `rules`, each a
69
+ # finding with its `number`, `source` and `status` and the fields of its kind
70
+ def from_record(interview)
71
+ { reference: plain(interview.reference), subject: plain(interview.subject), expert: plain(interview.expert),
72
+ exchanges: interview.exchanges.map { |ex| slice(ex, :question, :answer, :topic) },
73
+ things: interview.thing_findings.map { |f| slice(f, :number, :name, :identifier, :source, :status) },
74
+ actions: interview.action_findings.map { |f| slice(f, :number, :name, :thing, :event, :creates, :source, :status) },
75
+ rules: interview.rule_findings.map { |f| slice(f, :number, :statement, :source, :status) } }
76
+ end
77
+
78
+ # @api private
79
+ def record_file(interview, tail = "")
80
+ { "interviews/#{file_stem(interview.fetch(:reference))}.md" => record(interview) + tail }
81
+ end
82
+
83
+ # @api private
84
+ def file_stem(reference) = reference.to_s.gsub(/[^A-Za-z0-9._-]/, "_")
85
+
86
+ # @api private
87
+ def bluebook(interview)
88
+ name = interview.fetch(:subject)
89
+ things = accepted(interview, :things)
90
+ raise ArgumentError, "nothing was accepted: a draft needs at least one thing" if things.empty?
91
+
92
+ out = ["Hecks.bluebook #{name.inspect} do"]
93
+ out.concat(header(interview))
94
+ out << %( vision "TODO: say in one sentence what #{name} is for.")
95
+ out << " supporting" << ""
96
+ out.concat(rule_lines(interview))
97
+ out << fragment(interview).chomp << "end"
98
+ "#{out.join("\n")}\n"
99
+ end
100
+
101
+ # The aggregates, with their commands: the part a later interview offers to merge.
102
+ # @api private
103
+ def fragment(interview)
104
+ things = accepted(interview, :things)
105
+ actions = accepted(interview, :actions)
106
+ placed = things.map { |t| word(t[:name]) }
107
+ blocks = things.map { |thing| aggregate(interview, thing, actions) }
108
+ loose = actions.reject { |a| placed.include?(word(a[:thing])) }
109
+ blocks << unplaced(interview, loose) unless loose.empty?
110
+ "#{blocks.join("\n\n")}\n"
111
+ end
112
+
113
+ # @api private
114
+ def header(interview)
115
+ [" # Drafted from interview #{interview[:reference]} with #{interview[:expert]}. Each name below cites the",
116
+ " # exchange it came from; edit freely."]
117
+ end
118
+
119
+ # @api private
120
+ def rule_lines(interview)
121
+ rules = accepted(interview, :rules)
122
+ return [] if rules.empty?
123
+
124
+ [" # RULES the expert gave, for you to turn into `given` clauses:"] +
125
+ rules.map { |r| " # - #{cite(interview, r)} #{one_line(r[:statement])}" } + [""]
126
+ end
127
+
128
+ # @api private
129
+ def aggregate(interview, thing, actions)
130
+ name = word(thing[:name])
131
+ field = Naming.snake(word(thing[:identifier]))
132
+ field_type = identifier_type(name, field)
133
+ mine = actions.select { |a| word(a[:thing]) == name }
134
+ creating = mine.select { |a| a[:creates] }
135
+ rest = mine - creating
136
+ lines = [" # #{source_note(interview, thing)}".rstrip,
137
+ " aggregate #{name.inspect} do",
138
+ " description #{"TODO: describe what a #{name} is.".inspect}", "",
139
+ " identified_by :#{field}", "",
140
+ " attribute :#{field}, #{field_type}", "",
141
+ " value_object #{field_type.inspect} do",
142
+ " attribute :value, String, pattern: #{PATTERN}",
143
+ " end"]
144
+ creating = [stub_create(name)] if creating.empty?
145
+ creating.each { |action| lines.concat(creating_command(interview, action, field, field_type)) }
146
+ rest.each { |action| lines.concat(mutating_command(interview, action, name)) }
147
+ (lines << " end").join("\n")
148
+ end
149
+
150
+ # The creating command nothing in the interview named: a stub, marked for the developer.
151
+ # @api private
152
+ def stub_create(name) = { name: "Create", event: "#{name}Created", stub: true }
153
+
154
+ # @api private
155
+ def creating_command(interview, action, field, field_type)
156
+ verb = word(action[:name])
157
+ note = action[:stub] ? STUB_NOTE : " # #{source_note(interview, action)}"
158
+ ["", note, " command #{verb.inspect} do", " goal #{"TODO: say what #{verb} does".inspect}", "",
159
+ " attribute :#{field}, #{field_type}", "", " emits #{word(action[:event]).inspect}", " end"]
160
+ end
161
+
162
+ # @api private
163
+ def mutating_command(interview, action, name)
164
+ verb = word(action[:name])
165
+ ["", " # #{source_note(interview, action)}", " command #{verb.inspect} do",
166
+ " goal #{"TODO: say what #{verb} does".inspect}", "", " reference_to #{name}", "",
167
+ " emits #{word(action[:event]).inspect}", " end"]
168
+ end
169
+
170
+ # An action whose thing nobody accepted has nowhere to go; it is kept as a comment, not lost.
171
+ # @api private
172
+ def unplaced(interview, actions)
173
+ lines = [" # UNPLACED: these accepted actions name a thing that was not accepted. Place each, or drop it."]
174
+ actions.each do |action|
175
+ lines << " # - #{cite(interview, action)} #{word(action[:name])} on #{action[:thing]}, announcing #{action[:event]}"
176
+ end
177
+ lines.join("\n")
178
+ end
179
+
180
+ # @api private
181
+ def identifier_type(name, field)
182
+ type = Naming.pascal(field)
183
+ type == name ? "#{type}Id" : type
184
+ end
185
+
186
+ # @api private
187
+ def exchange_lines(exchange, number)
188
+ lines = ["#{number}. **Q:** #{one_line(exchange[:question])}", " **A:** #{one_line(exchange[:answer])}"]
189
+ topic = exchange[:topic].to_s
190
+ lines << " _Topic: #{one_line(topic)}_" unless topic.empty?
191
+ lines
192
+ end
193
+
194
+ # @api private
195
+ def finding_lines(interview)
196
+ lines = interview.fetch(:things).map do |f|
197
+ "- Thing #{f[:number]}, #{f[:status]}: **#{f[:name]}**, identified by `#{f[:identifier]}` (exchange #{f[:source]})"
198
+ end
199
+ lines += interview.fetch(:actions).map do |f|
200
+ creates = f[:creates] ? ", creates it" : ""
201
+ "- Action #{f[:number]}, #{f[:status]}: **#{f[:name]}** on #{f[:thing]}, " \
202
+ "announcing `#{f[:event]}`#{creates} (exchange #{f[:source]})"
203
+ end
204
+ lines + interview.fetch(:rules).map do |f|
205
+ "- Rule #{f[:number]}, #{f[:status]}: #{one_line(f[:statement])} (exchange #{f[:source]})"
206
+ end
207
+ end
208
+
209
+ # @api private
210
+ def accepted(interview, kind) = interview.fetch(kind).select { |finding| finding[:status] == ACCEPTED }
211
+
212
+ # A free-text name as a PascalCase word safe to write into Ruby: `lend a book` is `LendABook`.
213
+ # @api private
214
+ def word(text) = text.to_s.split(/[^A-Za-z0-9]+/).reject(&:empty?).map { |part| part.sub(/\A./, &:upcase) }.join
215
+
216
+ # @api private
217
+ def one_line(text) = text.to_s.gsub(/\s+/, " ").strip
218
+
219
+ # @api private
220
+ def cite(interview, finding) = "#{interview[:reference]} ##{finding[:source]}:"
221
+
222
+ # Where a name came from: the interview, the exchange, and a short quotation of the answer.
223
+ # @api private
224
+ def source_note(interview, finding)
225
+ exchange = interview.fetch(:exchanges)[finding[:source].to_i - 1]
226
+ note = "from #{cite(interview, finding)}"
227
+ return note unless exchange
228
+
229
+ text = one_line(exchange[:answer])
230
+ text = "#{text[0, 77]}..." if text.length > 80
231
+ "#{note} #{text.inspect}"
232
+ end
233
+
234
+ # @api private
235
+ def plain(value)
236
+ return nil if value.nil?
237
+
238
+ found = value.respond_to?(:to_h) && !value.is_a?(String) ? value.to_h : value
239
+ found.is_a?(Hash) && found.key?(:value) ? found[:value] : found
240
+ end
241
+
242
+ # @api private
243
+ def slice(record, *keys) = keys.to_h { |key| [key, plain(record[key])] }
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,87 @@
1
+ require_relative "../../hecks"
2
+ require_relative "domain_stub"
3
+ require_relative "domain_writer"
4
+ require_relative "interview_agent"
5
+ require_relative "interview_session"
6
+
7
+ module Hecks
8
+ module CLI
9
+ # The command behind `hecks interview <Name>` (ADR 0088): boots the SME chapter on demand, holds
10
+ # the interview at the terminal, then writes the first domain, or the next interview's proposed
11
+ # additions, and says what to type next. Nothing is written unless the interview concludes.
12
+ module InterviewRun
13
+ SME = File.expand_path("../sme", __dir__)
14
+
15
+ module_function
16
+
17
+ # @param name [String] the domain being discovered, as its bluebook will spell it
18
+ # @param adapter [String, nil] a persistence adapter from `DomainStub::ADAPTERS`
19
+ # @param dir [String, nil] where the domain goes; the snake-cased name here when nil
20
+ # @param expert [String, nil] who is interviewed; asked for when nil
21
+ # @param use_ai [Boolean] false runs the plain prompts (`--no-ai`)
22
+ # @param input [#gets] where the developer types
23
+ # @param output [#puts, #print] where the session speaks
24
+ # @param agent [#question, #proposals, nil] an interviewer to use instead of the real one
25
+ # @param runtime [Runtime, nil] a booted SME chapter to use instead of booting one
26
+ # @return [String] the report that was printed, or "" when nothing was written
27
+ # @raise [ArgumentError] when the name or adapter is refused, or a file would be replaced
28
+ def call(name:, adapter: nil, dir: nil, expert: nil, use_ai: true, input: $stdin, output: $stdout, agent: nil, runtime: nil)
29
+ DomainStub.support_files(name: name, adapter: adapter)
30
+ target = File.expand_path(dir || DomainStub.directory(name), Dir.pwd)
31
+ expert = who(expert, input, output)
32
+ result = InterviewSession.new(runtime: runtime || Hecks.boot(SME), agent: interviewer(use_ai, agent, output),
33
+ input: input, output: output, reference: next_reference(target),
34
+ subject: name, expert: expert).call
35
+ return "" unless result.status == :concluded
36
+
37
+ report(target, DomainWriter.write!(files(result.interview, adapter, target), target))
38
+ .tap { |text| output.puts(text) }
39
+ end
40
+
41
+ # @api private
42
+ def who(expert, input, output)
43
+ return expert unless expert.to_s.strip.empty?
44
+
45
+ output.print("Who is the expert? ")
46
+ input.gets.to_s.strip.then { |text| text.empty? ? "the expert" : text }
47
+ end
48
+
49
+ # @api private
50
+ def interviewer(use_ai, agent, output)
51
+ return nil unless use_ai
52
+ return agent if agent
53
+ return InterviewAgent.claude if InterviewAgent.claude_available?
54
+
55
+ output.puts("`claude` is not installed, so this runs without the AI. Use --no-ai to skip this note.")
56
+ nil
57
+ end
58
+
59
+ # The first interview names a domain; a later one finds a bluebook there and offers additions.
60
+ # @api private
61
+ def files(interview, adapter, target)
62
+ return InterviewDraft.additions(interview) unless Dir.glob(File.join(target, "bluebook", "*.bluebook")).empty?
63
+
64
+ InterviewDraft.files(interview, adapter: adapter)
65
+ end
66
+
67
+ # @api private
68
+ def next_reference(target)
69
+ "INT-#{Dir.glob(File.join(target, 'interviews', 'INT-*.md')).length + 1}"
70
+ end
71
+
72
+ # @api private
73
+ def report(target, written)
74
+ where = target.delete_prefix("#{Dir.pwd}/")
75
+ lines = ["wrote #{written.length} #{written.length == 1 ? 'file' : 'files'} in #{where}/:"] +
76
+ written.sort.map { |path| " #{path}" }
77
+ lines << "" << "next:"
78
+ if written.any? { |path| path.start_with?("bluebook/") }
79
+ lines << " hecks docs #{where}/bluebook" << " hecks console subject=#{where}"
80
+ else
81
+ lines << " merge the proposed additions in #{where}/#{written.first} into the domain's bluebook"
82
+ end
83
+ lines.join("\n")
84
+ end
85
+ end
86
+ end
87
+ end