rubik 0.4.1 → 0.5.0

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +118 -1
  4. data/lib/rubik/ast/cache_store.rb +69 -5
  5. data/lib/rubik/ast/extraction_result.rb +6 -0
  6. data/lib/rubik/ast/graph.rb +32 -5
  7. data/lib/rubik/ast/ruby_extractor.rb +8 -2
  8. data/lib/rubik/buffer/apply_contract.rb +6 -0
  9. data/lib/rubik/buffer/manager.rb +37 -27
  10. data/lib/rubik/config/defaults.rb +7 -1
  11. data/lib/rubik/mcp/client.rb +4 -0
  12. data/lib/rubik/mcp/manager.rb +51 -35
  13. data/lib/rubik/mcp/proxy_tool.rb +17 -0
  14. data/lib/rubik/mcp/transport/stdio.rb +142 -17
  15. data/lib/rubik/orchestrator/budget_manager.rb +2 -0
  16. data/lib/rubik/orchestrator/loop.rb +1 -0
  17. data/lib/rubik/orchestrator/shell_deps.rb +4 -0
  18. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -0
  19. data/lib/rubik/plugin/manager.rb +39 -15
  20. data/lib/rubik/plugin/package_inventory.rb +11 -1
  21. data/lib/rubik/plugin/storage.rb +5 -4
  22. data/lib/rubik/prompt/doctrine.rb +10 -15
  23. data/lib/rubik/prompt/tool_teachings.rb +29 -35
  24. data/lib/rubik/repair/loop.rb +29 -15
  25. data/lib/rubik/repl/commands/agent_context.rb +1 -0
  26. data/lib/rubik/repl/commands/system.rb +2 -0
  27. data/lib/rubik/repl/turn_recorder.rb +3 -0
  28. data/lib/rubik/security/redactor.rb +13 -9
  29. data/lib/rubik/storage/vakanuvis/gate.rb +13 -8
  30. data/lib/rubik/storage/vakanuvis/harness.rb +115 -112
  31. data/lib/rubik/storage/vakanuvis/orientation.rb +277 -0
  32. data/lib/rubik/storage/vakanuvis/service.rb +5 -1
  33. data/lib/rubik/storage/vakanuvis/vocabulary.rb +33 -2
  34. data/lib/rubik/storage/vakanuvis.rb +8 -1
  35. data/lib/rubik/tools/builtins/lifecycle.rb +42 -47
  36. data/lib/rubik/tools/builtins/vakanuvis.rb +13 -15
  37. data/lib/rubik/ui/capability.rb +19 -5
  38. data/lib/rubik/version.rb +1 -1
  39. metadata +2 -1
@@ -6,6 +6,7 @@ require "time"
6
6
  # save_manifest atomik yazımı Platform.atomic_write kullanır; dosyanın tek başına
7
7
  # require edilmesi durumunda da çözümlenebilsin diye açık yüklenir.
8
8
  require_relative "../platform"
9
+ require_relative "../log"
9
10
  require_relative "package_inventory"
10
11
 
11
12
  module Rubik
@@ -31,7 +32,7 @@ module Rubik
31
32
  [project_plugins_dir, global_plugins_dir].each do |dir|
32
33
  FileUtils.mkdir_p(dir)
33
34
  rescue SystemCallError => e
34
- warn "[WARN] Failed to create plugin directory #{dir}: #{e.message}" if ENV["RUBIK_DEBUG"]
35
+ Rubik::Log.warn(:plugin, "Failed to create plugin directory #{dir}: #{e.class}: #{e.message}")
35
36
  end
36
37
  end
37
38
 
@@ -67,7 +68,7 @@ module Rubik
67
68
  save_manifest(dir, manifest)
68
69
  end
69
70
 
70
- def verify_integrity(path, scope: :project)
71
+ def verify_integrity(path, scope: :project, content: nil)
71
72
  return :trusted if ENV["RUBIK_TRUST_OVERLAYS"] == "1"
72
73
 
73
74
  dir = scope.to_sym == :global ? global_plugins_dir : project_plugins_dir
@@ -78,12 +79,12 @@ module Rubik
78
79
  basename = File.basename(path)
79
80
  manifest = load_manifest(dir)
80
81
 
81
- current_content = File.exist?(path) ? File.binread(path) : ""
82
+ current_content = content || (File.exist?(path) ? File.binread(path) : "")
82
83
  current_hash = PackageInventory.content_hash(current_content)
83
84
 
84
85
  if manifest.key?(basename)
85
86
  expected_hash = manifest[basename]["sha256"]
86
- if current_hash == expected_hash
87
+ if PackageInventory.secure_compare(current_hash, expected_hash)
87
88
  :verified
88
89
  else
89
90
  warn "[SECURITY] Integrity check failed for plugin '#{basename}': file modified since adding."
@@ -221,14 +221,15 @@ module Rubik
221
221
  def rule_nine_body(active_tools: nil, plan_mode: false)
222
222
  return "Keep the repository worktree free of agent workfiles, scratch notes, and temporary artifacts." if plan_mode
223
223
 
224
+ vakanuvis_on = tool_active?("vakanuvis", active_tools)
224
225
  storage_part = if tool_active?("storage", active_tools)
225
- Rubik::Prompt::ToolTeachings.teaching_for("storage")
226
+ Rubik::Prompt::ToolTeachings.teaching_for("storage", vakanuvis_active: vakanuvis_on)
226
227
  else
227
228
  "Keep the repository worktree free of scattered agent workfiles; read any existing " \
228
229
  "`rubik://storage/<type>/<id>` entities via `read_file` when a store is wired."
229
230
  end
230
231
  parts = [storage_part]
231
- parts << Rubik::Prompt::ToolTeachings.teaching_for("vakanuvis") if tool_active?("vakanuvis", active_tools)
232
+ parts << Rubik::Prompt::ToolTeachings.teaching_for("vakanuvis") if vakanuvis_on
232
233
  parts << Rubik::Prompt::ToolTeachings.teaching_for("show_file") if tool_active?("show_file", active_tools)
233
234
  parts << "Subagent transcripts are indexed automatically as `transcript` entities (alias `subagent-<storage-id>`) " \
234
235
  "and are readable live or persisted via the canonical `rubik://subagent/<storage-id>/transcript` reference — " \
@@ -244,14 +245,10 @@ module Rubik
244
245
  if storage_on && vakanuvis_on
245
246
  "Persist pipeline knowledge in the central storage (`storage` tool): the decomposed plan, review findings, " \
246
247
  "and the final goal record — link stages with entity ids (`links: {\"goal\": \"goal.N.<hash16>\"}`) so the " \
247
- "audit trail survives the session. Per-stage MEMORY notes go through the chronicler instead: " \
248
- "`vakanuvis(action: 'record', topic: '...', body: '...')` mints the `mem.` ref, upserts and supersedes — " \
249
- "never write memory with raw storage puts when `vakanuvis` is available " \
250
- "(fall back to `storage` `put` only when `vakanuvis` is unavailable); re-enter prior context with " \
251
- "`vakanuvis(action: 'recall', query: '...')` (also after compaction). Keep the repository worktree free of " \
252
- "agent workfiles. Transcripts of finished " \
253
- "subagents are indexed automatically (searchable via `storage` action 'search') and persist after the " \
254
- "subagent is reaped."
248
+ "audit trail survives the session. Route durable memory through the chronicler (`vakanuvis` record/recall; " \
249
+ "see rule 9), never through raw storage puts. Keep the repository worktree free of agent workfiles. " \
250
+ "Transcripts of finished subagents are indexed automatically (searchable via `storage` action 'search') " \
251
+ "and persist after the subagent is reaped."
255
252
  elsif storage_on
256
253
  "Persist pipeline knowledge in the central storage (`storage` tool): the decomposed plan, review findings, " \
257
254
  "and the final goal record — link stages with entity ids (`links: {\"goal\": \"goal.N.<hash16>\"}`) so the " \
@@ -259,11 +256,9 @@ module Rubik
259
256
  "finished subagents are indexed automatically (searchable via `storage` action 'search') and persist after " \
260
257
  "the subagent is reaped."
261
258
  elsif vakanuvis_on
262
- "Persist durable per-stage MEMORY notes through the chronicler: `vakanuvis(action: 'record', topic: '...', " \
263
- "body: '...')` mints the `mem.` ref, upserts and supersedes; re-enter prior context with " \
264
- "`vakanuvis(action: 'recall', query: '...')` (also after compaction). Keep the repository worktree free of " \
265
- "agent workfiles. Transcripts of finished subagents remain readable via `rubik://subagent/<storage-id>/transcript` " \
266
- "after the subagent is reaped."
259
+ "Route durable memory through the chronicler (`vakanuvis` record/recall; see rule 9). Keep the repository " \
260
+ "worktree free of agent workfiles. Transcripts of finished subagents remain readable via " \
261
+ "`rubik://subagent/<storage-id>/transcript` after the subagent is reaped."
267
262
  else
268
263
  "Keep the repository worktree free of agent workfiles. Transcripts of finished subagents remain readable via " \
269
264
  "`rubik://subagent/<storage-id>/transcript` after the subagent is reaped."
@@ -2,21 +2,23 @@
2
2
 
3
3
  module Rubik
4
4
  module Prompt
5
- # Tool teachings keyed by canonical tool name (architecture review
6
- # 2026-10-01, candidate 1): the sentences that teach HOW a specific tool
7
- # is used live here, one key per tool, instead of being embedded unnamed
8
- # in Doctrine.operational_rules_xml's heredoc. Identity (the tool) lives in
9
- # its Tools::Declaration; prose (the presentation) lives here — a prompt
10
- # layer concern. test_tool_teachings welds the two: every key must
11
- # resolve to a declared (Catalog) tool, so a rename that forgets the
12
- # prompt fails the suite.
13
- #
14
- # The strings are interpolated into the operational rules HEREDOC at the
15
- # same positions they originally occupied — the rendered rules are
16
- # byte-identical (pinned by the substring tests in
17
- # test_structured_context_builder, test_agent_command_contract,
18
- # test_buffer_apply_conflict, test_repl_tool_separation_and_guardrails).
5
+ # Tool teachings keyed by canonical tool name (architecture review 2026-10-01, candidate 1):
6
+ # the sentences that teach HOW a specific tool is used live here, one key per tool.
7
+ # Identity lives in its Tools::Declaration; prose lives here — a prompt layer concern.
8
+ # test_tool_teachings welds the two: every key must resolve to a declared Catalog tool.
19
9
  module ToolTeachings
10
+ STORAGE_BASE =
11
+ "Persist agent workfiles — goals, plans, reviews, and work notes — in the central " \
12
+ "`storage` database (`storage` tool: put/get/list/search/set_ref/delete), NOT as scattered files in " \
13
+ "the repository worktree. Read, list, and search all entity types (`goal`, `plan`, `memory`, `wiki`, " \
14
+ "`transcript`, `review`, `note`) via `storage` or `read_file(path: 'rubik://storage/<type>/<id>')` " \
15
+ "(browse with `rubik://storage/index` and `rubik://storage/<type>` listings)."
16
+
17
+ STORAGE_FALLBACK =
18
+ "#{STORAGE_BASE[0...-1]}; write durable `memory` cards and compiled `wiki` pages through direct " \
19
+ "`storage` `put` on `memory`/`wiki` (reserving direct `storage` `put` on `memory`/`wiki` for sessions " \
20
+ "where `vakanuvis` is unavailable).".freeze
21
+
20
22
  TEACHINGS = {
21
23
  "replace_file_content" =>
22
24
  "For modifying existing files, prefer `replace_file_content` with `path`, exact verbatim `target_content`, " \
@@ -35,15 +37,7 @@ module Rubik
35
37
  "params_schema: { type: 'object', properties: { ... }, required: [...] }, code: '->(args) { ... }')` " \
36
38
  "(always provide `params_schema` when the handler reads arguments from `args`; omit it or pass an empty " \
37
39
  "`{ type: 'object', properties: {} }` schema only for tools that take no arguments).",
38
- "storage" =>
39
- "Persist agent workfiles — goals, plans, reviews, and work notes — in the central " \
40
- "`storage` database (`storage` tool: put/get/list/search/set_ref/delete), NOT as scattered files in " \
41
- "the repository worktree. Read, list, and search all entity types (`goal`, `plan`, `memory`, `wiki`, " \
42
- "`transcript`, `review`, `note`) via `storage` or `read_file(path: 'rubik://storage/<type>/<id>')` " \
43
- "(browse with `rubik://storage/index` and `rubik://storage/<type>` listings); write durable `memory` " \
44
- "cards and compiled `wiki` pages through `vakanuvis` (`record` / `digest`) whenever `vakanuvis` is in " \
45
- "your toolbelt, reserving direct `storage` `put` on `memory`/`wiki` for sessions where `vakanuvis` is " \
46
- "unavailable.",
40
+ "storage" => STORAGE_BASE,
47
41
  "run_command" =>
48
42
  "GitHub facts must be verified, not assumed: after `gh pr merge` (which can succeed with EMPTY output) " \
49
43
  "confirm the merge with `gh pr view <number> --json state`; `mergeable: UNKNOWN` right after a push means " \
@@ -57,17 +51,13 @@ module Rubik
57
51
  "while your context stays lean; only fall back to read_file when YOU must reason about the content.",
58
52
  "vakanuvis" =>
59
53
  "The background chronicler keeps compiled knowledge (memory cards, wiki pages) in rubik://storage. " \
60
- "At task start — and again after any compaction — ask it with `vakanuvis(action: 'recall', query: '...')` " \
61
- "instead of pre-loading pages: it returns a deterministic digest plus rubik:// source links. When " \
62
- "dispatching a subagent, paste the relevant recall digest (or its rubik:// link) into the task brief. " \
63
- "At the end of a task, persist only the durable knowledge with `vakanuvis(action: 'record', topic: '...', " \
64
- "body: '...')` — memory writes go through record (ref minting, upsert, supersede come free), never " \
65
- "through raw storage puts when `vakanuvis` is available. Recall is keyword-AND over card/page text, so END " \
66
- "every recorded body and digest query with its own vocabulary: include a `Keywords:` line listing 5-10 " \
67
- "synonyms in BOTH working languages (e.g. TR + EN) — a Turkish query cannot otherwise find an English page. " \
68
- "If recall is too shallow, queue `vakanuvis(action: 'digest', query: '...')`: it returns a deterministic " \
69
- "receipt and compiles the wiki page asynchronously. Make the session close a standard digest point: review " \
70
- "the session's new cards, queue one digest per open topic, and let the home index refresh.",
54
+ "At task start, ask it with `vakanuvis(action: 'recall', query: '...')`: it returns a deterministic " \
55
+ "digest plus rubik:// source links. Recall is keyword-AND over card/page text: use 2-3 core terms. " \
56
+ "If the result is empty, retry once with other-language synonyms. When dispatching a subagent, paste " \
57
+ "the relevant recall digest (or its rubik:// link) into the task brief. At the end of a task, persist " \
58
+ "durable conclusions with `vakanuvis(action: 'record', topic: '...', body: '...')` — memory writes go " \
59
+ "through record (ref minting, upsert, supersede come free), never through raw storage puts. End the body " \
60
+ "with a `Keywords:` line (TR+EN) if you can; it is auto-extracted otherwise.",
71
61
  "prune_context" =>
72
62
  "When long sessions leave stale exploration and superseded tool output weighing on the ROOT window, " \
73
63
  "check `prune_context(action: 'status')` (window estimate vs ceiling, staged plan state) and force a " \
@@ -79,7 +69,11 @@ module Rubik
79
69
 
80
70
  module_function
81
71
 
82
- def teaching_for(tool_name) = TEACHINGS[tool_name.to_s]
72
+ def teaching_for(tool_name, vakanuvis_active: true)
73
+ return vakanuvis_active ? STORAGE_BASE : STORAGE_FALLBACK if tool_name.to_s == "storage"
74
+
75
+ TEACHINGS[tool_name.to_s]
76
+ end
83
77
  end
84
78
  end
85
79
  end
@@ -27,20 +27,34 @@ module Rubik
27
27
 
28
28
  @snapshot_store.create_snapshot(@buffer_manager)
29
29
 
30
- prompt = build_repair_prompt(current_diag, target_file, @attempts_made)
31
- res = @provider.complete([{ role: "user", content: prompt }])
32
- proposed_code = extract_code(res.text)
33
-
34
- @buffer_manager.write(target_file, proposed_code)
35
-
36
- # Verify syntax against in-memory buffer via tempfile
37
- check_res = check_buffer_syntax(target_file, lang)
38
-
39
- return { ok: true, attempts: @attempts_made, code: proposed_code } if check_res[:ok]
40
-
41
- current_diag = Rubik::Diagnostics::Parser.parse(check_res[:output], lang)
42
- @snapshot_store.undo(@buffer_manager)
43
-
30
+ begin
31
+ prompt = build_repair_prompt(current_diag, target_file, @attempts_made)
32
+ res = @provider.complete([{ role: "user", content: prompt }])
33
+ proposed_code = extract_code(res.text)
34
+
35
+ if proposed_code.empty?
36
+ @snapshot_store.undo(@buffer_manager)
37
+ next
38
+ end
39
+
40
+ @buffer_manager.write(target_file, proposed_code)
41
+
42
+ # Verify syntax against in-memory buffer via tempfile
43
+ check_res = check_buffer_syntax(target_file, lang)
44
+
45
+ return { ok: true, attempts: @attempts_made, code: proposed_code } if check_res[:ok]
46
+
47
+ if check_res[:unverified]
48
+ @snapshot_store.undo(@buffer_manager)
49
+ return { ok: false, attempts: @attempts_made, unverified: true, error: check_res[:error] }
50
+ end
51
+
52
+ current_diag = Rubik::Diagnostics::Parser.parse(check_res[:output], lang)
53
+ @snapshot_store.undo(@buffer_manager)
54
+ rescue StandardError => e
55
+ @snapshot_store.undo(@buffer_manager)
56
+ return { ok: false, attempts: @attempts_made, error: "#{e.class}: #{e.message}" }
57
+ end
44
58
  end
45
59
 
46
60
  { ok: false, attempts: @attempts_made, last_diagnostics: current_diag }
@@ -50,7 +64,7 @@ module Rubik
50
64
 
51
65
  def check_buffer_syntax(target_file, lang)
52
66
  adapter = @adapter_registry.get(lang.to_s)
53
- return { ok: true } unless adapter
67
+ return { ok: false, unverified: true, error: "No adapter registered for #{lang}" } unless adapter
54
68
 
55
69
  content = @buffer_manager.read(target_file)
56
70
  ext = File.extname(target_file)
@@ -97,6 +97,7 @@ module Rubik
97
97
  config: context_cfg,
98
98
  force: true
99
99
  )
100
+ Rubik::Storage::Vakanuvis::Orientation.inject_after_compaction!(ctx.conversation_history, ctx.runtime_manager, config: ctx.config)
100
101
  after_tokens = Rubik::Prompt::TokenEstimator.estimate_history(ctx.conversation_history)
101
102
  delta = before_tokens - after_tokens
102
103
  ctx.out.puts "Context compaction completed: #{before_tokens} -> #{after_tokens} tokens (reduced #{delta} tokens)."
@@ -57,11 +57,13 @@ module Rubik
57
57
 
58
58
  shell.register_command("clear", description: "Clear conversation history / dialogue turns") do |_args, ctx|
59
59
  ctx.conversation_history.clear
60
+ Rubik::Storage::Vakanuvis::Orientation.reset_session!(ctx.runtime_manager)
60
61
  ctx.out.puts "Conversation context cleared. Starting fresh topic."
61
62
  end
62
63
 
63
64
  shell.register_command("reset", description: "Reset conversation history and in-memory dialogue turns") do |_args, ctx|
64
65
  ctx.conversation_history.clear
66
+ Rubik::Storage::Vakanuvis::Orientation.reset_session!(ctx.runtime_manager)
65
67
  ctx.out.puts "Conversation context reset. Starting fresh topic."
66
68
  end
67
69
 
@@ -191,6 +191,8 @@ module Rubik
191
191
  def apply_staged_prune_plan!
192
192
  return if @replaying
193
193
 
194
+ Rubik::Storage::Vakanuvis::Orientation.inject_session_start!(@conversation_history, @runtime_manager, config: @config) if @conversation_history.empty?
195
+
194
196
  harness = Rubik::Coeffects.get(@runtime_manager&.context, Rubik::Coeffects::PRUNER)
195
197
  return unless harness
196
198
 
@@ -198,6 +200,7 @@ module Rubik
198
200
  return unless new_window
199
201
 
200
202
  @conversation_history = new_window
203
+ Rubik::Storage::Vakanuvis::Orientation.inject_after_compaction!(@conversation_history, @runtime_manager, config: @config)
201
204
  line = "[Pruner] Applied staged plan: dropped #{receipt[:dropped]} unit(s), " \
202
205
  "summarized #{receipt[:summarized]} (#{receipt[:skipped_stale]} stale op(s) skipped) — " \
203
206
  "window ~#{receipt[:before_tokens]} -> ~#{receipt[:after_tokens]} tokens."
@@ -38,16 +38,20 @@ module Rubik
38
38
  return text unless text.is_a?(String)
39
39
  return text if text.empty?
40
40
 
41
- # scrub: redact ham geçmişe (tool çıktısı, prompt) dokunur — geçersiz
42
- # UTF-8 gsub!'ı ArgumentError ile düşürür; total olmak maskenin
43
- # kendisi bir arıza kaynağı olmasın demektir.
44
- result = text.valid_encoding? ? text.dup : text.scrub
41
+ # PERF: Fast-path for valid encoding strings: test QUICK_CHECK_REGEX
42
+ # directly on input text before calling dup or scrub. This short-circuits
43
+ # clean strings without allocating extra intermediate string copies or
44
+ # running scrub/gsub passes (~2.7x speedup, ~50% fewer allocations).
45
+ if text.valid_encoding?
46
+ return text.dup unless text.match?(QUICK_CHECK_REGEX)
45
47
 
46
- # PERF: kısa devre — sır tetikleyicisi yoksa 11 gsub turu atlanır.
47
- # Tetik testi scrub'lanmış sonuç üstünde yapılır: match? ham geçersiz
48
- # UTF-8'de ArgumentError düşürür; scrub sonrası metin hep geçerlidir
49
- # ve desenler zaten bu metne uygulanıyordu, dolayısıyla eşdeğerdir.
50
- return result unless result.match?(QUICK_CHECK_REGEX)
48
+ result = text.dup
49
+ else
50
+ # Fall back safely for invalid UTF-8 / binary strings: scrub first so
51
+ # match? and gsub! do not raise ArgumentError.
52
+ result = text.scrub
53
+ return result unless result.match?(QUICK_CHECK_REGEX)
54
+ end
51
55
 
52
56
  # Keep replacement order: masking one credential can expose a word
53
57
  # boundary needed to recognize the next (for example, a bearer token).
@@ -54,8 +54,11 @@ module Rubik
54
54
  # hemen ardından doğum denemesi AYRI bir @lock penceresinde
55
55
  # (iki fazlı — tek büyük kritik bölüm İÇİNE ALMA). Doğum
56
56
  # başarısızsa iş kuyrukta kalır: yarım durum birikmez.
57
- # Dönen makbuz dizgesi bayt-bayt eski Harness#digest çıktısıdır.
58
- def submit(query)
57
+ # Dönen makbuz dizgesi bayt-bayt eski Harness#digest çıktısıdır;
58
+ # submit_with_status aynı kabulü [makbuz, durum] ikilisi olarak
59
+ # verir (:accepted|:duplicate|:full) — otomatik damıtım red'i
60
+ # makbuz dizgesinden ayırt edebilsin diye (review #346).
61
+ def submit_with_status(query, spawn: true)
59
62
  target = Vocabulary.wiki_ref(query)
60
63
  # v2 §4-V5: aynı hedefli iş zaten kuyrukta ya da uçuştaysa makbuz
61
64
  # 'already queued' varyantına düşer — çift doğum yok.
@@ -68,18 +71,20 @@ module Rubik
68
71
  :accepted
69
72
  end
70
73
  if admission == :duplicate
71
- return "Digest already queued for '#{target}' (#{AnnalWriter.one_line(query, 60)}): the chronicler is " \
72
- "compiling it — recall it later with vakanuvis(action: 'recall', query: '...')."
74
+ return ["Digest already queued for '#{target}' (#{AnnalWriter.one_line(query, 60)}): the chronicler is " \
75
+ "compiling it — recall it later with vakanuvis(action: 'recall', query: '...').", admission]
73
76
  end
74
77
  if admission == :full
75
- return "Digest not accepted for '#{target}': capacity #{@max_digest_jobs} is full. " \
76
- "Existing jobs are preserved; retry after a digest finishes."
78
+ return ["Digest not accepted for '#{target}': capacity #{@max_digest_jobs} is full. " \
79
+ "Existing jobs are preserved; retry after a digest finishes.", admission]
77
80
  end
78
81
 
79
- spawned = spawn_job!(batch: false)
80
- DistillGate.receipt(query: query, target: target, spawned: !spawned.nil?)
82
+ spawned = spawn ? spawn_job!(batch: false) : nil
83
+ [DistillGate.receipt(query: query, target: target, spawned: !spawned.nil?), admission]
81
84
  end
82
85
 
86
+ def submit(query, spawn: true) = submit_with_status(query, spawn: spawn)[0]
87
+
83
88
  # ---- Seam 2: worker thread'i (Harness#handle_subagent çağırır) ---
84
89
 
85
90
  # Ham subagent tamamlanma olayı. İş sicilden @lock altında