pikuri-workspace 0.0.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52db0d4ce078507aa4a72cbc84d54a9433788f2a2d9a90ab883a9e469e6dea99
4
- data.tar.gz: 107b34e1c3b387b4b68d542ff8ab2345885383a461c227a6b7aeb29973a4292e
3
+ metadata.gz: 120a595848652290fe60f2505a6cf74ff7d8f83764da5a22b89c7f71c95b5796
4
+ data.tar.gz: cb6348c77246df64f29b03cdb6d846abbd4bfa97f9c4830c155fd978ea1b65cb
5
5
  SHA512:
6
- metadata.gz: 432af6cfc0a0f3555666e9c88accb0e9b6162af2c5f041c9ff71b10443f1681b8e70b9e46aa6c75ed12344357a286df087869b889f0a40aeb9635aa6c9a1e651
7
- data.tar.gz: 362eb9437127e8734f15e09919ae7fb928e0d1b71fc9bb90a78f419cb7b4f52f29aebd323dd32e7bd491b0a73a3de60cff54a91e1756f24bcdc4e91d52de5fc2
6
+ metadata.gz: a3d79a08b112424947f74667a24750e82a86539b61a1edfdbabb2ab2270d610d0d91442c05aab6f186f869dff70376259fecc6172ca60c23d43560797e536de2
7
+ data.tar.gz: 1fe906ff062d7dc37e1666f8f2ebf56c18eb3e7bc389fcef5bf8f6334548f1cc10684113f2d1331af11b92befd33ba72f5dac217d42ef02cc668b6f66262f3c6
data/README.md CHANGED
@@ -8,8 +8,11 @@ Self-contained "operate on a directory tree" toolkit:
8
8
  project root + explicit readable / writable prefix lists, with an
9
9
  optional ephemeral temp playground. Rejects `..`-escapes and
10
10
  symlinks that resolve outside the configured roots.
11
- - `Pikuri::Workspace::Confirmer` — abstract base + `AUTO_APPROVE` /
12
- `TERMINAL` for user-state mutations.
11
+ - `Pikuri::Workspace::Confirmer` — abstract base for approving
12
+ user-state mutations, plus `AUTO_APPROVE` (headless) and `Terminal`
13
+ (stdin/stdout). `Terminal` is written for pikuri's own single-threaded
14
+ demo scripts — a real app implements this seam itself and reads
15
+ `Terminal` as the worked example.
13
16
  - Five file tools: `Pikuri::Workspace::Read`,
14
17
  `Pikuri::Workspace::Write`, `Pikuri::Workspace::Edit`,
15
18
  `Pikuri::Workspace::Grep`, `Pikuri::Workspace::Glob`.
@@ -31,7 +34,8 @@ require 'pikuri-core'
31
34
  require 'pikuri-workspace'
32
35
 
33
36
  workspace = Pikuri::Workspace::Filesystem.new(project_root: Dir.pwd)
34
- confirmer = Pikuri::Workspace::Confirmer::TERMINAL
37
+ # Fine for a script like this one; write your own for a real app.
38
+ confirmer = Pikuri::Workspace::Confirmer::Terminal.new
35
39
 
36
40
  agent = Pikuri::Agent.new(
37
41
  transport: ...,
@@ -49,7 +53,7 @@ end
49
53
  `Workspace` is the "look-but-don't-leak" guard around filesystem
50
54
  access. Read tools route through `#resolve_for_read(path)`; mutating
51
55
  tools route through `#resolve_for_write(path)` + the Confirmer's
52
- `#confirm?(prompt:)`. Pass `temp: true` to mint an ephemeral
56
+ `#confirm?(request:)`. Pass `temp: true` to mint an ephemeral
53
57
  writable playground via `Dir.mktmpdir` — its path is exposed as
54
58
  `workspace.temp` and auto-removed at process exit.
55
59
 
@@ -57,7 +61,7 @@ writable playground via `Dir.mktmpdir` — its path is exposed as
57
61
 
58
62
  - **Narrative walkthrough:** the "Workspace seam" and "Confirmer
59
63
  seam" sections of
60
- [chapter 8 of the pikuri guide](../docs/guide/08-code.md) — what
64
+ [the coding-agent chapter of the pikuri guide](../book/code.md) — what
61
65
  each kwarg of `Filesystem.new` controls, when to use the
62
66
  `AllowAll` variant, and how the seams fit alongside `Sandbox`.
63
67
  - **API reference:** browse the YARD docs at
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rainbow'
4
+ require 'reline'
5
+ require 'tmpdir'
6
+
7
+ module Pikuri
8
+ module Workspace
9
+ class Confirmer
10
+ # Stdin/stdout implementation, written for pikuri's own single-threaded
11
+ # +bin/+ scripts — **not a component to build a host on**. A real host
12
+ # (TUI, web) owns its screen and its event loop, so it cannot have a
13
+ # library class calling +puts+ and +gets+ behind its back; it writes its
14
+ # own {Confirmer} and this is the worked example to read while doing it.
15
+ # The one piece to carry over is not the chrome but the warning-gated
16
+ # retype below — everything else is a terminal's answer to a problem your
17
+ # UI will answer differently.
18
+ #
19
+ # Renders the request as up to several lines
20
+ # (a leading +puts+ separates it from any streamed output above):
21
+ #
22
+ # 1. a bold-yellow warning block (one line per {Pikuri::Sanitizer::Warning})
23
+ # when the sanitizer flagged the question or detail
24
+ # 2. the question, bold
25
+ # 3. the detail, dim — omitted when +nil+
26
+ # 4. the answer cue
27
+ #
28
+ # Question and detail pass through {Pikuri::Sanitizer}, which neutralizes
29
+ # control bytes (else a model could craft +"\rrm -rf ~/"+ that overwrites
30
+ # the echoed line after the user read it) and reports *why* it was unsafe.
31
+ # Colors are Rainbow (self-disables on non-TTY).
32
+ #
33
+ # The human *chrome* — the one confirmer that renders a dialog and reads an
34
+ # answer. In its own file so the base seam carries no rendering deps
35
+ # (Rainbow / Reline / tmpdir ride only here).
36
+ #
37
+ # == The two flows
38
+ #
39
+ # A *non-editable* request (bash, write) gets +(y/n)?+: +y+ → {Approved},
40
+ # +n+ → {Rejected} (after an optional reason), EOF → {Rejected}, else
41
+ # re-prompt.
42
+ #
43
+ # An *editable* request (the +agent+ task) also offers +e+ to edit, and
44
+ # the warning set gates the pre-fill: the payload sent onward is RAW bytes
45
+ # but the terminal must *display* sanitized bytes, so pre-filling an
46
+ # editable buffer with raw bytes would reintroduce the control-byte attack.
47
+ # So a *clean* task offers +[y] approve [e] edit [n] reject+ (+e+ opens
48
+ # the raw task editable), a *flagged* task drops bare-approve and forces a
49
+ # retype: +[e] edit [n] reject+. Honest limit: a terminal can't
50
+ # *structurally* forbid a rubber-stamp +y+ on a clean task — the pure
51
+ # no-rubber-stamp author seat is a richer-UI invariant; the warning-gated
52
+ # retype is the terminal's partial answer.
53
+ #
54
+ # == Sharing
55
+ #
56
+ # +P_one_agent+, and no lock is coming: this is the demo chrome, and one
57
+ # script is one agent. Two agents prompting at once would interleave their
58
+ # blocks on one terminal, and whichever +gets+ runs first reads the answer
59
+ # — so agent A could consume the +y+ meant for agent B and approve a
60
+ # command nobody read. There is no state to corrupt; the contended
61
+ # resource is the terminal and the human at it.
62
+ #
63
+ # That queue belongs to whoever owns the screen, and it needs more than a
64
+ # mutex — it has to say *which* agent is asking. Which is the same
65
+ # sentence as the paragraph at the top: a multi-agent host writes its own
66
+ # confirmer.
67
+ class Terminal < Confirmer
68
+ LOGGER = Pikuri.logger_for('Workspace::Confirmer')
69
+
70
+ # Maximum diff lines rendered before truncation — keeps a
71
+ # whole-file rewrite from scrolling the terminal off-screen.
72
+ DIFF_MAX_LINES = 300
73
+
74
+ # @param request [Request]
75
+ # @return [Approved, Rejected]
76
+ def ask(request:)
77
+ warnings = render(request)
78
+ if request.editable && !request.detail.nil?
79
+ ask_editable(request, clean: warnings.empty?)
80
+ else
81
+ ask_plain(request)
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # Print the sanitized question / detail / warning block.
88
+ #
89
+ # @param request [Request]
90
+ # @return [Array<Pikuri::Sanitizer::Warning>] the flagged warnings
91
+ # (empty when clean) — the caller gates the edit pre-fill on this.
92
+ def render(request)
93
+ question = Pikuri::Sanitizer.sanitize(request.question)
94
+ detail = request.detail ? Pikuri::Sanitizer.sanitize(request.detail) : nil
95
+ raw_diff = request.change ? unified_diff(request.change) : nil
96
+ diff = raw_diff ? Pikuri::Sanitizer.sanitize(raw_diff) : nil
97
+ warnings = question.warnings + (detail ? detail.warnings : []) + (diff ? diff.warnings : [])
98
+
99
+ puts
100
+ unless warnings.empty?
101
+ puts Rainbow('⚠ Suspicious content detected — read carefully before approving:').yellow.bold
102
+ warnings.each { |w| puts Rainbow(" ! #{w.explanation}").yellow }
103
+ end
104
+ puts Rainbow(question.text).bold
105
+ puts Rainbow(detail.text).dimgray if detail
106
+ print_diff(diff.text) if diff
107
+ warnings
108
+ end
109
+
110
+ # Render a unified diff of +change+ by shelling to +diff -u+ (routed
111
+ # through {Pikuri::Subprocess}; a richer client uses its own diff lib).
112
+ # Old/new bytes go to tempfiles under a self-cleaning dir. Returns the
113
+ # diff capped at {DIFF_MAX_LINES}, or +nil+ on any failure (empty diff,
114
+ # missing +diff+, IO error) — a cosmetic path, so it degrades to "no
115
+ # diff shown" rather than raising.
116
+ #
117
+ # @param change [Change]
118
+ # @return [String, nil]
119
+ def unified_diff(change)
120
+ Dir.mktmpdir('pikuri-diff-') do |dir|
121
+ old_f = File.join(dir, 'old')
122
+ new_f = File.join(dir, 'new')
123
+ out_f = File.join(dir, 'out')
124
+ File.binwrite(old_f, change.old || '')
125
+ File.binwrite(new_f, change.new)
126
+ File.open(out_f, 'w') do |o|
127
+ Pikuri::Subprocess.run(
128
+ 'diff', '-u', '--label', "a/#{change.path}", '--label', "b/#{change.path}",
129
+ old_f, new_f, stdin_data: '', stdout: o, chdir: dir
130
+ )
131
+ end
132
+ text = File.read(out_f)
133
+ return nil if text.empty?
134
+
135
+ lines = text.lines
136
+ return text if lines.size <= DIFF_MAX_LINES
137
+
138
+ lines.first(DIFF_MAX_LINES).join + "… (#{lines.size - DIFF_MAX_LINES} more diff lines)\n"
139
+ end
140
+ rescue StandardError => e
141
+ LOGGER.warn("diff render failed (#{e.class}: #{e.message}); confirming without a diff")
142
+ nil
143
+ end
144
+
145
+ # Print a sanitized unified diff, coloring +added+/-removed/@@hunk@@
146
+ # lines. Sanitization ran in {#render}; this only adds color.
147
+ #
148
+ # @param text [String] sanitized diff text
149
+ # @return [void]
150
+ def print_diff(text)
151
+ text.each_line do |line|
152
+ body = line.chomp
153
+ puts(case body[0]
154
+ when '+' then Rainbow(body).green
155
+ when '-' then Rainbow(body).red
156
+ when '@' then Rainbow(body).cyan
157
+ else Rainbow(body).dimgray
158
+ end)
159
+ end
160
+ end
161
+
162
+ # The +(y/n)?+ flow, returning {Approved}/{Rejected} and prompting for a
163
+ # decline reason on +n+.
164
+ #
165
+ # @param request [Request]
166
+ # @return [Approved, Rejected]
167
+ def ask_plain(request)
168
+ puts '(y/n)?'
169
+ $stdout.flush
170
+ loop do
171
+ line = $stdin.gets
172
+ return Rejected.new(reason: nil) if line.nil?
173
+
174
+ answer = line.strip.downcase
175
+ return Approved.new(new_request_detail: request.detail) if answer == 'y' || answer == 'yes'
176
+ return Rejected.new(reason: read_reason) if answer == 'n' || answer == 'no'
177
+
178
+ print 'Please answer y or n: '
179
+ $stdout.flush
180
+ end
181
+ end
182
+
183
+ # The editable flow: approve-as-is (clean only), edit, or reject.
184
+ #
185
+ # @param request [Request]
186
+ # @param clean [Boolean] whether the payload passed the sanitizer
187
+ # (gates the pre-fill and the bare-approve path)
188
+ # @return [Approved, Rejected]
189
+ def ask_editable(request, clean:)
190
+ puts(clean ? '[y] approve [e] edit [n] reject' : '⚠ retype required — [e] edit [n] reject')
191
+ $stdout.flush
192
+ loop do
193
+ line = $stdin.gets
194
+ return Rejected.new(reason: nil) if line.nil?
195
+
196
+ answer = line.strip.downcase
197
+ if clean && (answer == 'y' || answer == 'yes')
198
+ return Approved.new(new_request_detail: request.detail)
199
+ end
200
+ if answer == 'e' || answer == 'edit'
201
+ return Approved.new(new_request_detail: read_edited(initial: request.detail, prefill: clean))
202
+ end
203
+ return Rejected.new(reason: read_reason) if answer == 'n' || answer == 'no'
204
+
205
+ print(clean ? 'Please answer y, e, or n: ' : 'Please answer e or n: ')
206
+ $stdout.flush
207
+ end
208
+ end
209
+
210
+ # Read the human's (re)authored task.
211
+ #
212
+ # @param initial [String] the raw payload to pre-fill with
213
+ # @param prefill [Boolean] +true+ places +initial+ in the editable buffer
214
+ # (clean payloads only); +false+ forces a fresh line — the retype for
215
+ # flagged input, which never puts raw bytes on the terminal.
216
+ # @return [String] the edited text (may be empty)
217
+ def read_edited(initial:, prefill:)
218
+ if prefill
219
+ Reline.pre_input_hook = lambda do
220
+ Reline.insert_text(initial)
221
+ Reline.pre_input_hook = nil
222
+ end
223
+ end
224
+ (Reline.readline('task> ', false) || '').chomp
225
+ ensure
226
+ Reline.pre_input_hook = nil
227
+ end
228
+
229
+ # Prompt for an optional decline reason (steering handed back to the
230
+ # model).
231
+ #
232
+ # @return [String, nil] the trimmed reason, or +nil+ for blank / EOF
233
+ def read_reason
234
+ print 'Reason (optional, Enter to skip): '
235
+ $stdout.flush
236
+ line = $stdin.gets
237
+ return nil if line.nil?
238
+
239
+ reason = line.strip
240
+ reason.empty? ? nil : reason
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -2,94 +2,172 @@
2
2
 
3
3
  module Pikuri
4
4
  module Workspace
5
- # Port for asking the user to confirm a potentially destructive tool
6
- # operation — currently {Pikuri::Code::Bash} (every command) and
7
- # {Write} (overwrite of an existing file with non-identical
8
- # content). Subclass and implement {#confirm?}.
5
+ # Port for resolving a "may I do this?" confirmation to a decision
6
+ # currently {Pikuri::Code::Bash}, {Write}, and the +agent+ tool's
7
+ # delegation gate. Subclass and implement {#ask}.
9
8
  #
10
- # == Why a Boolean return
9
+ # == The one job: resolve a {Request} to a decision
11
10
  #
12
- # v1 returns +true+ or +false+. Two paths-not-taken worth recording
13
- # so a future reader knows the design space was considered:
11
+ # A Confirmer takes a semantic {Request} and returns {Approved} or
12
+ # {Rejected}. That is *all*: it carries **no tool policy** and never
13
+ # classifies the request's *content* (no filesystem reads, no command
14
+ # parsing, no notion of "passive"). Two shipped implementations:
14
15
  #
15
- # 1. Richer return (+:once+ / +:always+ / +:reject+) rejected
16
- # because it creates decision fatigue, and the long-term answer
17
- # is to make confirmations rare rather than smart (sandboxing,
18
- # agentic destructiveness analysis).
19
- # 2. Agentic destructive-or-not classifier deferred to v2.
16
+ # * {Terminal} the human *chrome*: shows the request and reads the answer.
17
+ # In +confirmer/terminal.rb+ so this seam file stays free of rendering deps.
18
+ # Written for pikuri's own single-threaded +bin/+ scripts and best read as
19
+ # the worked example — a TUI or web host implements this seam itself.
20
+ # * {AutoApprove} the headless stance: ignores the request and
21
+ # blanket-approves (+--yolo+ / dev-container). Request-independent, so it
22
+ # encodes no policy either.
20
23
  #
21
- # The intended escape from confirmation prompts today is sandboxing
22
- # (docker / dev-container) plus the +--yolo+ flag on +bin/pikuri-code+
23
- # (which wires {AUTO_APPROVE} instead of {TERMINAL}).
24
+ # Sharing is per implementation, and the two shipped ones differ:
25
+ # {AutoApprove} is +P_stateless+ and shares freely, while {Terminal} is
26
+ # +P_one_agent+ because concurrent agents fight over one human's keystrokes.
27
+ # A confirmer written for a multi-agent host owns that queueing — and owns
28
+ # naming which agent is asking, which is why the queue can't usefully live
29
+ # down here.
30
+ #
31
+ # Content classification ("is this bash command provably passive, skip the
32
+ # prompt?") deliberately does *not* live here — it's a tool concern
33
+ # ({Pikuri::Code::Bash} takes a separate +passive_detector:+ predicate,
34
+ # falling through to this confirmer only for commands it can't pre-approve).
35
+ # Keeping them apart lets one stateless chrome serve every tool.
36
+ #
37
+ # == The return type: {Approved} / {Rejected}
38
+ #
39
+ # {#ask} returns {Approved} (the possibly-edited {Request#detail} to act on)
40
+ # or {Rejected} (an optional decline reason); callers pattern-match:
41
+ #
42
+ # case confirmer.ask(request: request)
43
+ # in Confirmer::Approved(new_request_detail:) then ...act on it...
44
+ # in Confirmer::Rejected(reason:) then ...steer with it...
45
+ # end
46
+ #
47
+ # {#confirm?} is the derived boolean convenience for gates ({Write},
48
+ # {Pikuri::Code::ExitPlanMode}) needing neither the payload nor the reason.
49
+ # Two orthogonal axes ride the richer return:
50
+ #
51
+ # 1. *Editing* (opt-in via {Request#editable}) — when +detail+ is the
52
+ # verbatim actionable payload (the +agent+ task, where +detail == task+),
53
+ # a confirmer may let the human *rewrite* it — "human as author, not
54
+ # approver". Bash's +"$ <command>"+ is a decorated display string, so it
55
+ # stays non-editable.
56
+ # 2. *Reason* (universal) — any decline can carry typed steering ("use the
57
+ # other library") back to the model as the next observation.
58
+ #
59
+ # A +:once+/+:always+ scope knob was *rejected*: it manages decision
60
+ # *fatigue*, a distinct axis, and the long-term answer to fatigue is making
61
+ # confirmations rare (sandboxing, +--yolo+, the +passive_detector+ skip),
62
+ # not smarter approval modes. (An agentic destructive-or-not classifier is a
63
+ # deferred v2.)
24
64
  #
25
65
  # == Seam discipline
26
66
  #
27
- # Tools that need confirmation take a {Confirmer} via constructor and
28
- # invoke {#confirm?} with a fully-composed prompt String. Tools do
29
- # *not* call +gets+ / +puts+ directly same lesson as listeners,
30
- # keep IO at the seam so a future TUI / web client can plug a
31
- # different implementation in without touching tool code.
67
+ # Tools take a {Confirmer} via constructor and invoke {#ask} with a semantic
68
+ # {Request} *what* is asked, never *how* it looks. All presentation
69
+ # belongs to the confirmer: color, the answer cue, parsing, the edit
70
+ # affordance, and security-relevant neutralizing hostile bytes in
71
+ # LLM-supplied text. The chrome-independent half (escape control bytes, flag
72
+ # bidi/zero-width/homoglyph spoofs) is the shared {Pikuri::Sanitizer}; the
73
+ # medium-specific half stays with the renderer (a terminal prints sanitized
74
+ # text; a web client HTML-escapes). Tools never call +gets+/+puts+ directly
75
+ # — keep IO at the seam so a TUI/web client plugs in without touching tools.
32
76
  class Confirmer
33
- # @param prompt [String] human-readable question composed by the
34
- # calling tool. The confirmer renders it and parses the answer;
35
- # it does NOT compose its own prompt content. Caller owns the
36
- # closing punctuation and any "(y/n)" cue.
37
- # @return [Boolean] +true+ iff approved
38
- # @raise [NotImplementedError] in the abstract base
39
- def confirm?(prompt:)
40
- raise NotImplementedError, "#{self.class}#confirm? must be implemented"
41
- end
42
-
43
- # Stdin/stdout implementation: prints +prompt+ on its own line (a
44
- # leading +puts+ guarantees separation from any streamed output
45
- # the +Terminal+ listener may have produced just above), reads one
46
- # line from +$stdin+, parses it strictly:
77
+ # The semantic payload of one confirmation:
47
78
  #
48
- # * +"y"+ / +"yes"+ (case-insensitive, stripped) +true+
49
- # * +"n"+ / +"no"+ +false+
50
- # * EOF / Ctrl+D (+gets+ returns +nil+) → +false+, deliberate abort
51
- # * anything else (blank, typo, +"maybe"+) re-prompt with a short
52
- # "Please answer y or n: " line and loop
79
+ # * +question+ one-line headline the tool composes (e.g. +"OK to
80
+ # overwrite foo.rb: 120 245 bytes?"+); the caller owns phrasing, the
81
+ # renderer owns the answer cue.
82
+ # * +detail+ optional preformatted body: the raw bash command, the
83
+ # +agent+ task, +nil+ for {Write}. When +editable+, the exact text the
84
+ # human may rewrite, returned as {Approved#new_request_detail}.
85
+ # * +editable+ — whether the human may edit +detail+ (default +false+);
86
+ # +true+ only when +detail+ is the verbatim payload (the +agent+ task),
87
+ # never a decorated string (bash's +"$ <command>"+).
53
88
  #
54
- # No retry cap; EOF eventually breaks adversarial input.
55
- class Terminal < Confirmer
56
- # @param prompt [String]
57
- # @return [Boolean]
58
- def confirm?(prompt:)
59
- puts
60
- puts prompt
61
- $stdout.flush
62
- loop do
63
- line = $stdin.gets
64
- return false if line.nil?
89
+ # +question+/+detail+ are RAW LLM-composed text renderers MUST neutralize
90
+ # them via {Pikuri::Sanitizer} (see {Terminal}), plus HTML-escape in a web
91
+ # client.
92
+ Request = Data.define(:question, :detail, :editable, :change) do
93
+ # @param question [String] one-line headline; caller owns phrasing
94
+ # @param detail [String, nil] optional preformatted body / editable payload
95
+ # @param editable [Boolean] whether the human may rewrite +detail+
96
+ # @param change [Change, nil] optional structured file change; when
97
+ # present a diff-capable chrome renders a diff of it (see {Change}).
98
+ def initialize(question:, detail: nil, editable: false, change: nil)
99
+ super
100
+ end
101
+ end
65
102
 
66
- answer = line.strip.downcase
67
- return true if answer == 'y' || answer == 'yes'
68
- return false if answer == 'n' || answer == 'no'
103
+ # A structured file change for a {Write}/{Edit} confirmation: the
104
+ # *semantic* change (path + old bytes + new bytes), not a pre-rendered
105
+ # string, so diff rendering stays the confirmer's job. {Terminal} shells
106
+ # to +diff+; a richer client renders the same {Change} its own way. +old+
107
+ # is the current bytes, or +nil+ for a new file.
108
+ Change = Data.define(:path, :old, :new)
69
109
 
70
- print 'Please answer y or n: '
71
- $stdout.flush
72
- end
73
- end
110
+ # An approval carrying the text to act on: the human-authored (possibly
111
+ # edited) {Request#detail} when editable, else +detail+ verbatim.
112
+ Approved = Data.define(:new_request_detail)
113
+
114
+ # A decline carrying the human's optional steering reason (+nil+ if none),
115
+ # which the tool folds into the observation handed back to the model.
116
+ Rejected = Data.define(:reason)
117
+
118
+ # @param request [Request] semantic content composed by the calling
119
+ # tool. The confirmer renders it (escaping for its medium), poses
120
+ # the question, and parses the answer.
121
+ # @return [Approved, Rejected]
122
+ # @raise [NotImplementedError] in the abstract base
123
+ def ask(request:)
124
+ raise NotImplementedError, "#{self.class}#ask must be implemented"
125
+ end
126
+
127
+ # Boolean convenience over {#ask} for gates needing neither the edited
128
+ # payload nor the reason.
129
+ #
130
+ # @param request [Request]
131
+ # @return [Boolean] +true+ iff approved
132
+ def confirm?(request:)
133
+ ask(request:).is_a?(Approved)
74
134
  end
75
135
 
76
- # Approves everything. Used by +bin/pikuri-code --yolo+ (docker /
77
- # dev-container mode) and by tool specs that don't want to
78
- # coordinate stdin. The name +AUTO_APPROVE+ matches the public
79
- # constant {AUTO_APPROVE}.
136
+ # Whether {#ask} can actually stop and wait for a person — the posture
137
+ # {Pikuri::Trifecta} reads when deciding whether a gated tool reaches the
138
+ # approver seat rather than counting as autonomous egress.
139
+ #
140
+ # +true+ on the base class: a confirmer exists to ask, so a subclass that
141
+ # doesn't must say so, and one that forgets is credited with a gate it
142
+ # does not have only if it also forgets to be {AutoApprove}. Override to
143
+ # +false+ for any request-independent auto-answer.
144
+ #
145
+ # This is what makes +--no-confirm+ re-harden a wiring's verdict on its
146
+ # own, with nothing in the detector special-casing the flag.
147
+ #
148
+ # @return [Boolean]
149
+ def blocks_on_human? = true
150
+
151
+ # Approves everything unedited — the headless "opted out of dialogs"
152
+ # stance. Request-independent, so it classifies nothing and stays a
153
+ # legitimate Confirmer. Used by +bin/pikuri-code --yolo+, downstream hosts'
154
+ # yolo modes, and specs that don't want to coordinate stdin.
80
155
  class AutoApprove < Confirmer
81
- # @param prompt [String] ignored
82
- # @return [true]
83
- def confirm?(prompt:)
84
- true
156
+ # @param request [Request]
157
+ # @return [Approved] always, carrying +detail+ unchanged
158
+ def ask(request:)
159
+ Approved.new(new_request_detail: request.detail)
85
160
  end
86
- end
87
161
 
88
- # Shared singleton instance of {Terminal}. Stateless; reusable
89
- # across tools and sub-agents.
90
- TERMINAL = Terminal.new
162
+ # @return [Boolean] +false+ nobody is asked, so a tool gated by this
163
+ # confirmer holds autonomous egress however human-gated its wiring
164
+ # looks on paper
165
+ def blocks_on_human? = false
166
+ end
91
167
 
92
- # Shared singleton instance of {AutoApprove}.
168
+ # Shared stateless {AutoApprove} singleton, reusable across tools,
169
+ # sub-agents, and hosts' yolo modes. (No +TERMINAL+ singleton: {Terminal}
170
+ # is a demo chrome, and real front-ends bring their own.)
93
171
  AUTO_APPROVE = AutoApprove.new
94
172
  end
95
173
  end