Class: Pikuri::Workspace::ReadOnly

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

Overview

A workspace-scoped read-only flag: when active, the mutating file tools (Write/Edit) refuse to write and return a read-only observation instead. A plain shared boolean with change notification — the host constructs one and hands it to Extension (which threads it into Write/Edit) and to whatever drives it.

Enforcement is the point, so a prompt-only "plan mode" is not a cheaper version of this: an outside-model review (Grok 4.6, 2026-08) rated the posture it couldn't talk its way past over being told to "think step by step and then start editing".

What this does NOT gate: Bash

This governs only Write/Edit, not Code::Bash. A shell command is Turing-complete with no reliable static "does this write" test (+echo+ + redirect, sed -i, >, dd, a Makefile target), so we make no attempt to gate Bash and don't pretend to: a "read-only" workspace can still mutate disk through bash, subject only to bash's own confirmer. A truly inert posture must also withhold or sandbox bash; this flag is not that boundary.

Two writers, one flag

The single source of truth for "are writes disabled now". Both a host (a key binding, a slash command) and a feature on top (pikuri-code's +enter_plan_mode+/+exit_plan_mode+ via #activate!/#deactivate!) flip the same instance. #on_change lets an observer (pikuri-code re-emits it as Code::PlanModeChanged) render the posture continuously.

Not an Agent control

Deliberately not under Pikuri::Agent::Control: unlike StepLimit / Cancellable / Interloper, the loop never reads this — only Write/Edit's execute does (the loop can't deny a tool call and feed the model an explanatory observation, so the refusal lives in the tool). It's a workspace property, not a loop control.

Thread-safe: the two writers may be on different threads, so the compare-and-set in #flip is +Mutex+-guarded (a read-then-write isn't atomic under the GVL; getting it wrong would fire #on_change twice or zero times). Callbacks run outside the lock so a re-entrant or blocking one can't deadlock the next flip.

Constant Summary collapse

DEFAULT_MESSAGE =

Default reason clause when the host gives no message:. Posture-agnostic — it must NOT assume plan mode (a host can engage read-only for other reasons); Write/Edit splice it after their own "cannot — " prefix. A driving feature (plan mode) passes its own — see Code::Extension::PLAN_MODE_READONLY_MESSAGE.

Returns:

  • (String)
'the workspace is read-only, so changes are disabled right now'

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(active: false, message: DEFAULT_MESSAGE) ⇒ ReadOnly

Returns a new instance of ReadOnly.

Parameters:

  • active (Boolean) (defaults to: false)

    initial posture; true starts read-only (a --plan startup flag).

  • message (String) (defaults to: DEFAULT_MESSAGE)

    the reason clause Write/Edit append; default DEFAULT_MESSAGE. Immutable — the host supplies the wording.



61
62
63
64
65
66
# File 'lib/pikuri/workspace/read_only.rb', line 61

def initialize(active: false, message: DEFAULT_MESSAGE)
  @mutex = Mutex.new
  @active = active
  @message = message
  @on_change = []
end

Instance Attribute Details

#messageString (readonly)

Returns the reason clause to surface when a write is refused; see #initialize.

Returns:

  • (String)

    the reason clause to surface when a write is refused; see #initialize.



70
71
72
# File 'lib/pikuri/workspace/read_only.rb', line 70

def message
  @message
end

Instance Method Details

#activate!void

This method returns an undefined value.

Turn the posture on. Idempotent (no #on_change when already active); safe off the loop thread.



82
83
84
# File 'lib/pikuri/workspace/read_only.rb', line 82

def activate!
  flip(true)
end

#active?Boolean

Returns whether writes are currently disabled. Observable from any thread.

Returns:

  • (Boolean)

    whether writes are currently disabled. Observable from any thread.



74
75
76
# File 'lib/pikuri/workspace/read_only.rb', line 74

def active?
  @mutex.synchronize { @active }
end

#deactivate!void

This method returns an undefined value.

Turn the posture off. Idempotent (no #on_change when already inactive); safe off the loop thread.



90
91
92
# File 'lib/pikuri/workspace/read_only.rb', line 90

def deactivate!
  flip(false)
end

#on_change {|active| ... } ⇒ void

This method returns an undefined value.

Register a callback fired once per real transition, with the new state. Guarded so a late registration races safely with a concurrent flip.

Yield Parameters:

  • active (Boolean)

    the new posture

Raises:

  • (ArgumentError)

    when called without a block



100
101
102
103
104
105
# File 'lib/pikuri/workspace/read_only.rb', line 100

def on_change(&blk)
  raise ArgumentError, 'on_change requires a block' unless block_given?

  @mutex.synchronize { @on_change << blk }
  nil
end

#to_sString

Returns short label for debug prints; reflects the current posture.

Returns:

  • (String)

    short label for debug prints; reflects the current posture.



109
110
111
# File 'lib/pikuri/workspace/read_only.rb', line 109

def to_s
  "ReadOnly(#{active? ? 'on' : 'off'})"
end