hecks 3.1.0 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +1 -1
  3. data/lib/hecks/chapters.rb +4 -3
  4. data/lib/hecks/cli/domain_writer.rb +39 -0
  5. data/lib/hecks/cli/interview_agent.rb +59 -0
  6. data/lib/hecks/cli/interview_run.rb +87 -0
  7. data/lib/hecks/cli/interview_session.rb +299 -0
  8. data/lib/hecks/corpus.rb +1 -0
  9. data/lib/hecks/doors/cli_runner.rb +1 -1
  10. data/lib/hecks/doors/usage_cache.rb +123 -0
  11. data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
  12. data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
  13. data/lib/hecks/hecks/adapters/terminal.rb +15 -0
  14. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  15. data/lib/hecks/hecks/custodian.bluebook +36 -0
  16. data/lib/hecks/hecks/hecks.bluebook +18 -0
  17. data/lib/hecks/hecks/hecks.hecksagon +8 -2
  18. data/lib/hecks/hecks/hecks.world +3 -2
  19. data/lib/hecks/projections/deploy/box/settings.rb +80 -4
  20. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -5
  22. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +17 -1
  23. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
  24. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +1 -1
  25. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
  26. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
  27. data/lib/hecks/projections/deploy/box.rb +185 -19
  28. data/lib/hecks/projector/cli_projector.rb +38 -14
  29. data/lib/hecks/runtime/loader.rb +31 -8
  30. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  31. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  32. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  33. data/lib/hecks/version.rb +1 -1
  34. data/lib/hecks.rb +1 -0
  35. data/rust/codegen/src/json_codec.rs +3 -0
  36. data/rust/codegen/src/naming.rs +4 -0
  37. data/rust/codegen/src/types.rs +45 -0
  38. data/rust/host/HECKS_RELEASE +1 -1
  39. metadata +15 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29694d3639f1ad69a3d0a81bf4e6494c5a6d60a88ca9897af3a01224944d654e
4
- data.tar.gz: b9fb1376266fe98b2d09c6127bbbbe3a954c11e9441c3f01976e54f44b63eaf7
3
+ metadata.gz: e8bfb40ad9e9760533d77e179154821128fee28930877e35118b5e87c5c32731
4
+ data.tar.gz: 49fd0e562e846d8633c11101d7b11c6d751089c8ca0f281907f1c669e1d19438
5
5
  SHA512:
6
- metadata.gz: 6eea28ed2426948aeca13df88967f06a5d64acf29dbb376a16b53d82367e90383f3646928ee4940ff9d93969ea7721ed29fb4467096eb8a2b88bc14f7f7ede47
7
- data.tar.gz: 52d5f117ce1b1bae946f939dad3c73567a44c5c466257c37cbaf5bb7bcecb0cb3a3ea92e9c41b3edde78efbdb674669d08f56305287f46313c1cb4ce813d3042
6
+ metadata.gz: e584bed9d83942763f583530d44910d6a988c7b55af6a4177f257ba017562957f373ea7ea726340b7ae845e2811d7217665ab6815991ae9cffb0e6cb437928c2
7
+ data.tar.gz: 6cac69c95c6dedc28736d5401d64a6597327c27b8d3ed1909c225ad6191c4cb0cac929ac9f3896f5c648c890d75b10d6d4b3d5431498e82e560bd682f2dc312d
data/exe/hecks CHANGED
@@ -24,7 +24,7 @@ end
24
24
  # These commands keep nothing worth a database, so they run on Memory unless told otherwise.
25
25
  # A person is at them: they wait for their result, say why when they are refused, and print
26
26
  # no record when they end well.
27
- MEMORY_COMMANDS = %w[console init].freeze
27
+ MEMORY_COMMANDS = %w[console init interview].freeze
28
28
  ENV["HECKS_ENVIRONMENT"] ||= "memory" if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))
29
29
  ARGV << "--wait" if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!")) && !ARGV.include?("--wait")
30
30
 
@@ -5,8 +5,8 @@ require_relative "bluebook/meta_validator"
5
5
 
6
6
  module Hecks
7
7
  # The chapters the gem carries that a hecksagon can attach by name (ADR 0080): the language
8
- # declared in itself, Expression, Tenancy, Deploy, Site and QualityControl. Framework members stay
9
- # in `Framework`; `table` and `attach!` find a name across both.
8
+ # declared in itself, Expression, Tenancy, Deploy, Site, Tickets and QualityControl. Framework
9
+ # members stay in `Framework`; `table` and `attach!` find a name across both.
10
10
  #
11
11
  # A chapter is named by the `Hecks.bluebook "Name"` header of its files, and may span several.
12
12
  # Beside its bluebook a chapter may carry what every hecksagon attaching it needs, whatever
@@ -15,7 +15,8 @@ module Hecks
15
15
  module Chapters
16
16
  # Where attachable chapters live, relative to `lib/hecks/`.
17
17
  GLOBS = %w[language/**/*.bluebook grammar/expression.bluebook tenancy/bluebook/*.bluebook
18
- deploy/bluebook/*.bluebook site/bluebook/*.bluebook quality_control/*.bluebook].freeze
18
+ deploy/bluebook/*.bluebook site/bluebook/*.bluebook tickets/bluebook/*.bluebook
19
+ quality_control/*.bluebook].freeze
19
20
 
20
21
  # Every attachable chapter, by name, with the files that declare it.
21
22
  #
@@ -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,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
@@ -0,0 +1,299 @@
1
+ require_relative "interview_draft"
2
+
3
+ module Hecks
4
+ module CLI
5
+ # One interview, held at a terminal (ADR 0088).
6
+ #
7
+ # hecks drives: it asks the agent for a question, shows it, reads the answer the developer typed
8
+ # back from the expert, records it, asks the agent what the answer names as findings, and puts
9
+ # each to the developer to accept or reject. The agent never dispatches anything. Every SME
10
+ # command is dispatched here, so the domain's own rules are the guard on the interview.
11
+ #
12
+ # With no agent (`--no-ai`), or for a turn the agent could not answer, a plain prompt stands in:
13
+ # a fixed question, and findings typed by the developer.
14
+ class InterviewSession
15
+ NOTICE = "Your answers are sent to a model through your own `claude` login. " \
16
+ "Run with --no-ai to leave it out.".freeze
17
+
18
+ FALLBACK_QUESTIONS = [
19
+ "What is the main thing this business keeps track of?",
20
+ "What happens to it, from the beginning to the end?",
21
+ "What must never happen?",
22
+ "Is there anything else I should know?"
23
+ ].freeze
24
+
25
+ TASK = "You interview a subject matter expert, through the developer who sits beside them, about the " \
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 " \
29
+ "industry, from the name of the domain: learn it from what the expert says. When you interpret " \
30
+ "an answer, propose findings only with the verbs listed under `verbs`, using the argument " \
31
+ "names given there, and nothing else.".freeze
32
+
33
+ VERBS = {
34
+ "SME::Interview.ProposeThing" => {
35
+ "meaning" => "a thing the business keeps track of, and the field that identifies one of it",
36
+ "arguments" => %w[name identifier]
37
+ },
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]
41
+ },
42
+ "SME::Interview.ProposeRule" => {
43
+ "meaning" => "a rule the expert stated, in their own words",
44
+ "arguments" => %w[statement]
45
+ }
46
+ }.freeze
47
+
48
+ # What each finding verb needs, and the SME commands that carry it out and decide it.
49
+ 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" }
57
+ }.freeze
58
+
59
+ TRUE_WORDS = %w[true yes y 1].freeze
60
+
61
+ Result = Struct.new(:status, :interview, keyword_init: true)
62
+
63
+ # @param runtime [Runtime] the booted SME chapter
64
+ # @param agent [#question, #proposals, nil] the interviewer; nil runs the plain prompts
65
+ # @param input [#gets] where the developer types
66
+ # @param output [#puts, #print] where the session speaks
67
+ # @param reference [String] the interview's reference, such as `INT-1`
68
+ # @param subject [String] the domain being discovered, as its bluebook will spell it
69
+ # @param expert [String] who is being interviewed
70
+ def initialize(runtime:, agent:, input:, output:, reference:, subject:, expert:)
71
+ @runtime = runtime
72
+ @agent = agent
73
+ @input = input
74
+ @output = output
75
+ @reference = reference
76
+ @subject = subject
77
+ @expert = expert
78
+ @asked = []
79
+ @number = 0
80
+ @suggested = false
81
+ end
82
+
83
+ # Holds the interview until the developer finishes or quits.
84
+ #
85
+ # @return [Result] `concluded` with the plain interview (see `InterviewDraft`), or `cancelled`
86
+ def call
87
+ ::Interview.plan!(reference: @reference, subject: @subject, expert: @expert).begin!
88
+ say("#{NOTICE}\n") if @agent
89
+ say("Type done when there is enough to start, or quit to stop and write nothing.\n")
90
+ turn until finished?
91
+ outcome
92
+ end
93
+
94
+ private
95
+
96
+ def outcome
97
+ return Result.new(status: :cancelled) if @cancelled
98
+
99
+ Result.new(status: :concluded, interview: InterviewDraft.from_record(current))
100
+ end
101
+
102
+ def finished? = @finished
103
+
104
+ def current = ::Interview.find(@reference)
105
+
106
+ def turn
107
+ question = next_question
108
+ say("\n#{question}")
109
+ answer = ask("> ")
110
+ return end_of_input if answer.nil?
111
+
112
+ case answer.strip.downcase
113
+ when "" then say("Say what the expert said, or type done.")
114
+ when "done" then conclude
115
+ when "quit" then cancel
116
+ else answered(question, answer.strip)
117
+ end
118
+ end
119
+
120
+ def answered(question, answer)
121
+ current.record!(question: question, answer: answer)
122
+ @asked << question
123
+ findings_from(answer)
124
+ suggest_enough
125
+ end
126
+
127
+ def next_question
128
+ asked = @asked.dup
129
+ proposed = @agent && ask_agent { @agent.question(state: state, asked: asked) }
130
+ text = proposed&.text
131
+ return text if text && !@asked.include?(text)
132
+
133
+ FALLBACK_QUESTIONS.find { |q| !@asked.include?(q) } || FALLBACK_QUESTIONS.last
134
+ end
135
+
136
+ def findings_from(answer)
137
+ proposals = @agent && ask_agent { @agent.proposals(prose: answer, state: state) }
138
+ return manual_findings unless proposals
139
+
140
+ proposals.each { |proposal| review(proposal) }
141
+ end
142
+
143
+ # Runs one call to the agent; a turn it cannot answer is said aloud, and a plain one follows.
144
+ def ask_agent
145
+ yield
146
+ rescue Ports::Agent::Unavailable, Ports::Agent::ValidationError => e
147
+ say("The AI could not answer (#{e.message.lines.first.to_s.strip}). Using a plain prompt for this turn.")
148
+ nil
149
+ end
150
+
151
+ def review(proposal)
152
+ config = KINDS[proposal.verb]
153
+ return say("Ignored a proposal for #{proposal.verb}: not a finding I know.") unless config
154
+
155
+ fields = fields_of(proposal, config)
156
+ missing = config[:fields].reject { |f| f == "creates" || fields[f].to_s.strip != "" }
157
+ return say("Ignored a #{config[:kind]} proposal with no #{missing.join(', ')}.") unless missing.empty?
158
+
159
+ number = propose(config, fields) or return
160
+ say(" Proposed #{describe(config, fields)}")
161
+ say(" because: #{proposal.rationale}")
162
+ decide(config, number, accept?(" Accept? [Y/n] "))
163
+ end
164
+
165
+ def fields_of(proposal, config)
166
+ rows = proposal.arguments.to_h { |row| [row[:name].to_s, row[:value]] }
167
+ fields = rows.slice(*config[:fields])
168
+ fields["creates"] = TRUE_WORDS.include?(fields["creates"].to_s.downcase) if fields.key?("creates")
169
+ fields
170
+ end
171
+
172
+ def propose(config, fields)
173
+ number = (@number += 1)
174
+ args = fields.transform_keys(&:to_sym).merge(number: number, source: current.exchanges.size)
175
+ current.public_send(config[:propose], **args)
176
+ number
177
+ rescue StandardError => e
178
+ say(" Could not keep that finding: #{reason(e)}")
179
+ nil
180
+ end
181
+
182
+ def decide(config, number, accepted)
183
+ verb = accepted ? config[:accept] : config[:reject]
184
+ @runtime.dispatch_flat("SME::Interview.#{config[:entity]}.#{verb}",
185
+ reference: { value: @reference }, number: { value: number })
186
+ say(accepted ? " Accepted." : " Rejected.")
187
+ rescue StandardError => e
188
+ say(" Could not record the decision: #{reason(e)}")
189
+ end
190
+
191
+ def describe(config, fields)
192
+ case config[:kind]
193
+ when "thing" then "thing: #{fields['name']}, identified by #{fields['identifier']}"
194
+ when "action" then "action: #{fields['name']} on #{fields['thing']}, announcing #{fields['event']}" \
195
+ "#{', creating it' if fields['creates']}"
196
+ else "rule: #{fields['statement']}"
197
+ end
198
+ end
199
+
200
+ # The plain prompt: findings typed by the developer, accepted as they are entered.
201
+ def manual_findings
202
+ loop do
203
+ kind = ask("Add a finding from that answer? thing, action or rule (enter to skip): ")
204
+ kind = kind.to_s.strip.downcase
205
+ break if kind.empty?
206
+
207
+ typed = typed_finding(kind) or next say(" I only know thing, action and rule.")
208
+ config = KINDS["SME::Interview.Propose#{kind.capitalize}"]
209
+ number = propose(config, typed) or next
210
+ decide(config, number, true)
211
+ end
212
+ end
213
+
214
+ def typed_finding(kind)
215
+ case kind
216
+ when "thing" then named("Name" => "name", "Identified by" => "identifier")
217
+ when "action" then typed_action
218
+ when "rule" then named("The rule, in the expert's words" => "statement")
219
+ end
220
+ end
221
+
222
+ def typed_action
223
+ fields = named("Name" => "name", "On which thing" => "thing", "It announces (event)" => "event") or return nil
224
+ fields.merge("creates" => TRUE_WORDS.include?(ask(" Does it create the thing? [y/N] ").to_s.strip.downcase))
225
+ end
226
+
227
+ def named(prompts)
228
+ fields = prompts.to_h { |label, key| [key, ask(" #{label}: ").to_s.strip] }
229
+ fields.values.all? { |v| !v.empty? } ? fields : nil
230
+ end
231
+
232
+ def suggest_enough
233
+ return if @suggested || !enough?
234
+
235
+ @suggested = true
236
+ say("\nI think we have enough to start. Type done to finish, or keep answering.")
237
+ end
238
+
239
+ def enough?
240
+ plain = InterviewDraft.from_record(current)
241
+ InterviewDraft.accepted(plain, :things).any? && InterviewDraft.accepted(plain, :actions).any?
242
+ end
243
+
244
+ def conclude
245
+ current.conclude!
246
+ @finished = true
247
+ rescue StandardError => e
248
+ say("Not finished yet: #{reason(e)}.")
249
+ end
250
+
251
+ def cancel
252
+ current.cancel!
253
+ @cancelled = @finished = true
254
+ say("Stopped. Nothing was written.")
255
+ end
256
+
257
+ def end_of_input
258
+ conclude
259
+ cancel unless @finished
260
+ end
261
+
262
+ def accept?(label)
263
+ !%w[n no].include?(ask(label).to_s.strip.downcase)
264
+ end
265
+
266
+ def state
267
+ plain = InterviewDraft.from_record(current)
268
+ { task: format(TASK, subject: @subject), subject: @subject, expert: @expert, verbs: VERBS,
269
+ exchanges: plain[:exchanges].each_with_index.map { |e, i| e.slice(:question, :answer).merge(number: i + 1) },
270
+ accepted: accepted_state(plain), gaps: gaps(plain) }
271
+ end
272
+
273
+ 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) } }
277
+ end
278
+
279
+ # What the interview has not yet pinned down, in words a model can act on.
280
+ def gaps(plain)
281
+ things = InterviewDraft.accepted(plain, :things).map { |t| t[:name].to_s }
282
+ actions = InterviewDraft.accepted(plain, :actions)
283
+ gaps = things.reject { |t| actions.any? { |a| a[:thing] == t } }.map { |t| "nothing is yet said to happen to #{t}" }
284
+ gaps += things.reject { |t| actions.any? { |a| a[:thing] == t && a[:creates] } }.map { |t| "nothing yet creates #{t}" }
285
+ unplaced = actions.reject { |a| things.include?(a[:thing].to_s) }
286
+ gaps + unplaced.map { |a| "#{a[:name]} names #{a[:thing]}, not yet a thing" }
287
+ end
288
+
289
+ def reason(error) = error.message.sub(/\A[A-Z]\w* refused\s+[—-]\s+/, "").strip
290
+
291
+ def ask(label)
292
+ @output.print(label)
293
+ @input.gets&.chomp
294
+ end
295
+
296
+ def say(text) = @output.puts(text)
297
+ end
298
+ end
299
+ end
data/lib/hecks/corpus.rb CHANGED
@@ -32,6 +32,7 @@ module Hecks
32
32
  language: "lib/hecks/language/**/*.bluebook",
33
33
  deploy: "lib/hecks/deploy/bluebook/*.bluebook",
34
34
  site: "lib/hecks/site/bluebook/*.bluebook",
35
+ tickets: "lib/hecks/tickets/bluebook/*.bluebook",
35
36
  tenancy: "lib/hecks/tenancy/bluebook/*.bluebook",
36
37
  sme: "lib/hecks/sme/bluebook/*.bluebook",
37
38
  fixture: "spec/fixtures/**/*.bluebook"
@@ -104,7 +104,7 @@ module Hecks
104
104
  # @return [Array(String, Integer), nil] the text and status, or nil when the line would
105
105
  # run a command or question and so needs a booted domain
106
106
  def usage(runtime:, argv:, program: "hecks run")
107
- resolve(runtime, argv, program)[:answer]
107
+ UsageCache.fetch(runtime, argv, program) { resolve(runtime, argv, program)[:answer] }
108
108
  end
109
109
 
110
110
  # Parses a command line against the projection: either the answer it gives without
@@ -0,0 +1,123 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Doors
7
+ # Remembers the help a launcher prints, so a repeat `hecks` need not read the domain again.
8
+ #
9
+ # The help is a pure function of the domain's declarations and of the gem that projects them,
10
+ # and reading those is most of what a launcher costs. The answer is kept in a file named for a
11
+ # digest of everything it depends on (the domain's declaration files, the gem's own library,
12
+ # the hecks and Ruby versions, the environment overlay, the command line and the program name),
13
+ # so any edit to one of them is simply a different entry: nothing is ever stale, only unused.
14
+ # Only an answer that ended well is kept, and any trouble with the cache itself is ignored
15
+ # and the help is worked out as if it did not exist.
16
+ #
17
+ # `HECKS_NO_USAGE_CACHE=1` turns it off; `HECKS_CACHE_DIR` moves it.
18
+ module UsageCache
19
+ # The variable that turns the cache off when set to anything but empty or `0`.
20
+ DISABLE_VARIABLE = "HECKS_NO_USAGE_CACHE".freeze
21
+ # The variable that names the directory the cache lives in.
22
+ DIRECTORY_VARIABLE = "HECKS_CACHE_DIR".freeze
23
+ # The declaration files whose contents shape a help text.
24
+ DECLARATIONS = "*.{bluebook,world,hecksagon,rb}".freeze
25
+ # How long an unused entry stays before the next write sweeps it away.
26
+ KEEP_SECONDS = 14 * 24 * 60 * 60
27
+
28
+ module_function
29
+
30
+ # Answers the cached help for this command line, or works it out with the block and
31
+ # remembers it.
32
+ #
33
+ # @param runtime [#directory] what `Hecks.describe` answered; anything else bypasses the cache
34
+ # @param argv [Array<String>] the command line
35
+ # @param program [String] how the caller was invoked
36
+ # @yield works the answer out, loading the domain
37
+ # @yieldreturn [Array(String, Integer), nil] the text and status; nil if it runs a command
38
+ # @return [Array(String, Integer), nil] the block's answer, or the remembered one
39
+ def fetch(runtime, argv, program)
40
+ directory = runtime.respond_to?(:directory) ? runtime.directory : nil
41
+ return yield unless directory && enabled?
42
+
43
+ file = entry_path(directory, argv, program)
44
+ remembered = file && recall(file)
45
+ return remembered if remembered
46
+
47
+ answer = yield
48
+ remember(file, answer) if file && answer&.last&.zero?
49
+ answer
50
+ end
51
+
52
+ # @return [Boolean] whether the cache is on
53
+ def enabled?
54
+ off = ENV[DISABLE_VARIABLE].to_s
55
+ off.empty? || off == "0"
56
+ end
57
+
58
+ # @return [String] the directory entries live in
59
+ def directory
60
+ ENV[DIRECTORY_VARIABLE] || File.join(ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache"), "hecks")
61
+ end
62
+
63
+ # The file that holds the answer for this command line, named by what it depends on.
64
+ #
65
+ # @return [String, nil] nil when the digest cannot be taken
66
+ def entry_path(domain, argv, program)
67
+ digest = Digest::SHA256.new
68
+ [Hecks::VERSION, RUBY_VERSION, ENV["HECKS_ENVIRONMENT"].to_s, program, argv.join("\0")].each do |part|
69
+ digest << part.to_s << "\0"
70
+ end
71
+ [domain, library].each { |root| fingerprint(root, digest) }
72
+ File.join(directory, "usage-#{digest.hexdigest}.json")
73
+ rescue SystemCallError
74
+ nil
75
+ end
76
+
77
+ # Feeds the digest every declaration file under `root`: its path, size and modification time.
78
+ def fingerprint(root, digest)
79
+ Dir.glob(File.join(root, "**", DECLARATIONS)).each do |file|
80
+ stat = File.stat(file)
81
+ digest << file << ":" << stat.size.to_s << ":" << stat.mtime.to_f.to_s << "\0"
82
+ end
83
+ end
84
+
85
+ # @return [String] the gem's own library directory, which the frameworks load from
86
+ def library
87
+ File.expand_path("..", __dir__)
88
+ end
89
+
90
+ # Reads the remembered answer and marks the entry as used, so a sweep keeps what is read.
91
+ #
92
+ # @return [Array(String, Integer), nil] the remembered answer, or nil when none can be trusted
93
+ def recall(file)
94
+ record = JSON.parse(File.read(file))
95
+ File.utime(nil, nil, file)
96
+ [record.fetch("text"), record.fetch("status")]
97
+ rescue SystemCallError, JSON::ParserError, KeyError
98
+ nil
99
+ end
100
+
101
+ # Writes the answer beside its name and sweeps entries nobody has read for a while.
102
+ def remember(file, answer)
103
+ FileUtils.mkdir_p(File.dirname(file))
104
+ scratch = "#{file}.#{Process.pid}"
105
+ File.write(scratch, JSON.generate("text" => answer.first, "status" => answer.last))
106
+ File.rename(scratch, file)
107
+ sweep(File.dirname(file))
108
+ rescue SystemCallError
109
+ nil
110
+ end
111
+
112
+ # Removes the entries older than `KEEP_SECONDS`.
113
+ def sweep(dir)
114
+ cutoff = Time.now - KEEP_SECONDS
115
+ Dir.glob(File.join(dir, "usage-*.json")).each do |entry|
116
+ File.delete(entry) if File.mtime(entry) < cutoff
117
+ end
118
+ rescue SystemCallError
119
+ nil
120
+ end
121
+ end
122
+ end
123
+ end