rubik 0.1.4 → 0.1.6

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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +66 -0
  4. data/lib/rubik/coeffects.rb +14 -15
  5. data/lib/rubik/config/defaults.rb +9 -6
  6. data/lib/rubik/config.rb +8 -0
  7. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  8. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  9. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  10. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  11. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  12. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  13. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  14. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  15. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  16. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  17. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  18. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  19. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  20. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  21. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  22. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  23. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  24. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  25. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  26. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  27. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  28. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  29. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  30. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  31. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  32. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  33. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  34. data/lib/rubik/prompt/context_builder.rb +1 -1
  35. data/lib/rubik/prompt/doctrine.rb +5 -3
  36. data/lib/rubik/repl/commands/subagents.rb +2 -0
  37. data/lib/rubik/repl/input_execution.rb +42 -0
  38. data/lib/rubik/repl/input_loop.rb +57 -7
  39. data/lib/rubik/repl/prompt_grace.rb +103 -0
  40. data/lib/rubik/repl/terminal_guard.rb +5 -1
  41. data/lib/rubik/security/redactor.rb +7 -7
  42. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  43. data/lib/rubik/tools/builtins/exec.rb +76 -28
  44. data/lib/rubik/tools/builtins/files.rb +6 -1
  45. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  46. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  47. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  48. data/lib/rubik/tools/truncator.rb +19 -2
  49. data/lib/rubik/tools/uri_resolver.rb +84 -20
  50. data/lib/rubik/uris.rb +11 -0
  51. data/lib/rubik/version.rb +1 -1
  52. metadata +17 -3
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "notices"
4
+
5
+ module Rubik
6
+ module Orchestrator
7
+ module Subagent
8
+ # Wait semantics (issue #126): waiting already knew when it returned; now
9
+ # it reports WHY — settled, deadline (targets still active), an actionable
10
+ # pause, a dependency queue, or unknown ids. The supervisor keeps
11
+ # wait_for (rows only) for its existing callers; the tool surface reads
12
+ # the reason so the model gets a truthful next action instead of guessing
13
+ # whether a timeout meant failure.
14
+ module WaitSupervisor
15
+ WaitOutcome = Struct.new(:reason, :rows, :paused, :active, keyword_init: true) do
16
+ def settled? = reason == :settled
17
+ end
18
+
19
+ def wait_for(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
20
+ wait(ids, timeout: timeout, wait_for_budget: wait_for_budget).rows
21
+ end
22
+
23
+ def wait(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
24
+ target_ids = Array(ids).compact.map(&:to_s)
25
+ deadline = Time.now + [timeout.to_i, 1].max
26
+
27
+ loop do
28
+ active_targets = @monitor.synchronize do
29
+ candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] }
30
+ candidates.select do |inst|
31
+ inst.active? && !inst.error_paused? && !inst.awaiting_human? && (!wait_for_budget || !inst.awaiting_budget?)
32
+ end
33
+ end
34
+
35
+ # Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
36
+ break if (active_targets.empty? && !deferred_pending?(target_ids)) || Time.now >= deadline
37
+
38
+ sleep 0.1
39
+ end
40
+
41
+ @monitor.synchronize do
42
+ candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] || queued_spec(id) }
43
+ paused = candidates.select { |c| parked_candidate?(c) }
44
+ active = candidates.select { |c| c.respond_to?(:active?) && c.active? }
45
+ WaitOutcome.new(reason: wait_reason(target_ids, candidates, active, deadline),
46
+ rows: candidates.map(&:to_h),
47
+ paused: paused.map(&:id),
48
+ active: active.map(&:id))
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # The returned-fact classification, derived from what was observed —
55
+ # never from a guess about the model's intent.
56
+ def wait_reason(target_ids, candidates, active, deadline)
57
+ return :unknown if target_ids.any? && candidates.empty?
58
+ return :deadline if Time.now >= deadline && active.any?
59
+ return :paused if candidates.any? { |c| parked_candidate?(c) }
60
+ return :queued if candidates.any? { |c| c.respond_to?(:queued?) && c.queued? }
61
+
62
+ :settled
63
+ end
64
+
65
+ def parked_candidate?(candidate)
66
+ (candidate.respond_to?(:error_paused?) && candidate.error_paused?) ||
67
+ (candidate.respond_to?(:awaiting_human?) && candidate.awaiting_human?) ||
68
+ (candidate.respond_to?(:awaiting_budget?) && candidate.awaiting_budget?)
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Rubik
6
+ module Orchestrator
7
+ # Foreground task cancellation token (issue #125).
8
+ #
9
+ # A foreground turn (or any explicit task scope) owns the subagents it
10
+ # spawns: the token is provided into the runtime context for the turn's
11
+ # duration and inherited — transitively — by the child contexts of the
12
+ # spawned subagents, so nested spawns remain owned by the same task. The
13
+ # interrupt handler cancels the token and the supervisor stops every
14
+ # execution still carrying it; work that was explicitly detached (no
15
+ # owner) or spawned under a different token is untouched by construction.
16
+ #
17
+ # The token is a leaf: no dependencies, no callbacks, no IO — cancellation
18
+ # REQUEST state only. The supervisor owns the stop choreography and the
19
+ # outcome report (cancellation-requested → complete / remaining), keeping
20
+ # this object honest about what it knows.
21
+ class TaskCancellation
22
+ attr_reader :id, :reason, :cancelled_at
23
+
24
+ # The owner of explicitly detached work: a token that can never be
25
+ # cancelled, provided into a detached subagent's child context so its
26
+ # own descendants inherit the detachment instead of the turn's token.
27
+ def self.detached
28
+ new(id: "task-detached-#{Process.pid}-#{rand(1000..9999)}", detached: true)
29
+ end
30
+
31
+ def initialize(id: nil, detached: false)
32
+ @id = id || "task-#{Process.pid}-#{Time.now.to_i}-#{rand(1000..9999)}"
33
+ @detached = detached
34
+ @monitor = Monitor.new
35
+ @cancelled = false
36
+ @reason = nil
37
+ @cancelled_at = nil
38
+ end
39
+
40
+ def cancelled? = @monitor.synchronize { @cancelled }
41
+
42
+ def detached? = @detached
43
+
44
+ # Idempotent: the first cancel wins (its reason/timestamp is the record),
45
+ # later calls report false and change nothing.
46
+ def cancel!(reason = "foreground task cancelled (^C)")
47
+ @monitor.synchronize do
48
+ return false if @detached || @cancelled
49
+
50
+ @cancelled = true
51
+ @reason = reason.to_s
52
+ @cancelled_at = Time.now
53
+ true
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -22,15 +22,20 @@ module Rubik
22
22
  # - content — an opaque String per channel (main path: Result#to_xml;
23
23
  # subagent: truncated plain output). The dictionary unifies the row
24
24
  # SHAPE, not the content encoding.
25
+ # - exit_code / metadata (issue #127) — the structured command outcome:
26
+ # machine-readable status without parsing display text. Also read by no
27
+ # wire-side consumer (same observability-only contract as status), so
28
+ # every channel writing nil/{} keeps the shape stable.
25
29
  module ToolHistory
26
- ROW_KEYS = [:role, :tool_call_id, :tool_name, :status, :duration_ms, :content].freeze
30
+ ROW_KEYS = [:role, :tool_call_id, :tool_name, :status, :duration_ms, :content, :exit_code, :metadata].freeze
27
31
 
28
32
  module_function
29
33
 
30
34
  # Appends exactly one canonical row to +history+ and returns it.
31
- def write(history, tool_call_id:, tool_name:, status:, duration_ms:, content:)
35
+ def write(history, tool_call_id:, tool_name:, status:, duration_ms:, content:, exit_code: nil, metadata: nil)
32
36
  row = { role: "tool", tool_call_id: tool_call_id, tool_name: tool_name,
33
- status: status, duration_ms: duration_ms, content: content }
37
+ status: status, duration_ms: duration_ms, content: content,
38
+ exit_code: exit_code, metadata: metadata || {} }
34
39
  history << row
35
40
  row
36
41
  end
@@ -334,7 +334,7 @@ module Rubik
334
334
  tool_section = if tool_schemas.nil? || tool_schemas.empty?
335
335
  ""
336
336
  elsif native_tools
337
- names = tool_schemas.map { |t| t[:name] || t["name"] }.compact.join(", ")
337
+ names = tool_schemas.filter_map { |t| t[:name] || t["name"] }.join(", ")
338
338
  Doctrine.native_tools_note(names)
339
339
  else
340
340
  Doctrine.json_tools_note(tool_schemas)
@@ -34,9 +34,9 @@ module Rubik
34
34
  4. REPL Bang Commands: `exec_repl_command` runs only the session-safe commands listed in <agent_commands> — runtime configuration, context and step-budget self-management, buffer review and rollback, runtime introspection, and code validation. If that section is absent, do not invoke REPL commands. File, code, test, and shell operations never travel through REPL commands — under the maestro doctrine (rules 2-3) they are delegated to subagents, and the direct tools (`read_file`, `write_file`, `replace_file_content`, `run_tests`, `run_command`) are your coder subagents' mechanics. Buffer-review commands (`!diff`, `!undo`, `!rollback`) operate on the in-memory staging buffer (unapplied edits) — they are a preview layer, not file ground truth: verify file content with `read_file`, and read git history from your coder subagent's `run_command` report.
35
35
  5. Autonomous Tool Synthesis: #{Rubik::Prompt::ToolTeachings.teaching_for('synthesize_tool')}
36
36
  6. Output Budget & Conciseness: Respect your output token budget (`<output_budget>`). Produce direct, high-signal responses and avoid unnecessary pleasantries, verbose conversational preambles, or duplicated context blocks that waste generation quota.
37
- 7. Subagent Delegation & Parallelism (Maestro): As the maestro you orchestrate — ALL execution work (exploration, code changes, verification) is delegated via `invoke_subagent`, in every mode. Match task to archetype: use 'research' for read-only codebase exploration; MUST use 'coder' for code modifications, refactoring, and test execution ('research' has no write or test tools); reach for 'self' (a full capability clone, minus subagent spawning) for work that fits neither archetype — runtime introspection/repair, self-evolution work, hybrid tasks. Subagents execute asynchronously in disposable sub-contexts in the background. For non-trivial modifications, allocate sufficient step budget (e.g. `max_steps: 100-140`) to prevent avoidable mid-flight pauses. When chaining stages with `depends_on`, direct the successor to read the predecessor's transcript via `read_file('rubik://storage/<predecessor-id>/transcript')`. Consume subagents' structured reports (Outcome, Changes/Locations, Verification/Findings, Assumptions) directly. After dispatching subagents, do NOT block with 'wait' unless you strictly depend on their output in the current turn — conclude your turn and liberate the prompt so the user can interact concurrently. Completion, budget-pause, error, and message notifications arrive reactively: during your turn via your notification inbox; while you are idle, the runtime automatically wakes you with a dedicated notification-evaluation turn — evaluate the pending notifications, act (extend budget, steer with send_message, retrieve results, wrap up, or report to the user), and conclude your turn again. Subagents can ask YOU questions mid-flight via `ask_question` — a question notification means that subagent is BLOCKED until you reply: answer it promptly with `send_message(recipient: '<id>', message: '<your answer>')` (it times out otherwise). Call `#{Rubik::Orchestrator::Subagent::Notices.wait_hint_template}` only when synchronous waiting is strictly required. Never poll 'status' repeatedly in a loop.
37
+ 7. Subagent Delegation & Parallelism (Maestro): As the maestro you orchestrate — ALL execution work (exploration, code changes, verification) is delegated via `invoke_subagent`, in every mode. Match task to archetype: use 'research' for read-only codebase exploration; MUST use 'coder' for code modifications, refactoring, and test execution ('research' has no write or test tools); reach for 'self' (a full capability clone, minus subagent spawning) for work that fits neither archetype — runtime introspection/repair, self-evolution work, hybrid tasks. Subagents execute asynchronously in disposable sub-contexts in the background. For non-trivial modifications, allocate sufficient step budget (e.g. `max_steps: 100-140`) to prevent avoidable mid-flight pauses. When chaining stages with `depends_on`, hand the successor the predecessor's canonical transcript reference (`read_file('rubik://subagent/<storage-id>/transcript')`, reported by status/completion notices) — never a handwritten storage path; if the successor launches before the storage id is known, paste the predecessor's findings into its brief. Consume subagents' structured reports (Outcome, Changes/Locations, Verification/Findings, Assumptions) directly. After dispatching subagents, do NOT block with 'wait' unless you strictly depend on their output in the current turn — conclude your turn and liberate the prompt so the user can interact concurrently. Completion, budget-pause, error, and message notifications arrive reactively: during your turn via your notification inbox; while you are idle, the runtime automatically wakes you with a dedicated notification-evaluation turn — evaluate the pending notifications, act (extend budget, steer with send_message, retrieve results, wrap up, or report to the user), and conclude your turn again. Subagents can ask YOU questions mid-flight via `ask_question` — a question notification means that subagent is BLOCKED until you reply: answer it promptly with `send_message(recipient: '<id>', message: '<your answer>')` (it times out otherwise). Call `#{Rubik::Orchestrator::Subagent::Notices.wait_hint_template}` only when synchronous waiting is strictly required. Never poll 'status' repeatedly in a loop.
38
38
  8. Runtime Introspection, Self-Healing & Human Feedback: If internal errors or service degradation occur, inspect runtime state with `inspect_runtime` and repair failed services via `repair_runtime_service`. When critical requirements are underspecified or high-impact architectural choices require user confirmation, use `ask_question` with structured multiple-choice options.
39
- 9. Central Storage & Clean Worktree: #{Rubik::Prompt::ToolTeachings.teaching_for('storage')} #{Rubik::Prompt::ToolTeachings.teaching_for('vakanuvis')} #{Rubik::Prompt::ToolTeachings.teaching_for('show_file')} Subagent transcripts are indexed automatically as `transcript` entities (alias `subagent-<storage-id>`). #{Rubik::Prompt::ToolTeachings.teaching_for('prune_context')}
39
+ 9. Central Storage & Clean Worktree: #{Rubik::Prompt::ToolTeachings.teaching_for('storage')} #{Rubik::Prompt::ToolTeachings.teaching_for('vakanuvis')} #{Rubik::Prompt::ToolTeachings.teaching_for('show_file')} Subagent transcripts are indexed automatically as `transcript` entities (alias `subagent-<storage-id>`) and are readable live or persisted via the canonical `rubik://subagent/<storage-id>/transcript` reference — indexed entities and live history have different availability, so address the exact execution by its unique storage id (from status/completion notices), never by a display id or a disk path. #{Rubik::Prompt::ToolTeachings.teaching_for('prune_context')}
40
40
  </operational_rules>
41
41
  XML
42
42
  end
@@ -117,7 +117,9 @@ module Rubik
117
117
  "and `wait` accepts queued ids (blocks until the whole chain settles). A failed/killed predecessor " \
118
118
  "CANCELS its dependents — re-dispatch with the new information; a budget- or error-paused predecessor " \
119
119
  "keeps them queued — extend or resume it instead. When chaining dependent stages, direct the successor " \
120
- "to read predecessor findings via `read_file('rubik://storage/<predecessor-id>/transcript')`.",
120
+ "to read predecessor findings via the canonical transcript reference " \
121
+ "(`read_file('rubik://subagent/<storage-id>/transcript')`, from status/completion notices) — never a " \
122
+ "handwritten storage path; the findings can also be pasted into the brief.",
121
123
  "Chronicler briefs: when dispatching a subagent, paste the relevant chronicled knowledge into the task " \
122
124
  "brief — the recall digest or a rubik://storage/... link — so the subagent pulls the depth itself. " \
123
125
  "Subagents never see the `vakanuvis` tool; you are the only librarian.",
@@ -94,6 +94,8 @@ module Rubik
94
94
  ctx.out.puts " Uptime: #{inst.uptime.round(2)}s"
95
95
  ctx.out.puts " Workspace: #{inst.workspace}"
96
96
  ctx.out.puts " Transcript: #{inst.transcript_uri}"
97
+ # Canonical read reference (issue #123): the path above is diagnostic.
98
+ ctx.out.puts " Read Ref: #{inst.transcript_ref}" if inst.respond_to?(:transcript_ref)
97
99
  ctx.out.puts " Last Error: #{inst.last_error}" if inst.last_error
98
100
  ctx.out.puts " Result: #{inst.result.to_s.slice(0, 150)}..." if inst.result
99
101
  end
@@ -148,6 +148,13 @@ module Rubik
148
148
  # an unbound read would mask the in-flight exception with a
149
149
  # NameError, breaking the "cleanup must never mask" contract.
150
150
  interrupted = false
151
+ # Task ownership (issue #125): this turn owns the subagents it spawns.
152
+ # The token is published for the turn's duration; ^C cancels it and the
153
+ # supervisor stops exactly the owned descendants (detached work and
154
+ # older turns' executions carry different tokens and survive).
155
+ task = Rubik::Orchestrator::TaskCancellation.new
156
+ @active_task = task
157
+ @runtime_manager&.provide(Rubik::Coeffects::TASK_CANCELLATION, task)
151
158
  # Plan 109 staleness rule (see execute_command): a real turn's
152
159
  # context already includes the idle-injected notices. The notice
153
160
  # turn itself drains first, so this clear is a no-op for it.
@@ -271,6 +278,10 @@ module Rubik
271
278
  # at route time), so draining loses nothing the model needed.
272
279
  clear_pending_wake_notices! if respond_to?(:clear_pending_wake_notices!)
273
280
  Rubik::REPL::InterruptHandler.restore_console_after_interrupt
281
+ # Propagate the cancellation to owned work (issue #125) and report the
282
+ # fact: with nothing owned running this reads as an interrupted wait,
283
+ # with owned descendants it reads as a cancelled task tree.
284
+ task_cancellation_lines(cancel_foreground_task!(task)).each { |line| @output.puts line }
274
285
  # Dedicated event type: restore_session must NOT hydrate this as a
275
286
  # dialogue turn (the user_turn recorded at method entry already
276
287
  # carries the question; a repl_command-style pair would duplicate it).
@@ -294,6 +305,7 @@ module Rubik
294
305
  record_failed_turn!(text, "failed", "#{e.class}: #{message}", orchestrator)
295
306
  ensure
296
307
  @active_loop = nil
308
+ @active_task = nil
297
309
  # Flush a half-streamed markdown tail so an interrupt never eats the last
298
310
  # line. Cleanup must never mask the in-flight exception (Interrupt, LLM
299
311
  # error): each step reports its own failure to stderr instead of raising.
@@ -320,6 +332,36 @@ module Rubik
320
332
  reap_dead_kernel_units! if respond_to?(:reap_dead_kernel_units!)
321
333
  end
322
334
 
335
+ # Foreground task cancellation (issue #125): cancel the turn's token and
336
+ # stop its owned descendants within the configured grace period. Returns
337
+ # the supervisor's fact report (nil when no supervisor is wired).
338
+ def cancel_foreground_task!(task)
339
+ supervisor = Rubik::Coeffects.get(@runtime_manager&.context, Rubik::Coeffects::SUBAGENT_SUPERVISOR)
340
+ return nil unless task && supervisor.respond_to?(:cancel_owned)
341
+
342
+ supervisor.cancel_owned(task, timeout: @config.fetch("subagents.cancel_timeout", 2.0))
343
+ rescue StandardError => e
344
+ warn "[rubik] task cancellation failed: #{e.class}: #{e.message}"
345
+ nil
346
+ end
347
+
348
+ # The UI's two truths (issue #125): an interrupt with nothing owned is an
349
+ # interrupted WAIT; one with owned work is a cancelled TASK TREE — and
350
+ # remaining work is named, so an incomplete cleanup is never silent.
351
+ def task_cancellation_lines(outcome)
352
+ return ["⏸️ [Rubik] Bekleme kesildi (^C) — iptal edilecek bağlı alt ajan yok."] unless outcome&.any?
353
+
354
+ line = "🛑 [Rubik] Görev ağacı iptal edildi (^C): #{outcome.stopped.size} bağlı alt ajan durduruldu"
355
+ line << ", #{outcome.queued_cancelled.size} kuyruk işi iptal edildi" unless outcome.queued_cancelled.empty?
356
+ if outcome.complete?
357
+ line << "; temizlik tamamlandı."
358
+ else
359
+ remaining = outcome.remaining.map { |r| "#{r[:id]}:#{r[:state]}" }.join(", ")
360
+ line << "; temizlik TAMAMLANMADI — kalan: #{remaining}."
361
+ end
362
+ [line]
363
+ end
364
+
323
365
  # Shared spinner teardown. Cleanup must never mask the in-flight
324
366
  # exception (AGENTS.md §5): a spinner whose tick thread died holding
325
367
  # the mutex raises ThreadError from synchronize — report it to stderr
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "prompt_grace"
4
+
3
5
  module Rubik
4
6
  module REPL
5
7
  # Öneri 3 (Kademe 1): Shell'den ayrılan girdi döngüsü sorumluluğu.
@@ -18,6 +20,7 @@ module Rubik
18
20
 
19
21
  def run_interactive
20
22
  @running = true
23
+ @interactive_prompt_loop = true
21
24
  multiline_buffer = []
22
25
  heredoc_delimiter = nil
23
26
  last_line_at = nil
@@ -33,6 +36,7 @@ module Rubik
33
36
  end
34
37
  end
35
38
  ensure
39
+ @interactive_prompt_loop = false
36
40
  stop
37
41
  end
38
42
 
@@ -45,7 +49,7 @@ module Rubik
45
49
  text = read_paste_block
46
50
  return { stop: false, buffer: [], delimiter: nil } if text.strip.empty?
47
51
 
48
- execute_input(text)
52
+ submit_interactive_prompt(text)
49
53
  { stop: false, buffer: [], delimiter: nil }
50
54
  end
51
55
 
@@ -66,6 +70,7 @@ module Rubik
66
70
  flush_subagent_notices! if respond_to?(:flush_subagent_notices!)
67
71
  @terminal_guard&.flush_notices!
68
72
  current_prompt, boxed = prepare_prompt_line(!heredoc_delimiter.nil?)
73
+ restored_submission = !@prompt_restore.nil?
69
74
  line = safe_readline(current_prompt, add_history: false)
70
75
  @output.puts box_bottom_border if boxed
71
76
  return handle_eof(multiline_buffer, heredoc_delimiter) if line.nil? || line == "\x04" || line == "\x1a"
@@ -82,14 +87,14 @@ module Rubik
82
87
  # "instantly", so an ungated collector would swallow consecutive test
83
88
  # lines like "x = a << 1" + "exit" into one turn (seen in test_repl).
84
89
  now = paste_clock
85
- if paste_burst_eligible? && !line.strip.empty? && !heredoc_delimiter &&
90
+ if !restored_submission && paste_burst_eligible? && !line.strip.empty? && !heredoc_delimiter &&
86
91
  multiline_buffer.empty? &&
87
92
  (paste_line_fast?(last_line_at, now) || paste_pending?)
88
93
  return collect_paste_burst(line)
89
94
  end
90
95
 
91
96
  # Push non-empty command to history
92
- @reline::HISTORY.push(line) if !line.strip.empty? && line != begin
97
+ @reline::HISTORY.push(line) if !prompt_grace_interactive? && !line.strip.empty? && line != begin
93
98
  @reline::HISTORY.to_a.last
94
99
  rescue
95
100
  nil
@@ -152,13 +157,15 @@ module Rubik
152
157
  collector.call
153
158
  end
154
159
  text = lines.join("\n")
155
- execute_input(text)
160
+ submit_interactive_prompt(text)
156
161
  # Return the final line timestamp so a following paste burst chain
157
162
  # (copy #2 right after copy #1) is still detected as fast lines.
158
163
  { stop: false, buffer: [], delimiter: nil, last_line_at: last_at }
159
164
  end
160
165
 
161
166
  def safe_readline(prompt, add_history: false)
167
+ return read_restored_prompt(prompt) if @prompt_restore
168
+
162
169
  if @terminal_guard
163
170
  @terminal_guard.with_reading { @reline.readline(prompt, add_history) }
164
171
  else
@@ -179,7 +186,7 @@ module Rubik
179
186
  def process_heredoc_line(line, multiline_buffer, heredoc_delimiter)
180
187
  if line.strip == heredoc_delimiter
181
188
  full_text = multiline_buffer.join("\n")
182
- execute_input(full_text)
189
+ submit_interactive_prompt(full_text)
183
190
  { stop: false, buffer: [], delimiter: nil }
184
191
  else
185
192
  multiline_buffer << line
@@ -190,7 +197,7 @@ module Rubik
190
197
  def process_normal_line(line, _multiline_buffer)
191
198
  # Only trigger heredoc if line explicitly opens a multiline block (e.g. `<<EOF`, `!plan <<EOF`)
192
199
  # NEVER trigger on inline operators like `array << item` or `a << b`
193
- heredoc_match = line.match(/^\s*(?:[a-zA-Z0-9_!.-]+\s+)?<<[-~]?['"]?([A-Za-z0-9_]+)['"]?\s*$/)
200
+ heredoc_match = line.match(/\A[ \t]*(?:[a-zA-Z0-9_!.-]+[ \t]+)?<<[-~]?['"]?([A-Za-z0-9_]+)['"]?[ \t]*\z/)
194
201
  if heredoc_match && !line.strip.start_with?("#")
195
202
  delimiter = heredoc_match[1]
196
203
  prefix = line.sub(/<<[-~]?['"]?#{Regexp.escape(delimiter)}['"]?\s*$/, "").strip
@@ -200,7 +207,7 @@ module Rubik
200
207
  stop
201
208
  { stop: true, buffer: [], delimiter: nil }
202
209
  else
203
- execute_input(line)
210
+ submit_interactive_prompt(line)
204
211
  { stop: false, buffer: [], delimiter: nil }
205
212
  end
206
213
  end
@@ -208,6 +215,49 @@ module Rubik
208
215
  def process_multiline_or_line(line)
209
216
  execute_input(line)
210
217
  end
218
+
219
+ def prompt_grace_interactive?
220
+ @interactive_prompt_loop && !@replaying && interactive_input?
221
+ end
222
+
223
+ def submit_interactive_prompt(text)
224
+ return execute_input(text) unless prompt_grace_interactive?
225
+
226
+ if CommandParser.parse(text).type == :prompt
227
+ milliseconds = @config.fetch("ui.prompt_grace_window_ms", 1000)
228
+ outcome = PromptGrace.new(input: @input, output: @output,
229
+ context: @runtime_manager&.context).wait(milliseconds)
230
+ if outcome == :edit
231
+ @prompt_restore = text
232
+ return
233
+ end
234
+ return if outcome == :cancel
235
+ end
236
+ @reline::HISTORY.push(text) unless text.strip.empty? || @reline::HISTORY.to_a.last == text
237
+ execute_input(text)
238
+ end
239
+
240
+ def read_restored_prompt(prompt)
241
+ text = @prompt_restore
242
+ @prompt_restore = nil
243
+ previous = @reline.pre_input_hook
244
+ scope = @runtime_manager ? @runtime_manager.context.extend : Rubik::Runtime::Context.new
245
+ scope.on_dispose { @reline.pre_input_hook = previous }
246
+ @reline.pre_input_hook = lambda {
247
+ previous&.call
248
+ @reline.insert_text(text)
249
+ }
250
+ reader = -> { @reline.readmultiline(prompt, false) { true } }
251
+ if @terminal_guard
252
+ # A reactive wake must never inject Enter while Reline resets an
253
+ # apparently empty buffer, before the restoration hook fills it.
254
+ @terminal_guard.with_collecting { @terminal_guard.with_reading(&reader) }
255
+ else
256
+ reader.call
257
+ end
258
+ ensure
259
+ scope&.dispose
260
+ end
211
261
  end
212
262
  end
213
263
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "io/wait"
5
+ require_relative "../runtime/context"
6
+
7
+ module Rubik
8
+ module REPL
9
+ # Synchronous admission gate: no task, recorder entry or delayed callback
10
+ # exists until this returns :execute. A temporary context owns all terminal
11
+ # effects and is pruned from its parent on every exit.
12
+ class PromptGrace
13
+ ESCAPE_TIMEOUT = 0.03
14
+ STATUS = "Gönderiliyor… Esc: düzenlemeye dön"
15
+
16
+ def initialize(input:, output:, context: nil, clock: nil, reader: nil)
17
+ @input = input
18
+ @output = output
19
+ @context = context
20
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
21
+ @reader = reader || method(:read_byte)
22
+ end
23
+
24
+ def wait(milliseconds)
25
+ return :execute if milliseconds.zero?
26
+
27
+ scope = @context ? @context.extend : Rubik::Runtime::Context.new
28
+ mode = @input.console_mode
29
+ scope.on_dispose { @input.console_mode = mode }
30
+ @input.raw!
31
+ scope.on_dispose do
32
+ @output.print "\r\e[2K"
33
+ @output.flush
34
+ end
35
+ @output.print STATUS
36
+ @output.flush
37
+ deadline = @clock.call + (milliseconds / 1000.0)
38
+ loop do
39
+ remaining = deadline - @clock.call
40
+ if remaining <= 0
41
+ # A burst arriving at the deadline can leave bytes queued after
42
+ # the last processed key. Do not let its Enter submit a later turn.
43
+ if @input.respond_to?(:nread) && @input.nread.positive?
44
+ @input.iflush
45
+ @output.print "\a\r\e[2K[Rubik] Bekleme sırasında gelen tuşlar yok sayıldı.\r\n"
46
+ end
47
+ return :execute
48
+ end
49
+
50
+ byte = @reader.call(remaining)
51
+ next if byte.nil?
52
+ return :cancel if [:eof, "\x03"].include?(byte)
53
+
54
+ if byte == "\e"
55
+ escape = classify_escape
56
+ return escape unless escape == :sequence
57
+ end
58
+
59
+ # Other keys are intentionally rejected, with immediate feedback.
60
+ # Enter never leaks into the next readline as an accidental submit.
61
+ @output.print "\a\r\e[2K#{STATUS} · Tuş yok sayıldı"
62
+ @output.flush
63
+ end
64
+ rescue Interrupt
65
+ :cancel
66
+ ensure
67
+ scope&.dispose
68
+ end
69
+
70
+ private
71
+
72
+ def read_byte(timeout)
73
+ return nil unless @input.wait_readable(timeout)
74
+
75
+ @input.read_nonblock(1)
76
+ rescue IO::WaitReadable
77
+ nil
78
+ rescue EOFError
79
+ :eof
80
+ end
81
+
82
+ # CSI/SS3 arrows and function keys start with Esc too. Resolve this
83
+ # ambiguity even when Esc arrives just before the submission deadline.
84
+ def classify_escape
85
+ following = @reader.call(ESCAPE_TIMEOUT)
86
+ return :edit if following.nil? || following == :eof
87
+ return :cancel if following == "\x03"
88
+ return :sequence unless ["[", "O"].include?(following)
89
+
90
+ deadline = @clock.call + ESCAPE_TIMEOUT
91
+ loop do
92
+ remaining = deadline - @clock.call
93
+ break if remaining <= 0
94
+
95
+ byte = @reader.call(remaining)
96
+ return :cancel if byte == "\x03"
97
+ break if byte.nil? || byte == :eof || byte.match?(/[\x40-\x7e]/)
98
+ end
99
+ :sequence
100
+ end
101
+ end
102
+ end
103
+ end
@@ -108,7 +108,11 @@ module Rubik
108
108
  # Plan 109: wake a blocked empty readline by injecting an Enter.
109
109
  # Reline 0.7.0's IO gates (ANSI/Windows/Dumb) all poll and re-check
110
110
  # their ungetc buffer (10ms/100ms/100ms), so the injection reaches
111
- # the blocked reader on every gate. idle_and_empty? is re-evaluated
111
+ # the blocked reader on every gate. The per-poll re-check is 0.7.0+
112
+ # behavior — rubik.gemspec floors that version and
113
+ # test/integration/test_reline_wakeup_contract.rb pins the real gate
114
+ # (on 0.6.x the injected Enter strands until the next keystroke).
115
+ # idle_and_empty? is re-evaluated
112
116
  # inside this critical section (Monitor reentrancy makes the nested
113
117
  # synchronize free); the residual window between check and gate
114
118
  # consumption is inherent to Reline's thread model and accepted. A
@@ -23,6 +23,9 @@ module Rubik
23
23
  # PERF: Pre-compiled constants to avoid re-allocation inside hot loops
24
24
  URL_PATTERN = %r{(https?://[^:]+:)([^@]+)@}
25
25
  URL_MASK = "\\1#{MASK_STRING}@".freeze
26
+ STATIC_PATTERNS = PATTERNS.reject { |pattern| pattern == URL_PATTERN }.freeze
27
+ # Skip static replacement passes for assignment-only and URL-only inputs.
28
+ STATIC_PATTERNS_COMBINED_REGEX = Regexp.union(STATIC_PATTERNS).freeze
26
29
  QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^:]+:|password|passwd|api_key|secret|token}i
27
30
 
28
31
  # Anahtar-maskeleme desenleri (Session::Recorder#mask_payload ile aynı
@@ -46,13 +49,10 @@ module Rubik
46
49
  # ve desenler zaten bu metne uygulanıyordu, dolayısıyla eşdeğerdir.
47
50
  return result unless result.match?(QUICK_CHECK_REGEX)
48
51
 
49
- PATTERNS.each do |pattern|
50
- if pattern == URL_PATTERN
51
- result.gsub!(pattern, URL_MASK)
52
- else
53
- result.gsub!(pattern, MASK_STRING)
54
- end
55
- end
52
+ # Keep replacement order: masking one credential can expose a word
53
+ # boundary needed to recognize the next (for example, a bearer token).
54
+ STATIC_PATTERNS.each { |pattern| result.gsub!(pattern, MASK_STRING) } if result.match?(STATIC_PATTERNS_COMBINED_REGEX)
55
+ result.gsub!(URL_PATTERN, URL_MASK)
56
56
 
57
57
  result.gsub!(ASSIGNMENT_KEY_REGEX) do
58
58
  "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{MASK_STRING}"
@@ -26,23 +26,30 @@ module Rubik
26
26
  return unless store && inst
27
27
 
28
28
  file = inst.transcript_uri.to_s
29
+ result_ref = inst.respond_to?(:result_ref) ? inst.result_ref : nil
29
30
  # Gerçek upsert: alias üzerinden varsa mevcut id güncellenir — resume
30
31
  # sonrası ikinci tamamlanma yinelenen transcript varlığı yaratmaz.
31
32
  ref = Rubik::Uris.transcript_ref(inst.storage_id)
32
33
  existing = store.get(type: "transcript", id: ref)
34
+ links = {
35
+ "subagent_id" => inst.storage_id.to_s,
36
+ # The raw path is forensics-only (read_file denies it): the
37
+ # canonical read route rides in the entity itself so recall
38
+ # consumers land on show_file in one hop (issue #97 finding 5).
39
+ "canonical" => Rubik::Uris.subagent_transcript(inst.storage_id),
40
+ "transcript" => file
41
+ }
42
+ # Issue #122: the searchable summary also points at the paginated
43
+ # retained-result route. Availability is session-scoped (archive TTL),
44
+ # so this is a pointer with explicit expiration semantics, not a
45
+ # promise of unbounded retention.
46
+ links["result"] = result_ref if result_ref
33
47
  store.put(
34
48
  type: "transcript",
35
49
  title: "[#{inst.type_name}] #{inst.role} (#{inst.id}) — #{inst.state}",
36
- body: entity_body(inst, file),
50
+ body: entity_body(inst, file, result_ref: result_ref),
37
51
  tags: [inst.type_name.to_s, inst.state.to_s].uniq,
38
- links: {
39
- "subagent_id" => inst.storage_id.to_s,
40
- # The raw path is forensics-only (read_file denies it): the
41
- # canonical read route rides in the entity itself so recall
42
- # consumers land on show_file in one hop (issue #97 finding 5).
43
- "canonical" => Rubik::Uris.subagent_transcript(inst.storage_id),
44
- "transcript" => file
45
- },
52
+ links: links,
46
53
  status: inst.state.to_s,
47
54
  id: existing&.[]("id"),
48
55
  ref: ref
@@ -59,7 +66,7 @@ module Rubik
59
66
  # gelmeyen her metin (prompt fallback, inst.result) HAM memory'den
60
67
  # gelir — kalıcı+aranabilir gövdeye girmeden Redactor'dan geçer
61
68
  # (dosya tarafının maskeleme paritesi; review bulgusu).
62
- def entity_body(inst, file)
69
+ def entity_body(inst, file, result_ref: nil)
63
70
  task = first_user_task(file)
64
71
  task_part = if task
65
72
  "[task] #{clip(task)}"
@@ -71,7 +78,10 @@ module Rubik
71
78
 
72
79
  result = inst.respond_to?(:result) ? redact(inst.result.to_s) : ""
73
80
  result_part = result.empty? ? "[state] #{inst.state} — no result recorded" : "[result] #{clip(result)}"
74
- "#{task_part}\n#{result_part}"
81
+ body = "#{task_part}\n#{result_part}"
82
+ return body unless result_ref && inst.respond_to?(:state) && inst.state.to_s == "completed"
83
+
84
+ "#{body}\n[retrieve] #{result_ref} — full retained result, paginated (manage_subagents action 'result')"
75
85
  end
76
86
 
77
87
  def redact(text)