samagotchi 0.147.0 → 0.149.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +2 -2
  4. data/docs/architecture.md +1 -1
  5. data/docs/bundles.md +64 -17
  6. data/docs/cli.md +18 -0
  7. data/docs/configuration.md +7 -3
  8. data/docs/guardrails.md +27 -5
  9. data/docs/hooks.md +2 -1
  10. data/docs/internals/objectives.md +264 -0
  11. data/docs/internals/web-frontend.md +4 -1
  12. data/docs/plugins.md +4 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +44 -2
  15. data/docs/system-one.md +18 -2
  16. data/lib/samagotchi/answer_tail.rb +1 -1
  17. data/lib/samagotchi/bundles/coordinator/guardrails/merge.yml +21 -0
  18. data/lib/samagotchi/bundles/coordinator/manifest.yml +9 -5
  19. data/lib/samagotchi/bundles/coordinator/plugin.rb +5 -3
  20. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +44 -13
  21. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  22. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  23. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  24. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  25. data/lib/samagotchi/config.rb +12 -1
  26. data/lib/samagotchi/context_note.rb +17 -5
  27. data/lib/samagotchi/engine.rb +113 -6
  28. data/lib/samagotchi/engine_peers.rb +11 -0
  29. data/lib/samagotchi/guardrail_wiring.rb +11 -6
  30. data/lib/samagotchi/guardrails/approvals.rb +3 -1
  31. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  32. data/lib/samagotchi/guardrails/rules.rb +6 -1
  33. data/lib/samagotchi/guardrails/verdict.rb +21 -1
  34. data/lib/samagotchi/guardrails_command.rb +47 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  36. data/lib/samagotchi/note_command.rb +1 -1
  37. data/lib/samagotchi/note_delivery.rb +6 -2
  38. data/lib/samagotchi/objectives.rb +395 -0
  39. data/lib/samagotchi/objectives_audit.rb +115 -0
  40. data/lib/samagotchi/objectives_check.rb +335 -0
  41. data/lib/samagotchi/objectives_command.rb +192 -0
  42. data/lib/samagotchi/plugin/api.rb +2 -1
  43. data/lib/samagotchi/plugin/context.rb +21 -1
  44. data/lib/samagotchi/run_options.rb +1 -0
  45. data/lib/samagotchi/send_command.rb +2 -1
  46. data/lib/samagotchi/session_chain.rb +91 -8
  47. data/lib/samagotchi/session_commands.rb +16 -1
  48. data/lib/samagotchi/session_continue.rb +6 -2
  49. data/lib/samagotchi/session_inbox.rb +14 -6
  50. data/lib/samagotchi/session_manager.rb +1 -1
  51. data/lib/samagotchi/steer.rb +24 -4
  52. data/lib/samagotchi/system_one/record.rb +17 -3
  53. data/lib/samagotchi/testing/boundary.rb +1 -0
  54. data/lib/samagotchi/testing/host.rb +1 -0
  55. data/lib/samagotchi/tool_activity.rb +17 -0
  56. data/lib/samagotchi/tool_declarations.rb +34 -0
  57. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  58. data/lib/samagotchi/tools/builtins.rb +4 -1
  59. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  60. data/lib/samagotchi/tools/peers.rb +3 -0
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/message_display.rb +9 -0
  63. data/lib/samagotchi/web/participant_view.rb +1 -1
  64. data/lib/samagotchi/web/public/app.js +10 -0
  65. data/lib/samagotchi/web/public/cards_place.js +20 -4
  66. data/lib/samagotchi/web/public/commands.js +4 -1
  67. data/lib/samagotchi/web/public/index.html +2 -0
  68. data/lib/samagotchi/web/public/objectives.css +80 -0
  69. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  70. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  71. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  72. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  73. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  74. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  75. data/lib/samagotchi/web/public/turn_html.js +4 -1
  76. data/lib/samagotchi/web/session_show.rb +12 -1
  77. data/lib/samagotchi/worker.rb +3 -0
  78. data/lib/samagotchi/worker_inbound.rb +16 -8
  79. metadata +16 -1
@@ -51,6 +51,7 @@ require_relative "context_candidates"
51
51
  require_relative "context_candidates_log"
52
52
  require_relative "context_candidate_choice"
53
53
  require_relative "context_judge"
54
+ require_relative "objectives_audit"
54
55
  require_relative "owner_prompt"
55
56
  require_relative "side_judgments"
56
57
  require_relative "toolkit_suggest"
@@ -156,6 +157,8 @@ module Samagotchi
156
157
  # steers) and the idle layer's activity clock. Built first: a plugin
157
158
  # may steer or ask whether a turn runs while it loads.
158
159
  @turn_state = TurnState.new(clock: -> { monotonic_now })
160
+ @objective_proposals = []
161
+ @objective_proposals_mutex = Mutex.new
159
162
  # A message for the running turn cutting its generation.
160
163
  @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
161
164
  cut_after: -> { steer_cut_after })
@@ -767,6 +770,8 @@ module Samagotchi
767
770
  # command's ask_model between turns).
768
771
  # @return [String, nil]
769
772
  def side_author(kind)
773
+ # Check this is only ever the owner's, whichever turn runs as it ends.
774
+ return nil if kind.to_s == ObjectivesCheck::SOURCE
770
775
  return nil unless turn_running? || kind.to_s == RECAP_SIDE_KIND
771
776
 
772
777
  @guardrail_wiring.turn_author
@@ -956,7 +961,7 @@ module Samagotchi
956
961
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
957
962
  command_queued command_ran context_added card hook_notice guardrail_warning
958
963
  plugin_init_started plugin_init_finished working_set_updated participants_changed
959
- verdict_recorded].freeze
964
+ verdict_recorded objectives_updated].freeze
960
965
 
961
966
  # Put a transport-level event into the ordered event log. Unlike turn
962
967
  # events it reaches only persistent observers (no turn sink, no memory
@@ -987,6 +992,63 @@ module Samagotchi
987
992
  synchronize_events { emit_working_set(nil, session, session.messages) }
988
993
  end
989
994
 
995
+ # The session's objectives as the page draws them (Objectives.view), when
996
+ # that differs from the last announced: after an edit (its file written,
997
+ # pending), once a list note is in, at a turn's end (its moves) and after
998
+ # a rollback. A session with no list announces nothing. It only informs:
999
+ # a failure is logged, never the turn's.
1000
+ def announce_objectives(session = @session)
1001
+ return unless session
1002
+
1003
+ synchronize_events do
1004
+ view = Objectives.view(session.id, session.messages, state_dir: session_state_dir, running: live_conversation&.dup)
1005
+ next if view.nil? || view == @objectives_announced
1006
+
1007
+ @objectives_announced = view
1008
+ announce({ type: :objectives_updated, session_id: session.id, **view })
1009
+ end
1010
+ rescue StandardError => e
1011
+ Log.warn(:turn, "objectives_announce_failed", error: e.class.name, msg: e.message)
1012
+ end
1013
+
1014
+ # Check this (ObjectivesCheck)'s one side request. Its usage is in the
1015
+ # session's side line. Raises as ctx.ask_model's request does.
1016
+ # @param request [Array<Hash>] chat messages
1017
+ # @param model [String, nil] a configured model; nil: the session's
1018
+ # @return [String] the answer
1019
+ def ask_objective_check(request, model:)
1020
+ ask_side_model(request, timeout: ObjectivesCheck::TIMEOUT_SECONDS, max_tokens: ObjectivesCheck::MAX_TOKENS,
1021
+ cancel_controller: nil, model: model, source: ObjectivesCheck::SOURCE)
1022
+ end
1023
+
1024
+ # A change the model proposes (the objectives tool), kept until the turn
1025
+ # is over, as #context_choice's cards are: a card shown then is a quiet one, between turns, with its Apply kept
1026
+ # over a restart (CardStore), not a running turn's that says it waits on the
1027
+ # user. The same change twice in a turn is one card.
1028
+ # @return [Boolean] false past Objectives::PROPOSALS_PER_TURN
1029
+ def propose_objective(proposal)
1030
+ @objective_proposals_mutex.synchronize do
1031
+ next true if @objective_proposals.any? { |p| p.line == proposal.line }
1032
+ next false if @objective_proposals.size >= Objectives::PROPOSALS_PER_TURN
1033
+
1034
+ @objective_proposals << proposal
1035
+ true
1036
+ end
1037
+ end
1038
+
1039
+ def show_objective_proposals
1040
+ proposals = @objective_proposals_mutex.synchronize { @objective_proposals.slice!(0..) }
1041
+ proposals.each do |proposal|
1042
+ id = "objectives-#{SecureRandom.hex(4)}"
1043
+ show_card(source: Objectives::SOURCE, id: id, title: proposal.title, body: proposal.body,
1044
+ actions: [{ label: "Apply", command: proposal.action_line(id) },
1045
+ { label: "Dismiss", command: "/objectives --card #{id} dismiss" }])
1046
+ end
1047
+ rescue StandardError => e
1048
+ Log.warn(:turn, "objectives_proposal_failed", error: e.class.name, msg: e.message)
1049
+ end
1050
+ private :show_objective_proposals
1051
+
990
1052
  # The strategy the working set is built under now, as LLMContextView
991
1053
  # takes it: the running turn's, else the next turn's (#emit_working_set).
992
1054
  # /working-set's ops act under it.
@@ -1018,11 +1080,14 @@ module Samagotchi
1018
1080
  next nil if Array(session.messages).any? { |m| m[:note_id] == note[:note_id] }
1019
1081
 
1020
1082
  message = ContextNote.message(**note)
1083
+ entry = note[:objectives] && Objectives.entry(note[:objectives], before: Objectives.build(session.messages))
1021
1084
  replace_session_messages(session, Array(session.messages) + [message])
1022
1085
  announce({ type: :context_added, session_id: session.id, note_id: note[:note_id], source: note[:source],
1023
1086
  label: ContextNote.label_of(message), from_session: note[:from_session],
1024
1087
  from_cwd: note[:from_cwd], text: note[:text], context_source: note[:context_source],
1025
- created_at: note[:created_at] }.compact)
1088
+ created_at: note[:created_at], objectives: entry }.compact)
1089
+ announce_objectives(session) if entry
1090
+ objectives_audit.note(session, note)
1026
1091
  message
1027
1092
  end
1028
1093
  end
@@ -1677,6 +1742,7 @@ module Samagotchi
1677
1742
  event[:verdict] = verdict if verdict
1678
1743
  event[:revises] = revises.to_h if revises
1679
1744
  emit_working_set(turn.on_event, turn.session, turn.session.messages)
1745
+ announce_objectives(turn.session)
1680
1746
  turn.session.status = Session::STATUS_IDLE
1681
1747
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
1682
1748
  emit_event(turn.on_event, turn.tag(event))
@@ -2366,6 +2432,7 @@ module Samagotchi
2366
2432
  ensure
2367
2433
  release_turn(probe_cancel_before)
2368
2434
  show_context_cards_after_turn
2435
+ show_objective_proposals
2369
2436
  end
2370
2437
  end
2371
2438
 
@@ -2469,6 +2536,7 @@ module Samagotchi
2469
2536
  # The candidate judge: its asks run beside the turn; on mode's
2470
2537
  # attaches reach the model at the next between-turn delivery.
2471
2538
  start_context_judge(turn)
2539
+ audit_objectives_incoming(turn)
2472
2540
  apply_toolkit!(session, turn.on_event)
2473
2541
  refresh_participants!(session)
2474
2542
  # A Stop during the waits above: the turn ends here,
@@ -2740,14 +2808,18 @@ module Samagotchi
2740
2808
 
2741
2809
  # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
2742
2810
  # ends the turn in #run_turn without it), with a read-only
2743
- # copy of the conversation the turn stored, event[:present] for
2811
+ # copy of the conversation the turn stored, event[:answer] (the answer's
2812
+ # text: the native loop's stored message still holds its thought),
2813
+ # event[:present] for
2744
2814
  # a display version of the answer (AnswerDisplay) and event[:refs]
2745
2815
  # for refs on it (AnswerRefs).
2746
2816
  session = turn.session
2817
+ audit_objectives_outgoing(turn, result) unless canceled || result.exhausted?
2747
2818
  display = AnswerDisplay.new(session.messages)
2748
2819
  refs = AnswerRefs.new(session.messages, cwd: session.working_directory)
2749
2820
  after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
2750
- messages: hook_messages(session.messages) }
2821
+ messages: hook_messages(session.messages),
2822
+ answer: (result.text unless canceled || result.exhausted?) }
2751
2823
  after_turn[:present] = display.presenter(after_turn)
2752
2824
  after_turn[:refs] = refs.adder(after_turn)
2753
2825
  @hooks.fire(:after_turn, after_turn)
@@ -2855,7 +2927,8 @@ module Samagotchi
2855
2927
  # The running turn's conversation as its loop builds it (both loops
2856
2928
  # register it on the kernel's llm_context_turn): the same array the loop
2857
2929
  # appends to, not a copy, so read it on the turn's thread (the pending
2858
- # input drain, at an iteration boundary: TurnCheckpoint). nil when no
2930
+ # input drain, at an iteration boundary: TurnCheckpoint), or from another
2931
+ # only as a dup, read only (/objectives). nil when no
2859
2932
  # turn runs (llm_context_turn is never cleared, and still holds the
2860
2933
  # last turn's then).
2861
2934
  # @return [Array<Hash>, nil]
@@ -2885,7 +2958,10 @@ module Samagotchi
2885
2958
  raise ArgumentError, "no current session" unless @session
2886
2959
 
2887
2960
  messages = replace_session_messages(@session, clone_messages(checkpoint))
2888
- announce_working_set unless turn_running?
2961
+ unless turn_running?
2962
+ announce_working_set
2963
+ announce_objectives
2964
+ end
2889
2965
  messages
2890
2966
  end
2891
2967
 
@@ -3296,6 +3372,36 @@ module Samagotchi
3296
3372
  end
3297
3373
  private :start_context_judge
3298
3374
 
3375
+ # System 1 on the objectives, in audit (ObjectivesAudit): the owner's turns
3376
+ # only, and for a message a person's: a reminder's or a delegate's prompt isn't one.
3377
+ def audit_objectives_incoming(turn)
3378
+ return unless turn.prompt.is_a?(String) && turn.origin.human? && OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
3379
+
3380
+ objectives_audit.incoming(turn.session, turn.prompt)
3381
+ end
3382
+ private :audit_objectives_incoming
3383
+
3384
+ def audit_objectives_outgoing(turn, result)
3385
+ return unless OwnerPrompt.turn?(session: turn.session, author: turn.origin.author)
3386
+
3387
+ objectives_audit.outgoing(turn.session, result.text)
3388
+ end
3389
+ private :audit_objectives_outgoing
3390
+
3391
+ # With saving off nothing would come of an ask, so none is made.
3392
+ def objectives_audit
3393
+ @objectives_audit ||= ObjectivesAudit.new(
3394
+ side: @side_judgments, state_dir: -> { session_state_dir },
3395
+ client_for: lambda do |judgment, session|
3396
+ next nil unless system_one_runs.on?
3397
+
3398
+ system_one_recording(SystemOne::Client.from_config(host_registry: @host_registry, mode: ObjectivesAudit::MODE, tag: :system_one),
3399
+ judgment, session)
3400
+ end
3401
+ )
3402
+ end
3403
+ private :objectives_audit
3404
+
3299
3405
  # A late proposal no owner prompt mentions (Plugin::Host
3300
3406
  # #candidate_proposed: github-pr's init task runs beside the first
3301
3407
  # turn): judged on the owner's prompts so far, the running turn's too.
@@ -3446,6 +3552,7 @@ module Samagotchi
3446
3552
  cwd: -> { @session&.working_directory },
3447
3553
  messages: -> { plugin_messages },
3448
3554
  messages_partial: -> { turn_running? && @running_turn_messages.nil? },
3555
+ turn_running: -> { turn_running? },
3449
3556
  notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
3450
3557
  ask_user: ->(question) { hook_ask_user(question) },
3451
3558
  # An init task's is its own (a Ctrl-C ends a turn, not the task).
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "objectives"
4
+
3
5
  module Samagotchi
4
6
  # The views of an Engine its peers get: the kernel's tools
5
7
  # (Engine#initialize sets @kernel.peers) and a delegate's approval relay
@@ -28,6 +30,15 @@ module Samagotchi
28
30
  # The peer's name when +name+ is a source a peer bundle declared
29
31
  # (PeerSources), for context_read's header.
30
32
  def peer_label(name) = engine.peer_sources&.label_for(name)
33
+
34
+ # The session's objectives now (the objectives tool), nil outside one.
35
+ def objectives
36
+ session = engine.session
37
+ session && Objectives.current(session.id, session.messages, state_dir: engine.peer_state_dir)
38
+ end
39
+
40
+ # A proposed change: its card shows at the turn's end (Engine#propose_objective).
41
+ def propose_objective(proposal) = engine.propose_objective(proposal)
31
42
  end
32
43
 
33
44
  # What the approval relay needs from the parent's Engine (Peers#relay):
@@ -323,7 +323,7 @@ module Samagotchi
323
323
  # one that passes every gate is allowed once without reaching them
324
324
  # (docs/guardrails.md, "System 1").
325
325
  judgment = begin
326
- system_one_judge(payload, targets: verdict.targets)
326
+ system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?)
327
327
  rescue LLM::RequestCancelled
328
328
  return turn_stopped(verdict)
329
329
  end
@@ -366,8 +366,9 @@ module Samagotchi
366
366
  # auto-allowed: its asks go to the owner. No client (mode off, no
367
367
  # host) means no line at all. A judgment that fails is logged with p nil,
368
368
  # so the failures are in the log too.
369
+ # With +ask_user+ (a fresh ask, a second rule's) it is judged and logged, never answered.
369
370
  # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
370
- def system_one_judge(payload, targets: nil)
371
+ def system_one_judge(payload, targets: nil, ask_user: false)
371
372
  return nil if Steer.participant_author?(turn_author)
372
373
 
373
374
  client = @system_one.call
@@ -397,13 +398,13 @@ module Samagotchi
397
398
  end
398
399
  latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
399
400
  p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
400
- auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
401
+ auto = !ask_user && system_one_auto_allow?(client, approval, p, entries, targets: targets)
401
402
  decision = system_one_decision(client, auto)
402
403
 
403
404
  id = log.judgment(id: run_id, session_id: @session_lookup.call&.id,
404
405
  rule: approval.rule, source: approval.source, tool: approval.tool,
405
406
  **SystemOne::ApprovalQuestion.call_fields(approval),
406
- repo_name: approval.repo_name,
407
+ repo_name: approval.repo_name, repo: targets&.repo,
407
408
  p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
408
409
  latency_ms: latency_ms, decision: decision, origin: "live")
409
410
  # A judgment the log cannot hold is no judgment: the user is asked.
@@ -443,7 +444,8 @@ module Samagotchi
443
444
 
444
445
  # Every gate an auto-allow must pass, in order: the mode, the rule the
445
446
  # user opted in, the never-list and chi's own files, the probability, and
446
- # the rule's record. Any one that fails leaves the ask to the user. The
447
+ # the rule's record (this repo's for a rule that goes by repo; with no repo
448
+ # that is none). Any one that fails leaves the ask to the user. The
447
449
  # entries are the judgment log read once for this ask.
448
450
  # @param approval [Guardrails::ApprovalFacts] the ask's facts
449
451
  # @param entries [Array<Hash>] SystemOne::Log#entries
@@ -460,7 +462,10 @@ module Samagotchi
460
462
  return false if p.nil? || p.to_f < threshold
461
463
 
462
464
  min_record = Config.get("guardrails.system_one.min_record").to_i
463
- SystemOne::Record.new(entries, rule: rule).qualifies?(threshold: threshold, min_record: min_record)
465
+ per_repo = SystemOne::Record.per_repo?(rule)
466
+ return false if per_repo && targets&.repo.nil?
467
+
468
+ SystemOne::Record.new(entries, rule: rule, repo: (targets.repo if per_repo)).qualifies?(threshold: threshold, min_record: min_record)
464
469
  rescue StandardError => e
465
470
  Samagotchi::Log.warn(:guardrails, "system_one_gate_failed", error: "#{e.class}: #{e.message}")
466
471
  false
@@ -80,7 +80,9 @@ module Samagotchi
80
80
  case e["scope"]
81
81
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
82
82
  when "repo" then here.call(e) && e["key"] == key
83
- when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
83
+ when "rule"
84
+ # One rule's grant can't answer for a second rule's ask on the call.
85
+ !verdict.second_rule_ask? && verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
84
86
  end
85
87
  end
86
88
  end
@@ -34,7 +34,8 @@ module Samagotchi
34
34
  # - owner-voiced tools (OWNER_VOICED, and any delegate_* tool): what
35
35
  # speaks or acts in the owner's name beyond this turn (a delegate, a
36
36
  # reminder, a note to another session, a background task, a
37
- # forgotten output, the session's loaded tools); the tools that read
37
+ # forgotten output, the session's loaded tools, a move on the owner's
38
+ # objectives); the tools that read
38
39
  # the owner's background tasks (OWNER_TASK_READERS); and memory_write:
39
40
  # a memory reaches the owner's later sessions;
40
41
  # - ask_user_question: there is no one on the participant's side to
@@ -51,7 +52,7 @@ module Samagotchi
51
52
  RULE = "pair-boundary"
52
53
  SOURCE = "core"
53
54
  OWNER_VOICED = %w[delegate register_reminder cancel_reminder send_note forget_outputs task_create task_stop
54
- find_tools].freeze
55
+ find_tools objectives].freeze
55
56
  OWNER_VOICED_PREFIX = "delegate_"
56
57
  # The background task tools that read the owner's tasks (a pair turn
57
58
  # starts none: task_create is owner-voiced, and its execute is never
@@ -310,10 +310,13 @@ module Samagotchi
310
310
  @disable.reject { |entry| @rules.any? { |rule| disables?(entry, rule) } }
311
311
  end
312
312
 
313
- # A core check: every matching rule votes (strictest wins).
313
+ # A core check: every matching rule votes (strictest wins). A second
314
+ # rule id that asks takes rule grants out (Verdict#second_rule_asks!);
315
+ # asks from before the rules (the child boundary's) stay as they are.
314
316
  def check(verdict)
315
317
  return verdict unless @enabled
316
318
 
319
+ asking = nil
317
320
  @rules.each do |rule|
318
321
  next if disabled?(rule)
319
322
  next unless rule.for_mode?(@mode)
@@ -323,6 +326,8 @@ module Samagotchi
323
326
  verdict.deny!(rule.reason, rule: rule.id, source: rule.source, decided_by: "rule")
324
327
  else
325
328
  verdict.ask!(rule.reason, scopes: rule.scopes, rule: rule.id, source: rule.source, decided_by: "rule")
329
+ verdict.second_rule_asks!(rule.scopes) if asking && asking != rule.id
330
+ asking ||= rule.id
326
331
  end
327
332
  end
328
333
  verdict
@@ -8,7 +8,8 @@ module Samagotchi
8
8
  # first one stays, except that a protected rule's ask (PROTECTED_RULES)
9
9
  # takes over an unprotected ask: its rule id is what a parent's answer
10
10
  # and a stored approval are checked against, and the offered scopes
11
- # narrow to both asks' common ones.
11
+ # narrow to both asks' common ones. A second rule's ask takes rule grants
12
+ # out (#second_rule_asks!): the one id it keeps can't answer for both.
12
13
  class Verdict
13
14
  STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
14
15
  # Rules that ask about chi's own config and hooks: the core
@@ -47,6 +48,9 @@ module Samagotchi
47
48
 
48
49
  def fresh_ask? = @fresh_ask == true
49
50
 
51
+ # Whether a second distinct rule asks about this call (#second_rule_asks!).
52
+ def second_rule_ask? = @second_rule_ask == true
53
+
50
54
  def initialize(call:)
51
55
  @call = call
52
56
  @decision = :allow
@@ -84,6 +88,22 @@ module Samagotchi
84
88
  self
85
89
  end
86
90
 
91
+ # A second distinct rule asks about this call (Rules#check). The ask
92
+ # names one rule, and a rule grant or System 1's record is about that
93
+ # rule alone, so neither answers for the other's: no rule-scope entry
94
+ # matches it (Approvals.find_in), System 1 doesn't auto-allow it, and it
95
+ # offers what both rules allow, minus the rule. An exact-call entry
96
+ # (session, repo) still answers: it is keyed on the whole command.
97
+ # @param scopes [Array<String>, nil] the second rule's scopes (default all)
98
+ def second_rule_asks!(scopes = nil)
99
+ @second_rule_ask = true
100
+ allowed = Array(scopes).map(&:to_s) & SCOPES
101
+ allowed = SCOPES if allowed.empty?
102
+ left = (@scopes & allowed) - %w[rule]
103
+ @scopes = left.empty? ? %w[once] : left
104
+ self
105
+ end
106
+
87
107
  def self.protected_rule?(rule) = PROTECTED_RULES.include?(rule.to_s)
88
108
 
89
109
  # A before_tool_call hook set the old event[:blocked] flag. Its model
@@ -8,6 +8,7 @@ require_relative "cli/flags"
8
8
  require_relative "config"
9
9
  require_relative "bridge/card_store"
10
10
  require_relative "guardrails/approval"
11
+ require_relative "guardrail_wiring"
11
12
  require_relative "guardrails/approvals"
12
13
  require_relative "guardrails/parent_policy"
13
14
  require_relative "host_registry"
@@ -20,7 +21,10 @@ module Samagotchi
20
21
  # seed: System 1 judges every approval this machine's sessions already
21
22
  # answered, and the judgment and the user's answer go to the log as they
22
23
  # would live. A rule's record then starts with the asks the user really
23
- # decided on, instead of waiting for a few days of live ones.
24
+ # decided on, instead of waiting for a few days of live ones. `rule` says
25
+ # whether one rule votes `ask` on this machine now, read from the config and
26
+ # the installed bundles as a worker reads them (GuardrailWiring#rules), by
27
+ # its id: the text of a command never comes near it.
24
28
  class GuardrailsCommand
25
29
  include CLI::Command
26
30
 
@@ -41,16 +45,24 @@ module Samagotchi
41
45
  --dry-run judges and prints the table without writing a line. Needs
42
46
  guardrails.system_one.mode audit or on and a system_one.host
43
47
  (exit 2 without one).
48
+ rule <id>
49
+ Print exactly "asks" when the rule is loaded, not disabled and votes
50
+ ask in this guardrails.mode for every model; another line says why not
51
+ when it isn't (exit 0 either way). Reads config.yml and the installed
52
+ bundles, as a worker does: no session, no model, nothing written.
44
53
  TEXT
45
54
 
46
- SUBCOMMANDS = %w[replay].freeze
55
+ SUBCOMMANDS = %w[replay rule].freeze
47
56
  FLAGS = {
48
57
  "replay" => CLI::Flags.new(help: HELP_WORDS, args: false) do |f|
49
58
  f.switch "--dry-run"
50
59
  f.value "--threshold"
51
60
  f.value "--model"
52
- end
61
+ end,
62
+ "rule" => CLI::Flags.new(help: HELP_WORDS)
53
63
  }.freeze
64
+ # What `rule` prints when a rule asks. An agent compares the line to it.
65
+ ASKS = "asks"
54
66
  # The labels that mean the user let the call through.
55
67
  ALLOW_LABELS = %w[allow_once allow_wide].freeze
56
68
  # replay's table, in column order: the rule, how many asks it had, how
@@ -135,6 +147,8 @@ module Samagotchi
135
147
  parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
136
148
  return parsed if parsed.is_a?(Integer)
137
149
 
150
+ return rule(parsed.args) if @sub == "rule"
151
+
138
152
  replay(parsed.options)
139
153
  end
140
154
 
@@ -142,6 +156,36 @@ module Samagotchi
142
156
 
143
157
  def command_name = @sub ? "chi guardrails #{@sub}" : "chi guardrails"
144
158
 
159
+ def rule(args)
160
+ return usage_error("give one rule id") unless args.size == 1
161
+
162
+ @stdout.puts(rule_status(args.first))
163
+ CLI::Exit::OK
164
+ end
165
+
166
+ # The rule set as a worker would load it now, the rule's id looked up in
167
+ # it. Every other answer than ASKS names what stands in the way.
168
+ def rule_status(id)
169
+ wiring = GuardrailWiring.new(scratch: false, hooks: -> {}, tools: -> {}, session: -> {}, model_key: -> {},
170
+ cancelled: -> { false }, ask: ->(_fields) {})
171
+ rules = wiring.rules
172
+ failed = wiring.failures.required
173
+ return "rules failed to load: #{failed.map(&:what).join(", ")}" unless failed.empty?
174
+ return "guardrails are off (guardrails.enabled: false)" unless rules.enabled?
175
+
176
+ named = rules.rules.select { |r| r.id == id }
177
+ return "no rule #{id} is loaded" if named.empty?
178
+
179
+ enabled = named.reject { |r| rules.disabled?(r) }
180
+ return "rule #{id} is disabled (guardrails.disable)" if enabled.empty?
181
+
182
+ voting = enabled.select { |r| r.for_mode?(rules.mode) }
183
+ return "rule #{id} doesn't vote in guardrails.mode #{rules.mode}" if voting.empty?
184
+ return "rule #{id} denies" if voting.any? { |r| r.verdict == :deny }
185
+
186
+ voting.any? { |r| r.models.nil? } ? ASKS : "rule #{id} asks for some models only"
187
+ end
188
+
145
189
  def replay(options)
146
190
  threshold = threshold(options[:threshold])
147
191
  return usage_error(threshold) if threshold.is_a?(String)
@@ -259,7 +259,7 @@ module Samagotchi
259
259
  # Claude model's cache breakpoint there (PromptCache) and drops it
260
260
  # for every other request.
261
261
  def system_message(entry, index)
262
- text = entry[:content].to_s
262
+ text = Steer.wire_text(entry, entry[:content].to_s)
263
263
  split = index.zero? && @stable_length&.call(text)
264
264
  split ? { role: "system", content: text, cache_split: split } : { role: "system", content: text }
265
265
  end
@@ -123,7 +123,7 @@ module Samagotchi
123
123
 
124
124
  def outcome(result)
125
125
  case result.status
126
- when :refused then "refused: it is open in a chi REPL; notes need attached mode"
126
+ when :refused then "refused: #{NoteDelivery::REPL_REFUSAL}"
127
127
  when :queued then "queued: its worker adds it within a few seconds"
128
128
  else "waits for the session's next start (#{result.queued} #{result.queued == 1 ? "note" : "notes"} queued)"
129
129
  end
@@ -14,6 +14,9 @@ module Samagotchi
14
14
  # note in its notes/ folder, which a live worker adds within seconds and
15
15
  # a session with no worker at its next start. Callers word the outcome.
16
16
  module NoteDelivery
17
+ # Why a chi REPL's session takes no note.
18
+ REPL_REFUSAL = "it is open in a chi REPL; notes need attached mode"
19
+
17
20
  # The outcome for one session.
18
21
  # @!attribute status [Symbol] :queued (a worker runs it), :waits (no
19
22
  # worker: it waits for the next start) or :refused (a chi REPL owns it)
@@ -26,14 +29,15 @@ module Samagotchi
26
29
  module_function
27
30
 
28
31
  # @param id [String] a full session id (resolved by the caller)
32
+ # @param objectives [Hash, nil] an objectives note's payload
29
33
  # @return [Result]
30
34
  # @raise [SessionInbox::NoteRejected] an empty note or one over 16 KiB
31
- def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil)
35
+ def deliver(id, text:, source:, state_dir:, from_session: nil, from_cwd: nil, objectives: nil)
32
36
  owner = SessionManager.session_owner(id, state_dir: state_dir)
33
37
  return Result.new(id: id, status: :refused, queued: 0) if owner&.tui?
34
38
 
35
39
  path = SessionInbox.write_note(id, text: text, source: source, from_session: from_session, from_cwd: from_cwd,
36
- state_dir: state_dir)
40
+ objectives: objectives, state_dir: state_dir)
37
41
  return Result.new(id: id, status: :queued, queued: 0) if owner
38
42
 
39
43
  Result.new(id: id, status: :waits, queued: SessionInbox.find_new_note_files(File.dirname(path, 2)).size)