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.
- checksums.yaml +4 -4
- data/exe/hecks +1 -1
- data/lib/hecks/chapters.rb +4 -3
- data/lib/hecks/cli/domain_writer.rb +39 -0
- data/lib/hecks/cli/interview_agent.rb +59 -0
- data/lib/hecks/cli/interview_run.rb +87 -0
- data/lib/hecks/cli/interview_session.rb +299 -0
- data/lib/hecks/corpus.rb +1 -0
- data/lib/hecks/doors/cli_runner.rb +1 -1
- data/lib/hecks/doors/usage_cache.rb +123 -0
- data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
- data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
- data/lib/hecks/hecks/adapters/terminal.rb +15 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.bluebook +36 -0
- data/lib/hecks/hecks/hecks.bluebook +18 -0
- data/lib/hecks/hecks/hecks.hecksagon +8 -2
- data/lib/hecks/hecks/hecks.world +3 -2
- data/lib/hecks/projections/deploy/box/settings.rb +80 -4
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -5
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +17 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
- data/lib/hecks/projections/deploy/box.rb +185 -19
- data/lib/hecks/projector/cli_projector.rb +38 -14
- data/lib/hecks/runtime/loader.rb +31 -8
- data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
- data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
- data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/codegen/src/json_codec.rs +3 -0
- data/rust/codegen/src/naming.rs +4 -0
- data/rust/codegen/src/types.rs +45 -0
- data/rust/host/HECKS_RELEASE +1 -1
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e8bfb40ad9e9760533d77e179154821128fee28930877e35118b5e87c5c32731
|
|
4
|
+
data.tar.gz: 49fd0e562e846d8633c11101d7b11c6d751089c8ca0f281907f1c669e1d19438
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/hecks/chapters.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|