pikuri-code 0.0.7 → 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 +367 -353
- data/lib/pikuri/code/bash/tokenizer.rb +310 -0
- data/lib/pikuri/code/bash.rb +181 -178
- 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 +40 -93
- data/lib/pikuri-code.rb +4 -12
- data/prompts/coding-system-prompt.txt +16 -14
- data/prompts/persona-git-repo-researcher.txt +2 -1
- metadata +15 -9
data/lib/pikuri/code/bash.rb
CHANGED
|
@@ -3,91 +3,71 @@
|
|
|
3
3
|
module Pikuri
|
|
4
4
|
module Code
|
|
5
5
|
# The +bash+ tool — run an arbitrary shell command in the workspace.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# (workspace + confirmer captured by the +execute+ closure at
|
|
10
|
-
# 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).
|
|
11
9
|
#
|
|
12
10
|
# == Confirmation
|
|
13
11
|
#
|
|
14
|
-
# Every command
|
|
15
|
-
# {Pikuri::Workspace::Confirmer::Request} —
|
|
16
|
-
# plus
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# The
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# passes through {.visible}, so the model can't smuggle a
|
|
24
|
-
# +\r\033[2K rm -rf ~/+ behind the displayed text there either.
|
|
25
|
-
# Execution uses the raw command; only displays are sanitized.
|
|
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.
|
|
26
21
|
#
|
|
27
22
|
# == Subprocess wiring
|
|
28
23
|
#
|
|
29
|
-
# The command runs through {Pikuri::Subprocess.spawn} with argv:
|
|
30
|
-
#
|
|
31
24
|
# timeout --signal=TERM --kill-after=5s <timeout>s bash -c <command>
|
|
32
25
|
#
|
|
33
|
-
# +bash -c+ (no +-l+) — no profile/rc sourcing
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
# The subprocess environment is "de-bundlerized" first — pikuri is
|
|
41
|
-
# usually launched under Bundler, which would otherwise leak its own
|
|
42
|
-
# +BUNDLE_GEMFILE+ / +RUBYOPT+ / ... into the command so a +bundle
|
|
43
|
-
# exec+ run against another project picks up *pikuri's* Gemfile. See
|
|
44
|
-
# {.subprocess_env} / {.bundler_clean_delta}.
|
|
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}.
|
|
45
32
|
#
|
|
46
33
|
# == Timeout detection
|
|
47
34
|
#
|
|
48
|
-
# GNU
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# rather than a routine timeout.
|
|
57
|
-
#
|
|
58
|
-
# Caveat: +137+ is ambiguous — a command killed by the OOM-killer
|
|
59
|
-
# also exits +137+. v1 accepts the mis-classification; the
|
|
60
|
-
# 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).
|
|
61
43
|
#
|
|
62
44
|
# == Output handling
|
|
63
45
|
#
|
|
64
|
-
# Combined stdout+stderr (popen2e)
|
|
65
|
-
# {
|
|
66
|
-
#
|
|
67
|
-
# 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+.
|
|
68
49
|
#
|
|
69
50
|
# == Backgrounded subprocesses
|
|
70
51
|
#
|
|
71
|
-
# Plain +cmd &+ does NOT detach — the
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
# +
|
|
76
|
-
# SIGTERM on pikuri exit via {Pikuri::Subprocess.cleanup!}; +nohup+ /
|
|
77
|
-
# +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.
|
|
78
57
|
#
|
|
79
|
-
# ==
|
|
58
|
+
# == Sharing
|
|
80
59
|
#
|
|
81
|
-
#
|
|
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.
|
|
82
66
|
#
|
|
83
|
-
# *
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
# * +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.
|
|
87
70
|
class Bash < Pikuri::Tool
|
|
88
|
-
# Pikuri-convention per-module logger; the +Bash+ progname tags the
|
|
89
|
-
# construction-time warning below so it's clear in the shared
|
|
90
|
-
# +Pikuri.log_io+ stream which tool issued it.
|
|
91
71
|
# @return [Logger]
|
|
92
72
|
LOGGER = Pikuri.logger_for('Bash')
|
|
93
73
|
|
|
@@ -101,6 +81,29 @@ module Pikuri
|
|
|
101
81
|
# passed to +timeout --kill-after=...+.
|
|
102
82
|
KILL_AFTER = '5s'
|
|
103
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
|
+
|
|
104
107
|
# @return [Integer] bytes preserved from the start of the output
|
|
105
108
|
# when the combined-output stream exceeds {OUTPUT_HEAD} + {OUTPUT_TAIL}.
|
|
106
109
|
OUTPUT_HEAD = 15 * 1024
|
|
@@ -112,52 +115,53 @@ module Pikuri
|
|
|
112
115
|
# bullets. Per-parameter constraints (default, max) live in the
|
|
113
116
|
# parameter descriptions.
|
|
114
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
|
+
#
|
|
115
124
|
# @return [String]
|
|
116
125
|
DESCRIPTION = <<~DESC
|
|
117
126
|
Run a bash command in the workspace.
|
|
118
127
|
|
|
119
128
|
Usage:
|
|
120
129
|
- Use for tasks the dedicated tools can't do: git, tests, package managers, multi-step shell pipelines.
|
|
121
|
-
-
|
|
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.
|
|
122
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.
|
|
123
132
|
- stdin is closed; interactive commands hang until timeout. Use non-interactive flags (`apt -y`, `git commit -m`).
|
|
124
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.
|
|
125
134
|
- Combined stdout+stderr is returned. Suppress either via `2>/dev/null` etc.
|
|
126
135
|
- Large outputs are head+tail-truncated. Pipe through `head`/`tail`/`grep`/`wc` to control volume.
|
|
127
|
-
|
|
128
|
-
-
|
|
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.
|
|
129
140
|
DESC
|
|
130
141
|
|
|
131
|
-
# @param
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
# {Sandbox::Bubblewrap.new(
|
|
145
|
-
# isolated subprocess
|
|
146
|
-
#
|
|
147
|
-
# {Sandbox} for the rationale.
|
|
148
|
-
# @raise [RuntimeError] if +bash+ or +timeout+ aren't on +PATH+;
|
|
149
|
-
# 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+.
|
|
150
158
|
# @return [Bash]
|
|
151
|
-
def initialize(
|
|
159
|
+
def initialize(filesystem:, confirmer:, sandbox: Sandbox::NONE, passive_detector: nil)
|
|
152
160
|
Bash.send(:check_binaries!)
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
# +Confirmer+ is the only line of defense in that mode. The
|
|
158
|
-
# bundled {Sandbox::Bubblewrap} addresses this concern with a
|
|
159
|
-
# filesystem-restricted subprocess; warn only when the host has
|
|
160
|
-
# 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.
|
|
161
165
|
if sandbox.equal?(Sandbox::NONE)
|
|
162
166
|
LOGGER.warn(
|
|
163
167
|
'Code::Bash is unsandboxed: commands run under your UID and can read ' \
|
|
@@ -180,9 +184,31 @@ module Pikuri
|
|
|
180
184
|
"max #{MAX_TIMEOUT}, e.g. 300."
|
|
181
185
|
},
|
|
182
186
|
execute: ->(command:, description: nil, timeout: DEFAULT_TIMEOUT) {
|
|
183
|
-
Bash.run(
|
|
187
|
+
Bash.run(filesystem: filesystem, confirmer: confirmer, sandbox: sandbox,
|
|
188
|
+
passive_detector: passive_detector,
|
|
184
189
|
command: command, description: description, timeout: timeout)
|
|
185
|
-
}
|
|
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
|
+
)
|
|
186
212
|
)
|
|
187
213
|
end
|
|
188
214
|
|
|
@@ -190,29 +216,42 @@ module Pikuri
|
|
|
190
216
|
# either +"$ ...\n<out>\n\nexit status: N"+ on a normal exit, or
|
|
191
217
|
# +"Error: ..."+ on rejection / timeout / bad inputs.
|
|
192
218
|
#
|
|
193
|
-
# @param
|
|
219
|
+
# @param filesystem [Pikuri::Workspace::Filesystem]
|
|
194
220
|
# @param confirmer [Pikuri::Workspace::Confirmer]
|
|
195
|
-
# @param sandbox [Code::Bash::Sandbox] wraps the spawned argv
|
|
196
|
-
#
|
|
197
|
-
#
|
|
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}.
|
|
198
224
|
# @param command [String] raw command as supplied by the LLM
|
|
199
225
|
# @param description [String, nil] optional short label for the user
|
|
200
226
|
# @param timeout [Integer] seconds before SIGTERM is sent
|
|
201
227
|
# @return [String]
|
|
202
|
-
def self.run(
|
|
228
|
+
def self.run(filesystem:, confirmer:, command:, description:, timeout:,
|
|
229
|
+
sandbox: Sandbox::NONE, passive_detector: nil)
|
|
203
230
|
return 'Error: empty bash command.' if command.strip.empty?
|
|
204
231
|
return "Error: timeout must be >= 1, got #{timeout}" if timeout < 1
|
|
205
232
|
return "Error: timeout must be <= #{MAX_TIMEOUT}, got #{timeout}" if timeout > MAX_TIMEOUT
|
|
206
233
|
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
209
248
|
|
|
210
249
|
argv = sandbox.wrap([
|
|
211
250
|
'timeout', '--signal=TERM', "--kill-after=#{KILL_AFTER}", "#{timeout}s",
|
|
212
251
|
'bash', '-c', command
|
|
213
252
|
])
|
|
214
|
-
result = Pikuri::Subprocess.spawn(*argv, chdir:
|
|
215
|
-
env: subprocess_env(
|
|
253
|
+
result = Pikuri::Subprocess.spawn(*argv, chdir: filesystem.project_root.to_s,
|
|
254
|
+
env: subprocess_env(filesystem)).wait
|
|
216
255
|
|
|
217
256
|
output = truncate(result.output)
|
|
218
257
|
exit_code = result.status.exitstatus
|
|
@@ -249,90 +288,54 @@ module Pikuri
|
|
|
249
288
|
end
|
|
250
289
|
private_class_method :compose_request
|
|
251
290
|
|
|
252
|
-
# Environment for the bash subprocess: the
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
# +BUNDLE_GEMFILE+ to their *own* Gemfile and +require
|
|
259
|
-
# 'bundler/setup'+ at boot. Doing so makes Bundler rewrite
|
|
260
|
-
# +BUNDLE_GEMFILE+ / +RUBYOPT+ / +GEM_HOME+ / +GEM_PATH+ / +RUBYLIB+
|
|
261
|
-
# / +PATH+ in the *process* environment (stashing the pre-bundler
|
|
262
|
-
# values under +BUNDLER_ORIG_*+). Those vars are inherited by every
|
|
263
|
-
# subprocess we spawn — so a +bundle exec rspec+ the agent runs in
|
|
264
|
-
# *another* Ruby project would resolve against PIKURI's Gemfile and
|
|
265
|
-
# die with a baffling "the Gemfile specifies ... is missing" error.
|
|
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.
|
|
266
297
|
#
|
|
267
|
-
#
|
|
268
|
-
# touched it (see {.bundler_clean_delta}), with the workspace's git
|
|
269
|
-
# identity on top.
|
|
270
|
-
#
|
|
271
|
-
# @param workspace [Pikuri::Workspace::Filesystem]
|
|
298
|
+
# @param filesystem [Pikuri::Workspace::Filesystem]
|
|
272
299
|
# @return [Hash{String=>(String,nil)}] +env:+ delta for
|
|
273
|
-
# {Pikuri::Subprocess.spawn} (a +nil+ value unsets the key
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
bundler_clean_delta.merge(workspace.env)
|
|
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)
|
|
277
303
|
end
|
|
278
304
|
private_class_method :subprocess_env
|
|
279
305
|
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
# +
|
|
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.
|
|
284
310
|
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
# +GEM_*+ wipe — while the bundler-only vars (+BUNDLE_*+, the
|
|
290
|
-
# +-rbundler/setup+ +RUBYOPT+) are dropped. We diff against the live
|
|
291
|
-
# +ENV+ rather than passing the full hash because +spawn+'s +env:+
|
|
292
|
-
# is additive: changed/added keys carry their clean value, keys
|
|
293
|
-
# bundler injected that aren't in the clean env map to +nil+
|
|
294
|
-
# (+Open3.popen2e+ unsets a +nil+-valued key in the child).
|
|
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.
|
|
295
315
|
#
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
#
|
|
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.
|
|
299
319
|
#
|
|
300
|
-
# @return [Hash{String=>
|
|
301
|
-
def self.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
env_delta(current: ENV.to_h, clean: Bundler.unbundled_env)
|
|
305
|
-
end
|
|
306
|
-
private_class_method :bundler_clean_delta
|
|
307
|
-
|
|
308
|
-
# Pure diff between two environment hashes: the additive delta that,
|
|
309
|
-
# applied to +current+ via {Pikuri::Subprocess.spawn}'s +env:+,
|
|
310
|
-
# yields +clean+. A key whose value differs (or is new in +clean+)
|
|
311
|
-
# carries the +clean+ value; a key present in +current+ but absent
|
|
312
|
-
# from +clean+ maps to +nil+ (which +Open3.popen2e+ unsets). Keys
|
|
313
|
-
# equal in both are omitted, keeping the delta minimal.
|
|
314
|
-
#
|
|
315
|
-
# @param current [Hash{String=>String}]
|
|
316
|
-
# @param clean [Hash{String=>String}]
|
|
317
|
-
# @return [Hash{String=>(String,nil)}]
|
|
318
|
-
def self.env_delta(current:, clean:)
|
|
320
|
+
# @return [Hash{String=>String}]
|
|
321
|
+
def self.git_hardening_delta
|
|
322
|
+
base = ENV['GIT_CONFIG_COUNT'].to_i
|
|
319
323
|
delta = {}
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
322
329
|
delta
|
|
323
330
|
end
|
|
324
|
-
private_class_method :
|
|
331
|
+
private_class_method :git_hardening_delta
|
|
325
332
|
|
|
326
|
-
# Neutralize control bytes for
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
# {Pikuri::Sanitizer}
|
|
331
|
-
#
|
|
332
|
-
# prompt routes the same command through the same primitive (see
|
|
333
|
-
# +Confirmer::Terminal+). The echo is passive, so the
|
|
334
|
-
# {Pikuri::Sanitizer::Warning}s are dropped here — they surface at
|
|
335
|
-
# the confirmation prompt, before the user approves.
|
|
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.
|
|
336
339
|
#
|
|
337
340
|
# @param command [String]
|
|
338
341
|
# @return [String]
|
|
@@ -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
|