rubik 0.4.2 → 0.6.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +125 -1
  4. data/lib/rubik/ast/cache_store.rb +33 -0
  5. data/lib/rubik/ast/graph.rb +2 -1
  6. data/lib/rubik/benchmark/runner.rb +14 -0
  7. data/lib/rubik/benchmark/vakanuvis_adoption.rb +439 -0
  8. data/lib/rubik/config/defaults.rb +7 -1
  9. data/lib/rubik/log.rb +4 -3
  10. data/lib/rubik/orchestrator/budget_manager.rb +2 -0
  11. data/lib/rubik/orchestrator/loop.rb +1 -0
  12. data/lib/rubik/orchestrator/shell_deps.rb +4 -0
  13. data/lib/rubik/orchestrator/step_executor.rb +3 -3
  14. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +31 -11
  15. data/lib/rubik/orchestrator/turn_executor/user_channel.rb +2 -0
  16. data/lib/rubik/prompt/doctrine.rb +10 -15
  17. data/lib/rubik/prompt/emergency_compactor.rb +7 -3
  18. data/lib/rubik/prompt/tool_teachings.rb +29 -35
  19. data/lib/rubik/repl/commands/agent_context.rb +1 -0
  20. data/lib/rubik/repl/commands/agent_execution.rb +7 -0
  21. data/lib/rubik/repl/commands/system.rb +2 -0
  22. data/lib/rubik/repl/input_execution.rb +5 -1
  23. data/lib/rubik/repl/turn_recorder.rb +3 -0
  24. data/lib/rubik/security/redactor.rb +13 -9
  25. data/lib/rubik/storage/vakanuvis/annal_writer.rb +51 -4
  26. data/lib/rubik/storage/vakanuvis/distill_gate.rb +14 -2
  27. data/lib/rubik/storage/vakanuvis/gate.rb +23 -15
  28. data/lib/rubik/storage/vakanuvis/harness.rb +149 -121
  29. data/lib/rubik/storage/vakanuvis/job.rb +25 -0
  30. data/lib/rubik/storage/vakanuvis/orientation.rb +277 -0
  31. data/lib/rubik/storage/vakanuvis/service.rb +5 -1
  32. data/lib/rubik/storage/vakanuvis.rb +20 -1
  33. data/lib/rubik/tools/builtins/vakanuvis.rb +13 -15
  34. data/lib/rubik/tools/truncator.rb +53 -0
  35. data/lib/rubik/version.rb +1 -1
  36. metadata +4 -1
@@ -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."
@@ -52,9 +52,13 @@ module Rubik
52
52
  # "head_tail" (default, original behavior) keeps head + tail around the
53
53
  # omission notice; "head_only" keeps the message head — useful when the
54
54
  # actionable guidance at the tail must not crowd out the error header.
55
- # Truncator normalizes strings/symbols and falls back to head_tail on
56
- # unknown values.
57
- strategy = config["emergency_truncate_strategy"] || config[:emergency_truncate_strategy] || "head_tail"
55
+ # Truncator.resolve_strategy normalizes strings/symbols, falls back to
56
+ # head_tail on unknown values AND warns once — the same typo-honesty
57
+ # contract as the tools.truncation_strategy knob (issue #349).
58
+ strategy = Rubik::Tools::Truncator.resolve_strategy(
59
+ config["emergency_truncate_strategy"] || config[:emergency_truncate_strategy],
60
+ key: "context.emergency_truncate_strategy"
61
+ )
58
62
 
59
63
  history.each do |msg|
60
64
  next unless ConversationRows.row?(msg)
@@ -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
@@ -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)."
@@ -270,6 +270,13 @@ module Rubik
270
270
  private_class_method def self.suspend_goal_turn(ctx, orchestrator:, goal_state:, goal_ledger:,
271
271
  goal_text:, goal_id:, record_pair:, reason:, tokens: nil)
272
272
  attempt = suspended_attempt(ctx, goal_id) + 1
273
+ # Ledger kimliği askıdan ÖNCE okunur: suspend! → ledger.suspend!
274
+ # sırası metanın bu turun token'larını içermesi için zorunlu,
275
+ # ama o damga storage_id'yi değiştirdiği için sonradan atanan id
276
+ # SUSPEND_EVENT'e geç kalırdı — ilk askıda kronik satırı slug
277
+ # kimliğine düşer, ledger= bağlantısı kaybolurdu.
278
+ # resume_cross_process ile aynı uçuş-içi atama önceliği.
279
+ goal_state.ledger_id = goal_ledger.snapshot[:storage_id] if goal_ledger
273
280
  goal_state.suspend!(reason, tokens: tokens)
274
281
  goal_ledger&.suspend!(suspend_stamp_meta(goal_state, goal_id, reason, attempt))
275
282
  registry = goal_registry_for(ctx)
@@ -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
 
@@ -82,7 +82,11 @@ module Rubik
82
82
  clean_raw = Rubik::Security::Redactor.redact(parsed.raw)
83
83
  @recorder&.record_repl_command(clean_raw, output_text)
84
84
  # Plan 61 D-8: bound what a bang command leaves in LLM memory; the recorder keeps the full text.
85
- trunc = Rubik::Tools::Truncator.truncate(output_text, max_chars: @config.fetch("tools.max_output_chars", 12_000), strategy: :head_tail)
85
+ max_chars = @config.fetch("tools.max_output_chars", Rubik::Tools::Truncator::DEFAULT_MAX_CHARS)
86
+ # Deliberately context-scoped (issue #349 F8): truncation_strategy is a
87
+ # tool-output contract (the two orchestrator planes); bang-command echo
88
+ # is REPL history capture and stays head_tail, outside the knob's promise.
89
+ trunc = Rubik::Tools::Truncator.truncate(output_text, max_chars: max_chars, strategy: :head_tail)
86
90
  @conversation_history << { role: "user", content: clean_raw.delete_prefix("!") } # bangs stay out of LLM history; recorder keeps the literal
87
91
  @conversation_history << { role: "assistant", content: trunc[:truncated] ? trunc[:text] : output_text }
88
92
  trim_conversation_window!
@@ -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).
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Rubik
4
6
  module Storage
5
7
  module Vakanuvis
@@ -35,6 +37,44 @@ module Rubik
35
37
  [["goal.#{payload[:goal_ordinal]}.#{state}", "#{head} (event=goal.#{payload[:goal_ordinal]}.#{state})"]]
36
38
  end
37
39
 
40
+ # Askı satırı (issue #350): anahtar PEEK sırası DEĞİL hedef
41
+ # kimliğidir. Askı peek'lediği sıraya ait görünür; araya başka bir
42
+ # goal bitişi girerse peek yanlış kayda bağlanır, iki askı aynı
43
+ # peek'i paylaşınca dedup ikinci satırı yutar. Kimlik = ledger_id
44
+ # (goal.ledger açıkken post_lifecycle'nin damgası), yoksa goal
45
+ # metninin slug'ı; steps + hata özeti anahtara işlenir — gerçek
46
+ # yeniden teslim düşer, içerik değişen askı kalır. Satır goal=
47
+ # tek-satırını da taşır: sıra bağlantısı kalkınca metnin kendisi
48
+ # dürüst bağdır (goal kaydı askıda henüz mint edilmez).
49
+ def suspend_line(seq, payload)
50
+ ledger = payload[:ledger_id].to_s
51
+ identity = ledger.empty? ? Vocabulary.slug(payload[:goal]) : ledger
52
+ head = "- [#{seq}] goal status=suspended steps=#{payload[:steps]}/#{payload[:max_steps]}"
53
+ head += " error=#{one_line(payload[:last_error], 80)}" unless payload[:last_error].to_s.empty?
54
+ head += " ledger=#{one_line(ledger, 40)}" unless ledger.empty?
55
+ head += " goal=#{one_line(payload[:goal], 60)}"
56
+ error = payload[:last_error].to_s
57
+ err_part = error.empty? ? "ok" : Digest::SHA256.hexdigest(error)[0, 8]
58
+ key = "goal.suspend.#{identity}.#{payload[:steps]}.#{err_part}"
59
+ [[key, "#{head} (event=#{key})"]]
60
+ end
61
+
62
+ # Olay-kimliği eki (issue #351 F7): handle_event'in kurtarma
63
+ # günlüğü tek başına kind ile askı satırını bitiş satırından
64
+ # ayırt edemez — düşen kronik satırının kimliği status + goal
65
+ # tek-satırıdır (bitiş ayrıca maintain(goal_end: true)'i atlar).
66
+ # Üretilemezse boş dizgi; günlük satırı asla patlamaz.
67
+ def event_context(kind, payload)
68
+ return "" unless payload.is_a?(Hash)
69
+
70
+ case kind
71
+ when :goal then " status=#{payload[:status]} goal=#{one_line(payload[:goal], 40)}"
72
+ when :subagent then " id=#{payload[:storage_id] || payload[:id]} state=#{payload[:state]}"
73
+ when :turn then " kind=#{payload[:kind]} prompt=#{one_line(payload[:prompt], 40)}"
74
+ else ""
75
+ end
76
+ end
77
+
38
78
  # Kök-turn satırı (root-turn planı 2026-10-04): REPL turn'ünün
39
79
  # deterministik özeti. Boş alan (tools/tokens) satırda görünmez;
40
80
  # ordinal harness-lokal sayaçtan gelir, anahtar turn.<n>.<kind>.
@@ -64,13 +104,20 @@ module Rubik
64
104
  "goal: #{text}"
65
105
  end
66
106
 
67
- # Satır ekleme + tekilleştirme: body'de anahtarı olan satır bir
68
- # daha yazılmaz (olay yeniden teslimi — resume sonrası ikinci
69
- # tamamlanma gibi — yinelenen satır açmaz).
107
+ # Satır ekleme + tekilleştirme: body'de SATIR SONU (event=anahtar)
108
+ # taşıyan satır bir daha yazılmaz (olay yeniden teslimi — resume
109
+ # sonrası ikinci tamamlanma gibi — yinelenen satır açmaz). Bağ
110
+ # (end_with?) zorunludur: serbest metin alanları (goal=, error=,
111
+ # prompt= …) bir "(event=X)" dizgisi TAŞIYABİLİR; gövde-geniş
112
+ # include taraması o dizgiyi sahte anahtar sayıp gelecekteki
113
+ # gerçek X satırını sessizce yutardı (review #353: zehirlenmiş
114
+ # dedup). Anahtar daima satırı bitirdiğinden bağ tarama hem dürüst
115
+ # hem ucuzdur.
70
116
  def append(store, ref:, title:, tags: Vocabulary::ANNAL_TAGS, links: {}, lines: [])
71
117
  existing = store.get(type: "note", id: ref)
72
118
  body = existing ? existing["body"].to_s : ""
73
- fresh = lines.reject { |key, _line| body.include?("(event=#{key})") }
119
+ settled = body.lines.map(&:rstrip)
120
+ fresh = lines.reject { |key, _line| settled.any? { |l| l.end_with?("(event=#{key})") } }
74
121
  return existing if existing && fresh.empty?
75
122
 
76
123
  merged = ([body] + fresh.map { |_key, line| line }).reject { |l| l.to_s.strip.empty? }.join("\n")
@@ -25,11 +25,13 @@ module Rubik
25
25
  # v2 §4-V6: öneri sonundaki smoke-test sorgularının sayı sınırı.
26
26
  CHECK_LIMIT = 5
27
27
 
28
- def receipt(query:, target:, spawned:)
28
+ def receipt(query:, target:, spawned:, spawn_failed: false)
29
29
  head = "Digest queued (async): the chronicler will compile '#{AnnalWriter.one_line(query, 80)}' into '#{target}'."
30
30
  tail = if spawned
31
31
  "This call did NOT wait for the LLM — conclude your turn; recall it later with " \
32
32
  "vakanuvis(action: 'recall', query: '...') once compiled."
33
+ elsif spawn_failed
34
+ "The distiller spawn attempt failed (see dmesg) — the job stays queued and the spawn retries with the next digest or flush."
33
35
  else
34
36
  "No distiller run started yet (subagents.vakanuvis.distiller or the chronicler type may be " \
35
37
  "disabled) — the job stays queued and compiles when the distiller becomes available."
@@ -37,6 +39,16 @@ module Rubik
37
39
  "#{head} #{tail}"
38
40
  end
39
41
 
42
+ def duplicate_receipt(query, target)
43
+ "Digest already queued for '#{target}' (#{AnnalWriter.one_line(query, 60)}): the chronicler is " \
44
+ "compiling it — recall it later with vakanuvis(action: 'recall', query: '...')."
45
+ end
46
+
47
+ def full_receipt(target, capacity)
48
+ "Digest not accepted for '#{target}': capacity #{capacity} is full. " \
49
+ "Existing jobs are preserved; retry after a digest finishes."
50
+ end
51
+
40
52
  # Öneri şeması: ilk satır hedef ref (wiki.<slug>), gerisi gövde;
41
53
  # gövde sonunda isteğe bağlı `CHECK: <sorgu>` satırları (v2 §4-V6
42
54
  # smoke testi — en fazla CHECK_LIMIT adet, gövdeden AYIKLANIR,
@@ -128,7 +140,7 @@ module Rubik
128
140
  "read_file(path: '#{annal_uri}')"
129
141
  end
130
142
  Array(job[:transcript_uris]).each { |u| sources << "- Transcript: read_file(path: '#{u}')" }
131
- sources << "- Goal record: read_file(path: '#{store.uri_for('goal', job[:goal_id])}')" if job[:goal_id]
143
+ sources << "- Goal record: read_file(path: '#{store.uri_for('note', Vocabulary.goal_ref(job[:goal_ordinal]))}')" if job[:goal_ordinal]
132
144
  if (gaps = open_recall_gaps(store))
133
145
  sources << gaps
134
146
  end
@@ -54,8 +54,13 @@ 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
+ # goal_ordinal: goal-end auto-digest'in kanıt hedefi (tarama kartı 1,
62
+ # 2026-11-01) — işe işlenir, brief goal kaydını kaynak gösterir.
63
+ def submit_with_status(query, spawn: true, goal_ordinal: nil)
59
64
  target = Vocabulary.wiki_ref(query)
60
65
  # v2 §4-V5: aynı hedefli iş zaten kuyrukta ya da uçuştaysa makbuz
61
66
  # 'already queued' varyantına düşer — çift doğum yok.
@@ -63,23 +68,22 @@ module Rubik
63
68
  next :duplicate if @digest_targets.key?(target)
64
69
  next :full if @digest_targets.size >= @max_digest_jobs
65
70
 
66
- @pending_digests << { query: query, target: target, transcript_uris: source_transcript_uris }
71
+ @pending_digests << Job.new(query: query, target: target, transcript_uris: source_transcript_uris, goal_ordinal: goal_ordinal)
67
72
  @digest_targets[target] = true
68
73
  :accepted
69
74
  end
70
- 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: '...')."
73
- end
74
- 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."
77
- end
75
+ return [DistillGate.duplicate_receipt(query, target), admission] if admission == :duplicate
78
76
 
79
- spawned = spawn_job!(batch: false)
80
- DistillGate.receipt(query: query, target: target, spawned: !spawned.nil?)
77
+ return [DistillGate.full_receipt(target, @max_digest_jobs), admission] if admission == :full
78
+
79
+ spawned = spawn ? spawn_job!(batch: false) : nil
80
+ # REVIEW: patlak doğum "disabled" makbuzuyla yalanlanmasın (F2).
81
+ failed_spawn = spawn && spawned.nil? && spawn_allowed?
82
+ [DistillGate.receipt(query: query, target: target, spawned: !spawned.nil?, spawn_failed: failed_spawn), admission]
81
83
  end
82
84
 
85
+ def submit(query, spawn: true) = submit_with_status(query, spawn: spawn)[0]
86
+
83
87
  # ---- Seam 2: worker thread'i (Harness#handle_subagent çağırır) ---
84
88
 
85
89
  # Ham subagent tamamlanma olayı. İş sicilden @lock altında
@@ -145,7 +149,7 @@ module Rubik
145
149
  # Salt-okunuma peeki (test/kurcalama yüzeyi): kuyruğun başını
146
150
  # gösterir — tüketmez. Üretim görünürlüğü aşağıdaki job_targets'tır.
147
151
  def peek_job(batch: false)
148
- @lock.synchronize { @pending_digests.first || (batch ? { batch: true } : nil) }
152
+ @lock.synchronize { @pending_digests.first || (batch ? Job.new(batch: true) : nil) }
149
153
  end
150
154
 
151
155
  # İş defteri görünümü (issue #100 P2b): kuyruktaki + uçuştaki
@@ -214,11 +218,15 @@ module Rubik
214
218
  @supervisor.respond_to?(:spawn) &&
215
219
  @supervisor.respond_to?(:type_registry) && @supervisor.type_registry.get(TYPE_NAME)
216
220
  end
221
+ # Genel durum sorgusu (inceleme turu 2): Harness, kabul edilen
222
+ # digest topic'lerinin hiç derlenemeyeceğini dürüstçe
223
+ # bildirmek için doğum kapısını sorabilir olmalı.
224
+ public :spawn_allowed?
217
225
 
218
226
  # Aşağısı YALNIZ @lock altından çağrılır (çağıran: submit /
219
227
  # spawn_job! / completion_event) — kendi kilidi almaz.
220
228
  def claim_job(batch: false)
221
- @pending_digests.shift || (batch ? { batch: true, transcript_uris: source_transcript_uris } : nil)
229
+ @pending_digests.shift || (batch ? Job.new(batch: true, transcript_uris: source_transcript_uris) : nil)
222
230
  end
223
231
 
224
232
  def restore_job(job)