pikuri-code 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 +4 -4
- data/README.md +9 -8
- data/lib/pikuri/code/bash/passive_command_detector.rb +567 -0
- data/lib/pikuri/code/bash/sandbox.rb +387 -354
- data/lib/pikuri/code/bash/tokenizer.rb +310 -0
- data/lib/pikuri/code/bash.rb +206 -117
- data/lib/pikuri/code/enter_plan_mode.rb +55 -0
- data/lib/pikuri/code/exit_plan_mode.rb +85 -0
- data/lib/pikuri/code/extension.rb +164 -0
- data/lib/pikuri/code/git_clone.rb +57 -74
- data/lib/pikuri/code/git_repo_researcher.rb +18 -48
- data/lib/pikuri/code/plan_mode_changed.rb +24 -0
- data/lib/pikuri/code/toolchain_paths.rb +42 -98
- data/lib/pikuri-code.rb +4 -12
- data/prompts/coding-system-prompt.txt +18 -16
- data/prompts/persona-git-repo-researcher.txt +2 -1
- metadata +17 -14
data/lib/pikuri/code/bash.rb
CHANGED
|
@@ -1,84 +1,73 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'rainbow'
|
|
4
|
-
|
|
5
3
|
module Pikuri
|
|
6
4
|
module Code
|
|
7
5
|
# The +bash+ tool — run an arbitrary shell command in the workspace.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# (workspace + confirmer captured by the +execute+ closure at
|
|
12
|
-
# construction).
|
|
6
|
+
# +Code::Bash.new(filesystem: fs, confirmer: c)+ produces a tool whose
|
|
7
|
+
# {Pikuri::Tool#to_ruby_llm_tool} wiring is identical to any bundled tool's
|
|
8
|
+
# (filesystem + confirmer captured by the +execute+ closure).
|
|
13
9
|
#
|
|
14
10
|
# == Confirmation
|
|
15
11
|
#
|
|
16
|
-
# Every command
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
12
|
+
# Every command is confirmed (unless a +passive_detector+ pre-approves it).
|
|
13
|
+
# Bash composes a semantic {Pikuri::Workspace::Confirmer::Request} — a
|
|
14
|
+
# question plus +$ <command>+ detail — and hands it to the confirmer, which
|
|
15
|
+
# owns ALL presentation and medium-appropriate escaping of the raw bytes
|
|
16
|
+
# (terminal neutralizes control bytes, web client HTML-escapes). The request
|
|
17
|
+
# carries the command verbatim so each renderer escapes for its own medium.
|
|
18
|
+
# The *observation* echo (+$ ...+ in the tool result) passes through
|
|
19
|
+
# {.visible}, so the model can't smuggle a +\r\033[2K rm -rf ~/+ behind it
|
|
20
|
+
# either. Execution uses the raw command; only displays are sanitized.
|
|
23
21
|
#
|
|
24
22
|
# == Subprocess wiring
|
|
25
23
|
#
|
|
26
|
-
# The command runs through {Pikuri::Subprocess.spawn} with argv:
|
|
27
|
-
#
|
|
28
24
|
# timeout --signal=TERM --kill-after=5s <timeout>s bash -c <command>
|
|
29
25
|
#
|
|
30
|
-
# +bash -c+ (no +-l+) — no profile/rc sourcing
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
26
|
+
# +bash -c+ (no +-l+) — no profile/rc sourcing. +timeout(1)+ from GNU
|
|
27
|
+
# coreutils handles the SIGTERM-then-SIGKILL race (Ruby's +Timeout.timeout+
|
|
28
|
+
# can't reliably kill subprocesses); +--kill-after=5s+ gives 5s to handle
|
|
29
|
+
# SIGTERM before SIGKILL. The environment is de-bundlerized first, or a
|
|
30
|
+
# +bundle exec+ against another project would pick up *pikuri's* Gemfile.
|
|
31
|
+
# See {.subprocess_env} and {Pikuri::BundlerEnv}.
|
|
36
32
|
#
|
|
37
33
|
# == Timeout detection
|
|
38
34
|
#
|
|
39
|
-
# GNU
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
# rather than a routine timeout.
|
|
48
|
-
#
|
|
49
|
-
# Caveat: +137+ is ambiguous — a command killed by the OOM-killer
|
|
50
|
-
# also exits +137+. v1 accepts the mis-classification; the
|
|
51
|
-
# observation tells the user "sent SIGTERM, then SIGKILL" regardless.
|
|
35
|
+
# GNU +timeout+ exits +124+ after SIGTERM, +137+ after escalating to
|
|
36
|
+
# SIGKILL; both are treated as "timed out". +125+ is also accepted:
|
|
37
|
+
# uutils-coreutils 0.2.2 (the Rust reimplementation on some distros)
|
|
38
|
+
# mis-reports +125+ instead of +124+ when +--kill-after+ is in play.
|
|
39
|
+
# False-positive risk on real GNU coreutils is low (fixed, well-formed
|
|
40
|
+
# argv). Caveat: +137+ is ambiguous — the OOM-killer also exits +137+; v1
|
|
41
|
+
# accepts the mis-classification (the observation says "sent SIGTERM, then
|
|
42
|
+
# SIGKILL" regardless).
|
|
52
43
|
#
|
|
53
44
|
# == Output handling
|
|
54
45
|
#
|
|
55
|
-
# Combined stdout+stderr (popen2e)
|
|
56
|
-
# {
|
|
57
|
-
#
|
|
58
|
-
# to decide whether to re-run with +head+/+tail+/+grep+.
|
|
46
|
+
# Combined stdout+stderr (popen2e), head+tail truncated at {OUTPUT_HEAD} +
|
|
47
|
+
# {OUTPUT_TAIL} bytes with a marker reporting bytes-omitted and total — the
|
|
48
|
+
# model needs the scale to decide whether to re-run with +head+/+grep+.
|
|
59
49
|
#
|
|
60
50
|
# == Backgrounded subprocesses
|
|
61
51
|
#
|
|
62
|
-
# Plain +cmd &+ does NOT detach — the
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# +
|
|
67
|
-
# SIGTERM on pikuri exit via {Pikuri::Subprocess.cleanup!}; +nohup+ /
|
|
68
|
-
# +setsid+ plus redirection opt out of cleanup.
|
|
52
|
+
# Plain +cmd &+ does NOT detach — the child inherits our combined-output
|
|
53
|
+
# pipe, so {Pikuri::Subprocess#wait} blocks on +io.read+ until it exits. The
|
|
54
|
+
# model must redirect fds to genuinely background: +cmd >/dev/null 2>&1 &+.
|
|
55
|
+
# Such commands stay in our pgroup and get SIGTERM on pikuri exit via
|
|
56
|
+
# {Pikuri::Subprocess.cleanup!}; +nohup+ / +setsid+ plus redirection opt out.
|
|
69
57
|
#
|
|
70
|
-
# ==
|
|
58
|
+
# == Sharing
|
|
71
59
|
#
|
|
72
|
-
#
|
|
60
|
+
# +P_one_agent+, but only because of the {Pikuri::Workspace::Confirmer}: with
|
|
61
|
+
# a shared {Confirmer::Terminal} two agents fight over one human's
|
|
62
|
+
# keystrokes. In substance this tool is stateless — every call spawns its
|
|
63
|
+
# own subprocess, and the {Pikuri::Workspace::Filesystem}, {Bash::Sandbox}
|
|
64
|
+
# and +passive_detector+ it holds are immutable — so under
|
|
65
|
+
# {Confirmer::AutoApprove} nothing stops one instance serving every agent.
|
|
73
66
|
#
|
|
74
|
-
# *
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
# * +timeout+ exit (+124+ / +137+) → timeout error with partial output.
|
|
67
|
+
# Concurrency the *commands* create is not pikuri's to serialize: ten agents
|
|
68
|
+
# running +bundle install+ in one workspace will corrupt each other's work
|
|
69
|
+
# exactly as ten shells would.
|
|
78
70
|
class Bash < Pikuri::Tool
|
|
79
|
-
# Pikuri-convention per-module logger; the +Bash+ progname tags the
|
|
80
|
-
# construction-time warning below so it's clear in the shared
|
|
81
|
-
# +Pikuri.log_io+ stream which tool issued it.
|
|
82
71
|
# @return [Logger]
|
|
83
72
|
LOGGER = Pikuri.logger_for('Bash')
|
|
84
73
|
|
|
@@ -92,6 +81,29 @@ module Pikuri
|
|
|
92
81
|
# passed to +timeout --kill-after=...+.
|
|
93
82
|
KILL_AFTER = '5s'
|
|
94
83
|
|
|
84
|
+
# Git config keys forced onto every git invocation, threaded in via
|
|
85
|
+
# {.git_hardening_delta}. What each key does:
|
|
86
|
+
#
|
|
87
|
+
# * +core.fsmonitor=false+ — the headline vector: a repo-local
|
|
88
|
+
# +core.fsmonitor = <cmd>+ runs +<cmd>+ on every index refresh (plain
|
|
89
|
+
# +git status+/+diff+, no +.gitattributes+ needed). +false+ is git's
|
|
90
|
+
# default, so legitimate status/diff are unaffected.
|
|
91
|
+
# * +core.pager=cat+ — belt-and-suspenders; auto-paging is already inert
|
|
92
|
+
# under our non-TTY pipe, but a forced +cat+ makes it a no-op regardless.
|
|
93
|
+
#
|
|
94
|
+
# Why this exists — the config-execution vector that makes even a *passive*
|
|
95
|
+
# git command an unconfirmed RCE without it, the narrow diff-driver
|
|
96
|
+
# residual it deliberately does NOT close (+diff.external+ /
|
|
97
|
+
# +.gitattributes+ +.textconv+, uncloseable via environment), and the tie
|
|
98
|
+
# to {PassiveCommandDetector}'s +allow_git:+ — lives in
|
|
99
|
+
# +pikuri-code/DESIGN.md+ (*Passive git needs Bash's hardening*).
|
|
100
|
+
#
|
|
101
|
+
# @return [Array<Array(String, String)>] +[key, value]+ pairs.
|
|
102
|
+
GIT_HARDENING = [
|
|
103
|
+
['core.fsmonitor', 'false'],
|
|
104
|
+
['core.pager', 'cat']
|
|
105
|
+
].freeze
|
|
106
|
+
|
|
95
107
|
# @return [Integer] bytes preserved from the start of the output
|
|
96
108
|
# when the combined-output stream exceeds {OUTPUT_HEAD} + {OUTPUT_TAIL}.
|
|
97
109
|
OUTPUT_HEAD = 15 * 1024
|
|
@@ -103,52 +115,53 @@ module Pikuri
|
|
|
103
115
|
# bullets. Per-parameter constraints (default, max) live in the
|
|
104
116
|
# parameter descriptions.
|
|
105
117
|
#
|
|
118
|
+
# The +Avoid ... cat / rg / find+ bullet is load-bearing, not etiquette —
|
|
119
|
+
# a shell read skips the +cat -n+ numbering {Pikuri::Workspace::Edit}
|
|
120
|
+
# anchors on, the byte caps, the read-before-edit ledger and the path
|
|
121
|
+
# gate. (An outside-model review — Grok 4.6, 2026-08 — called the split
|
|
122
|
+
# the right bet; nothing tests that the model obeys the bullet.)
|
|
123
|
+
#
|
|
106
124
|
# @return [String]
|
|
107
125
|
DESCRIPTION = <<~DESC
|
|
108
126
|
Run a bash command in the workspace.
|
|
109
127
|
|
|
110
128
|
Usage:
|
|
111
129
|
- Use for tasks the dedicated tools can't do: git, tests, package managers, multi-step shell pipelines.
|
|
112
|
-
-
|
|
130
|
+
- IMPORTANT: Avoid using this tool to run `cat`, plain `head` or `tail`, `sed`, `awk`, `rg`, `find` or `echo` commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user, respect the workspace and produce cleaner output.
|
|
113
131
|
- Working directory is ALWAYS the project root: `pwd` returns it, and relative paths in commands resolve from there. To operate in a subfolder, chain `cd` in the same command (`cd src/foo && make test`) — `cd` does NOT persist across calls; each call starts fresh at the project root.
|
|
114
132
|
- stdin is closed; interactive commands hang until timeout. Use non-interactive flags (`apt -y`, `git commit -m`).
|
|
115
133
|
- Plain `cmd &` does NOT detach — the backgrounded process inherits our output pipe and blocks. To genuinely background, redirect fds: `cmd >/dev/null 2>&1 &`. Add `nohup` or `setsid` to survive pikuri exit.
|
|
116
134
|
- Combined stdout+stderr is returned. Suppress either via `2>/dev/null` etc.
|
|
117
135
|
- Large outputs are head+tail-truncated. Pipe through `head`/`tail`/`grep`/`wc` to control volume.
|
|
118
|
-
|
|
119
|
-
-
|
|
136
|
+
# Git
|
|
137
|
+
- Interactive flags (`-i`, e.g. `git rebase -i`, `git add -i`) are not supported in this environment.
|
|
138
|
+
- Use the `gh` CLI (if available) for GitHub operations (PRs, issues, API).
|
|
139
|
+
- Commit or push only when the user asks.
|
|
120
140
|
DESC
|
|
121
141
|
|
|
122
|
-
# @param
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
# {Sandbox::Bubblewrap.new(
|
|
136
|
-
# isolated subprocess
|
|
137
|
-
#
|
|
138
|
-
# {Sandbox} for the rationale.
|
|
139
|
-
# @raise [RuntimeError] if +bash+ or +timeout+ aren't on +PATH+;
|
|
140
|
-
# fail-loud at construction rather than the first tool call.
|
|
142
|
+
# @param filesystem [Pikuri::Workspace::Filesystem] captured for +chdir+;
|
|
143
|
+
# commands always run in +filesystem.project_root+ (explicit +chdir:+
|
|
144
|
+
# even when the process already chdir'd there, so a sub-agent or
|
|
145
|
+
# embedding host gets the same cwd). Bash does NOT path-resolve
|
|
146
|
+
# arguments — the +command+ is opaque shell syntax.
|
|
147
|
+
# @param confirmer [Pikuri::Workspace::Confirmer] consulted before every
|
|
148
|
+
# command that +passive_detector+ does not pre-approve.
|
|
149
|
+
# @param passive_detector [#passive?, nil] optional predicate over the
|
|
150
|
+
# raw command string ({PassiveCommandDetector}); +true+ runs with no
|
|
151
|
+
# prompt, +false+/+nil+ falls through to +confirmer+. The
|
|
152
|
+
# passive-auto-gate seam, kept separate so "how to ask" ({Confirmer})
|
|
153
|
+
# and "what may skip asking" (this) stay composable axes.
|
|
154
|
+
# @param sandbox [Code::Bash::Sandbox] filesystem-sandbox seam (default
|
|
155
|
+
# {Sandbox::NONE}). Pass {Sandbox::Bubblewrap.new(filesystem:)} for an
|
|
156
|
+
# isolated subprocess; see {Sandbox}.
|
|
157
|
+
# @raise [RuntimeError] if +bash+ or +timeout+ aren't on +PATH+.
|
|
141
158
|
# @return [Bash]
|
|
142
|
-
def initialize(
|
|
159
|
+
def initialize(filesystem:, confirmer:, sandbox: Sandbox::NONE, passive_detector: nil)
|
|
143
160
|
Bash.send(:check_binaries!)
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
# +Confirmer+ is the only line of defense in that mode. The
|
|
149
|
-
# bundled {Sandbox::Bubblewrap} addresses this concern with a
|
|
150
|
-
# filesystem-restricted subprocess; warn only when the host has
|
|
151
|
-
# opted out (or never opted in).
|
|
161
|
+
# Without a sandbox, bash runs with pikuri's UID + filesystem view —
|
|
162
|
+
# anything the user can read the LLM can read (+cat ~/.ssh/id_*+, +aws
|
|
163
|
+
# configure list+), with the per-command Confirmer the only defense. Warn
|
|
164
|
+
# only when the host opted out (or never opted in); Bubblewrap fixes it.
|
|
152
165
|
if sandbox.equal?(Sandbox::NONE)
|
|
153
166
|
LOGGER.warn(
|
|
154
167
|
'Code::Bash is unsandboxed: commands run under your UID and can read ' \
|
|
@@ -171,9 +184,31 @@ module Pikuri
|
|
|
171
184
|
"max #{MAX_TIMEOUT}, e.g. 300."
|
|
172
185
|
},
|
|
173
186
|
execute: ->(command:, description: nil, timeout: DEFAULT_TIMEOUT) {
|
|
174
|
-
Bash.run(
|
|
187
|
+
Bash.run(filesystem: filesystem, confirmer: confirmer, sandbox: sandbox,
|
|
188
|
+
passive_detector: passive_detector,
|
|
175
189
|
command: command, description: description, timeout: timeout)
|
|
176
|
-
}
|
|
190
|
+
},
|
|
191
|
+
# Both inbound axes come from the *sandbox*, not the workspace: the
|
|
192
|
+
# workspace governs what the LLM observes through the file tools, the
|
|
193
|
+
# sandbox governs what bash sees. Scope the workspace to a vouched-for
|
|
194
|
+
# repo and +cat ~/Downloads/*+ still works under a full-root bind.
|
|
195
|
+
#
|
|
196
|
+
# Note what this does *not* claim: +:human_reviewed+, though a
|
|
197
|
+
# +Confirmer+ is right there. The human approves a *command* — a
|
|
198
|
+
# program, not a payload. Behind a pipe, a heredoc, +$(…)+ or a script
|
|
199
|
+
# file, the bytes that egress are computed at runtime and were never on
|
|
200
|
+
# screen, so approving +curl+ approves an intent. That failure of the
|
|
201
|
+
# payload-visibility clause is what keeps a coding agent over a private
|
|
202
|
+
# repo loud, and it is deliberately a literal here rather than
|
|
203
|
+
# something a rule engine could talk itself out of.
|
|
204
|
+
#
|
|
205
|
+
# The destination axis is left to derive: a shell picks its own host,
|
|
206
|
+
# so a live leg here is always +:attacker_reachable+.
|
|
207
|
+
trifecta_legs: Pikuri::Tool::TrifectaLegs.new(
|
|
208
|
+
private: filesystem.private?,
|
|
209
|
+
untrusted: sandbox.confined_to_workspace? && filesystem.trusted? ? :none : :hard,
|
|
210
|
+
egress_payload_review: sandbox.egress? ? :unreviewed : :no_egress
|
|
211
|
+
)
|
|
177
212
|
)
|
|
178
213
|
end
|
|
179
214
|
|
|
@@ -181,28 +216,42 @@ module Pikuri
|
|
|
181
216
|
# either +"$ ...\n<out>\n\nexit status: N"+ on a normal exit, or
|
|
182
217
|
# +"Error: ..."+ on rejection / timeout / bad inputs.
|
|
183
218
|
#
|
|
184
|
-
# @param
|
|
219
|
+
# @param filesystem [Pikuri::Workspace::Filesystem]
|
|
185
220
|
# @param confirmer [Pikuri::Workspace::Confirmer]
|
|
186
|
-
# @param sandbox [Code::Bash::Sandbox] wraps the spawned argv
|
|
187
|
-
#
|
|
188
|
-
#
|
|
221
|
+
# @param sandbox [Code::Bash::Sandbox] wraps the spawned argv.
|
|
222
|
+
# @param passive_detector [#passive?, nil] passive-command pre-approval
|
|
223
|
+
# predicate; a +true+ verdict skips the prompt. See {#initialize}.
|
|
189
224
|
# @param command [String] raw command as supplied by the LLM
|
|
190
225
|
# @param description [String, nil] optional short label for the user
|
|
191
226
|
# @param timeout [Integer] seconds before SIGTERM is sent
|
|
192
227
|
# @return [String]
|
|
193
|
-
def self.run(
|
|
228
|
+
def self.run(filesystem:, confirmer:, command:, description:, timeout:,
|
|
229
|
+
sandbox: Sandbox::NONE, passive_detector: nil)
|
|
194
230
|
return 'Error: empty bash command.' if command.strip.empty?
|
|
195
231
|
return "Error: timeout must be >= 1, got #{timeout}" if timeout < 1
|
|
196
232
|
return "Error: timeout must be <= #{MAX_TIMEOUT}, got #{timeout}" if timeout > MAX_TIMEOUT
|
|
197
233
|
|
|
198
|
-
|
|
199
|
-
|
|
234
|
+
# A command the detector deems passive (provably observe-only) runs
|
|
235
|
+
# without a prompt; everything else is confirmed by the human.
|
|
236
|
+
unless passive_detector&.passive?(command)
|
|
237
|
+
request = compose_request(command: command, description: description, timeout: timeout)
|
|
238
|
+
case confirmer.ask(request: request)
|
|
239
|
+
in Pikuri::Workspace::Confirmer::Rejected(reason:)
|
|
240
|
+
msg = +'Error: user declined the bash command.'
|
|
241
|
+
msg << " Reason: #{reason}" if reason && !reason.empty?
|
|
242
|
+
return msg
|
|
243
|
+
in Pikuri::Workspace::Confirmer::Approved
|
|
244
|
+
# fall through to execution (the command is non-editable, so the
|
|
245
|
+
# approved detail is the same command we already hold)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
200
248
|
|
|
201
249
|
argv = sandbox.wrap([
|
|
202
250
|
'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s",
|
|
203
251
|
'bash', '-c', command
|
|
204
252
|
])
|
|
205
|
-
result = Pikuri::Subprocess.spawn(*argv, chdir:
|
|
253
|
+
result = Pikuri::Subprocess.spawn(*argv, chdir: filesystem.project_root.to_s,
|
|
254
|
+
env: subprocess_env(filesystem)).wait
|
|
206
255
|
|
|
207
256
|
output = truncate(result.output)
|
|
208
257
|
exit_code = result.status.exitstatus
|
|
@@ -217,41 +266,81 @@ module Pikuri
|
|
|
217
266
|
end
|
|
218
267
|
end
|
|
219
268
|
|
|
220
|
-
# Compose the
|
|
269
|
+
# Compose the semantic confirmation request:
|
|
221
270
|
#
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
271
|
+
# * question — +OK to run bash[: <desc>][ \[Timeout: Ns\]]+
|
|
272
|
+
# * detail — +$ <command>+, the command VERBATIM (the confirmer
|
|
273
|
+
# escapes for its medium; see the class header's Confirmation
|
|
274
|
+
# section)
|
|
225
275
|
#
|
|
226
276
|
# Colon after +bash+ is dropped when there's no description, since a
|
|
227
277
|
# trailing colon with nothing after it reads as broken. Timeout
|
|
228
278
|
# suffix appears only when non-default.
|
|
229
279
|
#
|
|
230
|
-
# @return [
|
|
231
|
-
def self.
|
|
232
|
-
|
|
280
|
+
# @return [Pikuri::Workspace::Confirmer::Request]
|
|
281
|
+
def self.compose_request(command:, description:, timeout:)
|
|
282
|
+
question = +'OK to run bash'
|
|
233
283
|
desc_clean = description&.strip
|
|
234
|
-
|
|
235
|
-
|
|
284
|
+
question << ": #{desc_clean}" if desc_clean && !desc_clean.empty?
|
|
285
|
+
question << " [Timeout: #{timeout}s]" if timeout != DEFAULT_TIMEOUT
|
|
286
|
+
|
|
287
|
+
Pikuri::Workspace::Confirmer::Request.new(question: question, detail: "$ #{command}")
|
|
288
|
+
end
|
|
289
|
+
private_class_method :compose_request
|
|
290
|
+
|
|
291
|
+
# Environment for the bash subprocess: the de-bundlerized base
|
|
292
|
+
# ({Pikuri::BundlerEnv.clean_delta}), then git hardening
|
|
293
|
+
# ({.git_hardening_delta}), then the filesystem's git identity
|
|
294
|
+
# ({Pikuri::Workspace::Filesystem#env}). The three deltas touch disjoint
|
|
295
|
+
# keys (bundler vars / +GIT_CONFIG_*+ / +GIT_AUTHOR_*+ +
|
|
296
|
+
# +GIT_COMMITTER_*+), so merge order is immaterial.
|
|
297
|
+
#
|
|
298
|
+
# @param filesystem [Pikuri::Workspace::Filesystem]
|
|
299
|
+
# @return [Hash{String=>(String,nil)}] +env:+ delta for
|
|
300
|
+
# {Pikuri::Subprocess.spawn} (a +nil+ value unsets the key).
|
|
301
|
+
def self.subprocess_env(filesystem)
|
|
302
|
+
Pikuri::BundlerEnv.clean_delta.merge(git_hardening_delta).merge(filesystem.env)
|
|
303
|
+
end
|
|
304
|
+
private_class_method :subprocess_env
|
|
236
305
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
306
|
+
# Emit {GIT_HARDENING} as a +GIT_CONFIG_COUNT+ / +GIT_CONFIG_KEY_<n>+ /
|
|
307
|
+
# +GIT_CONFIG_VALUE_<n>+ env delta. This protocol has the same top
|
|
308
|
+
# precedence as command-line +-c+, so it overrides a hostile repo-local
|
|
309
|
+
# +core.fsmonitor+ regardless of what the repo says.
|
|
310
|
+
#
|
|
311
|
+
# Via *environment*, not by rewriting the model's command, because the
|
|
312
|
+
# command is opaque shell syntax we refuse to parse, and env applies
|
|
313
|
+
# uniformly to *every* git in the command — each link of a +&&+/+|+ chain,
|
|
314
|
+
# and any git a script underneath forks — with no parsing.
|
|
315
|
+
#
|
|
316
|
+
# Indices are offset past any +GIT_CONFIG_COUNT+ already inherited, so our
|
|
317
|
+
# keys append rather than clobber a host's; the higher index also wins
|
|
318
|
+
# under git's last-wins rule on a duplicate key.
|
|
319
|
+
#
|
|
320
|
+
# @return [Hash{String=>String}]
|
|
321
|
+
def self.git_hardening_delta
|
|
322
|
+
base = ENV['GIT_CONFIG_COUNT'].to_i
|
|
323
|
+
delta = {}
|
|
324
|
+
GIT_HARDENING.each_with_index do |(key, value), i|
|
|
325
|
+
delta["GIT_CONFIG_KEY_#{base + i}"] = key
|
|
326
|
+
delta["GIT_CONFIG_VALUE_#{base + i}"] = value
|
|
327
|
+
end
|
|
328
|
+
delta['GIT_CONFIG_COUNT'] = (base + GIT_HARDENING.size).to_s
|
|
329
|
+
delta
|
|
242
330
|
end
|
|
243
|
-
private_class_method :
|
|
331
|
+
private_class_method :git_hardening_delta
|
|
244
332
|
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
333
|
+
# Neutralize control bytes for the *observation* echo (+"$ ..."+ in the
|
|
334
|
+
# tool result) — without this a model could craft +command: "\rrm -rf
|
|
335
|
+
# ~/"+ that visually overwrites the echo line after the user read it.
|
|
336
|
+
# Delegates to {Pikuri::Sanitizer} (preserves +\n+, visualizes the rest).
|
|
337
|
+
# The echo is passive, so {Pikuri::Sanitizer::Warning}s are dropped here —
|
|
338
|
+
# they surface at the confirmation prompt, before the user approves.
|
|
250
339
|
#
|
|
251
340
|
# @param command [String]
|
|
252
341
|
# @return [String]
|
|
253
342
|
def self.visible(command)
|
|
254
|
-
|
|
343
|
+
Pikuri::Sanitizer.sanitize(command).text
|
|
255
344
|
end
|
|
256
345
|
private_class_method :visible
|
|
257
346
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Code
|
|
5
|
+
# The +enter_plan_mode+ tool: turns on the shared
|
|
6
|
+
# {Pikuri::Workspace::ReadOnly} flag. Once active, Write/Edit refuse and the
|
|
7
|
+
# {Extension}'s per-turn reminder steers the model to research and propose;
|
|
8
|
+
# the model exits via {ExitPlanMode}, or the host flips the same flag from a
|
|
9
|
+
# key binding / slash command. Entry is just as often host-initiated as
|
|
10
|
+
# model-driven — either way the flag is the single source of truth. (It
|
|
11
|
+
# gates Write/Edit, not Bash — see {Pikuri::Workspace::ReadOnly}.)
|
|
12
|
+
#
|
|
13
|
+
# Sharing: +P_shared_locked+ — the flag it flips is thread-safe, so nothing
|
|
14
|
+
# breaks. Decide the *semantics* on purpose, though: agents sharing one flag
|
|
15
|
+
# share one plan mode, so this agent entering it disables writes for all of
|
|
16
|
+
# them. Per-agent plan mode means a {Pikuri::Workspace::ReadOnly} each.
|
|
17
|
+
class EnterPlanMode < Pikuri::Tool
|
|
18
|
+
# @return [String]
|
|
19
|
+
DESCRIPTION = <<~DESC
|
|
20
|
+
Enter plan mode: a read-only posture for researching and designing a change before writing any of it.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
- Reach for this before a non-trivial change — new features, multi-file edits, refactors, or anything with several valid approaches — so the user can sign off on the approach before code is written.
|
|
24
|
+
- Skip it for small, unambiguous edits (a typo, an obvious one-line fix) and for pure research questions.
|
|
25
|
+
- While in plan mode the file-editing tools are disabled; explore and reason freely, then present a plan with exit_plan_mode for approval.
|
|
26
|
+
- When unsure, prefer entering: alignment up front is cheaper than redone work.
|
|
27
|
+
DESC
|
|
28
|
+
|
|
29
|
+
# @param read_only [Pikuri::Workspace::ReadOnly] the shared
|
|
30
|
+
# read-only flag to activate.
|
|
31
|
+
# @return [EnterPlanMode]
|
|
32
|
+
def initialize(read_only:)
|
|
33
|
+
super(
|
|
34
|
+
name: 'enter_plan_mode',
|
|
35
|
+
description: DESCRIPTION,
|
|
36
|
+
parameters: Parameters::EMPTY,
|
|
37
|
+
execute: -> { EnterPlanMode.run(read_only: read_only) },
|
|
38
|
+
# No legs: flips a local mode flag.
|
|
39
|
+
trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Activate plan mode (idempotent) and return the observation that
|
|
44
|
+
# confirms the posture to the model.
|
|
45
|
+
#
|
|
46
|
+
# @param read_only [Pikuri::Workspace::ReadOnly]
|
|
47
|
+
# @return [String] tool observation
|
|
48
|
+
def self.run(read_only:)
|
|
49
|
+
read_only.activate!
|
|
50
|
+
'You are now in plan mode (read-only): research and design only, do not edit ' \
|
|
51
|
+
'files. When the plan is ready, call exit_plan_mode to present it for approval.'
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Code
|
|
5
|
+
# The +exit_plan_mode+ tool: presents the finished plan for approval and, on
|
|
6
|
+
# a yes, turns the shared {Pikuri::Workspace::ReadOnly} flag back off. The
|
|
7
|
+
# plan is shown and the yes/no collected through the same
|
|
8
|
+
# {Pikuri::Workspace::Confirmer} the +write+/+bash+ tools use, so
|
|
9
|
+
# presentation + attacker-text sanitization live in one place. On approval
|
|
10
|
+
# the flag flips off and the model is told to proceed; on a decline it stays
|
|
11
|
+
# on and the observation steers a refine-and-re-present, never an edit. The
|
|
12
|
+
# LLM-authored plan is passed as the request +detail+, so the Confirmer
|
|
13
|
+
# neutralizes it before display.
|
|
14
|
+
#
|
|
15
|
+
# Sharing: as {EnterPlanMode} — +P_shared_locked+ on the flag, and sharing
|
|
16
|
+
# it makes plan mode VM-wide. The {Pikuri::Workspace::Confirmer} pulls the
|
|
17
|
+
# other way ({Confirmer::Terminal} is +P_one_agent+), so a shared instance
|
|
18
|
+
# is only as shareable as the confirmer behind it.
|
|
19
|
+
class ExitPlanMode < Pikuri::Tool
|
|
20
|
+
# @return [String]
|
|
21
|
+
DESCRIPTION = <<~DESC
|
|
22
|
+
Exit plan mode by presenting your finished implementation plan for the user's approval.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
- Call this only once your plan is complete and unambiguous; pass the full plan as the `plan` argument.
|
|
26
|
+
- The user is shown the plan and asked to approve. On approval you leave plan mode and may implement it; on a decline you stay in plan mode — refine the plan and call this again.
|
|
27
|
+
- If a requirement is still unclear, ask the user directly first (without leaving plan mode) rather than presenting a half-formed plan.
|
|
28
|
+
- Do not use this for pure research tasks — those never enter plan mode in the first place.
|
|
29
|
+
DESC
|
|
30
|
+
|
|
31
|
+
# @param read_only [Pikuri::Workspace::ReadOnly] the shared
|
|
32
|
+
# read-only flag to deactivate on approval.
|
|
33
|
+
# @param confirmer [Pikuri::Workspace::Confirmer] consulted to
|
|
34
|
+
# present the plan and collect the approval.
|
|
35
|
+
# @return [ExitPlanMode]
|
|
36
|
+
def initialize(read_only:, confirmer:)
|
|
37
|
+
super(
|
|
38
|
+
name: 'exit_plan_mode',
|
|
39
|
+
description: DESCRIPTION,
|
|
40
|
+
parameters: Parameters.build { |p|
|
|
41
|
+
p.required_string :plan,
|
|
42
|
+
'Your complete implementation plan, in Markdown, ' \
|
|
43
|
+
'shown to the user for approval. Cover the files ' \
|
|
44
|
+
'to change and the changes to each, e.g. ' \
|
|
45
|
+
'"## Plan\n1. Add X to foo.rb\n2. ...".'
|
|
46
|
+
},
|
|
47
|
+
execute: ->(plan:) {
|
|
48
|
+
ExitPlanMode.run(read_only: read_only, confirmer: confirmer, plan: plan)
|
|
49
|
+
},
|
|
50
|
+
# No legs: flips a local mode flag (behind a confirmer, which grades
|
|
51
|
+
# nothing here — there is no leg to gate).
|
|
52
|
+
trifecta_legs: Pikuri::Tool::TrifectaLegs::NONE
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Present +plan+ for approval; on yes deactivate plan mode, on no
|
|
57
|
+
# keep it active. Returns the observation the model reacts to.
|
|
58
|
+
#
|
|
59
|
+
# @param read_only [Pikuri::Workspace::ReadOnly]
|
|
60
|
+
# @param confirmer [Pikuri::Workspace::Confirmer]
|
|
61
|
+
# @param plan [String] the LLM-authored plan to show the user
|
|
62
|
+
# @return [String] tool observation
|
|
63
|
+
def self.run(read_only:, confirmer:, plan:)
|
|
64
|
+
unless read_only.active?
|
|
65
|
+
return 'Error: not in plan mode, so there is nothing to exit. ' \
|
|
66
|
+
'Proceed with the work directly.'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
request = Pikuri::Workspace::Confirmer::Request.new(
|
|
70
|
+
question: 'Approve this plan? (approving exits plan mode and lets the agent edit files)',
|
|
71
|
+
detail: plan
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if confirmer.confirm?(request: request)
|
|
75
|
+
read_only.deactivate!
|
|
76
|
+
'Plan approved — you are no longer in plan mode. Implement the plan now.'
|
|
77
|
+
else
|
|
78
|
+
'The user did not approve the plan; you are still in plan mode (read-only). ' \
|
|
79
|
+
'Ask what they would like changed, revise the plan, and call exit_plan_mode ' \
|
|
80
|
+
'again when ready. Do not edit any files.'
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|