Class: Pikuri::Workspace::ReadOnly
- Inherits:
-
Object
- Object
- Pikuri::Workspace::ReadOnly
- 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. 'the workspace is read-only, so changes are disabled right now'
Instance Attribute Summary collapse
-
#message ⇒ String
readonly
The reason clause to surface when a write is refused; see #initialize.
Instance Method Summary collapse
-
#activate! ⇒ void
Turn the posture on.
-
#active? ⇒ Boolean
Whether writes are currently disabled.
-
#deactivate! ⇒ void
Turn the posture off.
-
#initialize(active: false, message: DEFAULT_MESSAGE) ⇒ ReadOnly
constructor
A new instance of ReadOnly.
-
#on_change {|active| ... } ⇒ void
Register a callback fired once per real transition, with the new state.
-
#to_s ⇒ String
Short label for debug prints; reflects the current posture.
Constructor Details
#initialize(active: false, message: DEFAULT_MESSAGE) ⇒ ReadOnly
Returns a new instance of ReadOnly.
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 = @on_change = [] end |
Instance Attribute Details
#message ⇒ String (readonly)
Returns 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 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.
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.
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_s ⇒ String
Returns 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 |