Files
patterm/internal/scratchpad/scratchpad.go
Harry Bayliss 55c6c93086 Sync MCP surface to SPEC §7 process model
Rename list_children/read_output/kill/send_message_to to their SPEC §7
process_id-shaped names; drop report_to_parent (direction inferred by
send_message) and policy_check (replaced by per-project trust gating).
Add the SPEC's missing tools: start_process, restart_process,
close_process, rename_process, select_process, get_process_status,
get_project_status, get_process_raw_output, search_output,
get_process_ports, whoami, help.

Process model now distinguishes agent/terminal/command kinds with
opaque p_<6hex> IDs. Command entries are session-persistent so they
survive PTY exit and can be Restart'd. Status enum gains starting and
stopped. screen_version, port detection, and bracketed-paste send_input
land alongside.

Trust gating (internal/trust) replaces the regex policy: command-preset
spawns return needs_trust on first use; the user confirms in a
status-line modal and the grant persists to
\$XDG_DATA_HOME/patterm/projects/<key>/trust.json.

Tests cover send_message direction inference (parent↔child, sibling
rejection, nil caller paths) and trust grant persistence across reopen.
2026-05-14 14:29:45 +01:00

152 lines
3.9 KiB
Go

// Package scratchpad manages the project-scoped markdown files described
// in SPEC §3. Files live under
// $XDG_DATA_HOME/patterm/projects/<project-key>/scratchpads/. Last-write-
// wins with a revision token (SPEC §14).
package scratchpad
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Store is the per-project scratchpad directory.
type Store struct {
dir string
}
// Open returns a Store rooted at the SPEC §3 path for projectKey.
func Open(projectKey string) (*Store, error) {
base, err := DataDir()
if err != nil {
return nil, err
}
dir := filepath.Join(base, "projects", projectKey, "scratchpads")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("scratchpad: mkdir %s: %w", dir, err)
}
return &Store{dir: dir}, nil
}
// DataDir resolves $XDG_DATA_HOME/patterm with the conventional fallback.
func DataDir() (string, error) {
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
return filepath.Join(h, "patterm"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", "patterm"), nil
}
// Entry is what List returns; SPEC §7 `scratchpad_list` shape.
type Entry struct {
Name string
Size int64
ModifiedAt string // RFC3339; kept as string so MCP serialization is trivial later
}
func (s *Store) Dir() string { return s.dir }
func (s *Store) List() ([]Entry, error) {
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil, err
}
out := make([]Entry, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, Entry{
Name: e.Name(),
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (s *Store) Read(name string) (content string, revision string, err error) {
p, err := s.safePath(name)
if err != nil {
return "", "", err
}
b, err := os.ReadFile(p)
if err != nil {
return "", "", err
}
return string(b), revisionOf(b), nil
}
// RevisionMismatchError is returned by Write when expectedRevision
// doesn't match the on-disk revision. The MCP scratchpad_write tool
// surfaces CurrentRevision as `current_revision` in the response so
// the caller can re-read and merge (SPEC §7 / §14).
type RevisionMismatchError struct {
CurrentRevision string
}
func (e *RevisionMismatchError) Error() string {
return "scratchpad: revision mismatch (current=" + e.CurrentRevision + ")"
}
// Write replaces the file's contents. expectedRevision, if non-empty,
// must match the current revision or the write is rejected with a
// *RevisionMismatchError (SPEC §14 last-write-wins-with-token).
func (s *Store) Write(name, content, expectedRevision string) (string, error) {
p, err := s.safePath(name)
if err != nil {
return "", err
}
if expectedRevision != "" {
if cur, err := os.ReadFile(p); err == nil {
curRev := revisionOf(cur)
if curRev != expectedRevision {
return "", &RevisionMismatchError{CurrentRevision: curRev}
}
}
}
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
return "", err
}
return revisionOf([]byte(content)), nil
}
func (s *Store) Append(name, content string) error {
p, err := s.safePath(name)
if err != nil {
return err
}
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(content)
return err
}
func (s *Store) safePath(name string) (string, error) {
if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
return "", errors.New("scratchpad: invalid name")
}
return filepath.Join(s.dir, name), nil
}
func revisionOf(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:6])
}