Class: Pikuri::Workspace::Write

Inherits:
Tool
  • Object
show all
Defined in:
lib/pikuri/workspace/write.rb

Overview

The write tool, expressed as a Tool subclass: instantiating Write.new(workspace: ws, confirmer: c) produces a tool whose Tool#to_ruby_llm_tool wiring is identical to any bundled tool’s, so ruby_llm sees nothing special about it. Same shape as Read — workspace and confirmer are captured by the execute closure at construction.

Policy

Three branches based on the on-disk state of path:

  1. *New file* — write, no prompt.

  2. *Existing file, identical content* — return an “Error: …” no-op observation before invoking the confirmer; don’t ask the user to approve a write that wouldn’t change the file. Comparison is byte-strict, in BINARY encoding (trailing-newline-only differences trigger the confirm path; encoding tags can’t make equal bytes compare unequal).

  3. *Existing file, content differs* — confirm with “OK to overwrite <path>: <old> → <new> bytes?” via Confirmer; on yes, write. On no, return a decline-Error observation.

Why ask-on-overwrite (Edit doesn’t)

Edit’s old_string argument is an implicit read-check: the model can’t write a correct old_string without having read the file, so blast radius is bounded by what the model actually knows about file state. Write has no such check, so a hallucinated 500-line content could clobber unread work. The confirmation prompt guards exactly the gap Edit’s argument shape already covers.

Side effects

Parent directories are created (FileUtils.mkdir_p) before the write — matches the git add lib/new/dir/foo.rb mental model and mirrors opencode’s and pi’s behavior. Edge case: mkdir_p succeeds but the write fails; an empty directory is left behind. Accepted for v1 — users have version control.

No atomic temp-file+rename. Plain File.write, same as opencode and pi. The crash-safety story is “the user has git”.

Constant Summary collapse

DESCRIPTION =

Description shown to the LLM. Follows the opencode-shape (summary + Usage: bullets) prescribed by the project’s tool-description convention. Per-parameter constraints live in the parameter descriptions; the Usage: bullets are for “when do I pick this? how does it chain with other tools?”.

Returns:

  • (String)
<<~DESC
  Write a file to the workspace, creating parent directories as needed.

  Usage:
  - Use for new files or full-file rewrites; for partial changes use `edit` instead.
  - Overwriting an existing file requires user confirmation; identical content is rejected as a no-op error — if you see that error, re-read the file rather than trying again.
  - Parent directories are created automatically (mkdir -p).
  - Writes the exact bytes supplied: no trailing-newline normalization, no encoding conversion.
  - Paths outside the workspace are refused.
DESC

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(workspace:, confirmer:) ⇒ Write

Parameters:

  • workspace (Filesystem)

    captured for path resolution; all writes route through workspace.resolve_for_write.

  • confirmer (Confirmer)

    consulted before any overwrite of an existing file with non-identical content.



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/pikuri/workspace/write.rb', line 72

def initialize(workspace:, confirmer:)
  super(
    name: 'write',
    description: DESCRIPTION,
    parameters: Parameters.build { |p|
      p.required_string :path,
                        'Path to the file to write. Relative paths ' \
                        'resolve against the workspace root, e.g. ' \
                        '"lib/foo.rb".'
      p.required_string :content,
                        'Full contents to write to the file, e.g. ' \
                        '"class Foo\nend\n".'
    },
    execute: ->(path:, content:) {
      Write.write(workspace: workspace, confirmer: confirmer, path: path, content: content)
    }
  )
end

Class Method Details

.write(workspace:, confirmer:, path:, content:) ⇒ String

Resolve path against workspace, apply the three-branch policy (new / identical / differs), and return either a success observation or an “Error: …” observation.

Parameters:

  • workspace (Filesystem)
  • confirmer (Confirmer)
  • path (String)

    raw path as supplied by the LLM

  • content (String)

    bytes to write

Returns:

  • (String)

    tool observation



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/pikuri/workspace/write.rb', line 100

def self.write(workspace:, confirmer:, path:, content:)
  resolved = workspace.resolve_for_write(path)

  if resolved.exist?
    return "Error: #{path} is a directory" if resolved.directory?

    existing = read_for_compare(resolved, path)

    if existing == content.b
      return "Error: #{path} already contains exactly this content — " \
             'no write needed. If you intended a change, re-read the ' \
             'file and try again.'
    end

    request = Confirmer::Request.new(
      question: "OK to overwrite #{path}: #{existing.bytesize}#{content.bytesize} bytes?"
    )
    return "Error: user declined the write to #{path}." unless confirmer.confirm?(request: request)

    write_bytes(resolved, content)
    "Updated #{path} (#{existing.bytesize}#{content.bytesize} bytes)"
  else
    write_bytes(resolved, content)
    "Created #{path} (#{content.bytesize} bytes)"
  end
rescue Filesystem::Error, Error => e
  "Error: #{e.message}"
rescue Errno::EACCES => e
  "Error: cannot write #{path}: #{e.message}"
end