samagotchi 0.133.0 → 0.135.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +50 -34
  6. data/docs/configuration.md +9 -6
  7. data/docs/guardrails.md +146 -13
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/internals/engine.md +6 -3
  10. data/docs/internals/participants.md +7 -6
  11. data/docs/internals/tool-guardrails.md +2 -0
  12. data/docs/internals/web-frontend.md +262 -0
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +5 -2
  15. data/docs/testing.md +34 -4
  16. data/lib/samagotchi/bridge.rb +11 -6
  17. data/lib/samagotchi/bridge_client.rb +8 -5
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_home.rb +12 -2
  20. data/lib/samagotchi/config.rb +0 -2
  21. data/lib/samagotchi/desktop/macos.rb +19 -4
  22. data/lib/samagotchi/engine.rb +82 -12
  23. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  24. data/lib/samagotchi/guardrails/approval.rb +41 -5
  25. data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
  27. data/lib/samagotchi/guardrails/pair_command.rb +828 -86
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
  30. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
  32. data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
  33. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  34. data/lib/samagotchi/guardrails/targets.rb +8 -3
  35. data/lib/samagotchi/guardrails_command.rb +13 -10
  36. data/lib/samagotchi/home_command.rb +22 -6
  37. data/lib/samagotchi/home_doctor.rb +36 -3
  38. data/lib/samagotchi/home_notice.rb +8 -1
  39. data/lib/samagotchi/hooks/loader.rb +5 -0
  40. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  41. data/lib/samagotchi/log_line.rb +12 -0
  42. data/lib/samagotchi/message_parts.rb +1 -1
  43. data/lib/samagotchi/project_env.rb +3 -11
  44. data/lib/samagotchi/revise_anchor.rb +33 -0
  45. data/lib/samagotchi/self_report.rb +3 -1
  46. data/lib/samagotchi/session_inbox.rb +20 -12
  47. data/lib/samagotchi/session_manager.rb +13 -11
  48. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  49. data/lib/samagotchi/system_one/self_protect.rb +70 -16
  50. data/lib/samagotchi/testing/waiting.rb +13 -0
  51. data/lib/samagotchi/toolkit_binding.rb +16 -0
  52. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  53. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  54. data/lib/samagotchi/tools/execute.rb +6 -6
  55. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  56. data/lib/samagotchi/tools/read.rb +5 -5
  57. data/lib/samagotchi/turn_anchors.rb +49 -0
  58. data/lib/samagotchi/update_command.rb +7 -3
  59. data/lib/samagotchi/update_hint.rb +6 -1
  60. data/lib/samagotchi/verify_anchor.rb +5 -5
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -14
  63. data/lib/samagotchi/web/message_display.rb +17 -1
  64. data/lib/samagotchi/web/participant_access.rb +2 -2
  65. data/lib/samagotchi/web/participant_view.rb +8 -0
  66. data/lib/samagotchi/web/public/activity.js +1 -1
  67. data/lib/samagotchi/web/public/annotations.js +1 -1
  68. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  69. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  70. data/lib/samagotchi/web/public/app.js +462 -338
  71. data/lib/samagotchi/web/public/bubbles.js +3 -3
  72. data/lib/samagotchi/web/public/cards_place.js +9 -4
  73. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  74. data/lib/samagotchi/web/public/claim_link.js +4 -2
  75. data/lib/samagotchi/web/public/composer.css +11 -1
  76. data/lib/samagotchi/web/public/context_chips.js +9 -6
  77. data/lib/samagotchi/web/public/data.js +4 -2
  78. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  79. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  80. data/lib/samagotchi/web/public/history_reveal.js +27 -0
  81. data/lib/samagotchi/web/public/index.html +82 -37
  82. data/lib/samagotchi/web/public/latest.js +46 -0
  83. data/lib/samagotchi/web/public/lists_view.js +15 -13
  84. data/lib/samagotchi/web/public/live_timing.js +2 -0
  85. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  86. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  87. data/lib/samagotchi/web/public/method_api.js +14 -0
  88. data/lib/samagotchi/web/public/notifications.js +4 -11
  89. data/lib/samagotchi/web/public/participant.js +11 -6
  90. data/lib/samagotchi/web/public/participant_names.js +1 -1
  91. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  92. data/lib/samagotchi/web/public/project_picker.js +18 -22
  93. data/lib/samagotchi/web/public/question_cards.js +2 -2
  94. data/lib/samagotchi/web/public/refs.js +6 -7
  95. data/lib/samagotchi/web/public/restore.js +2 -0
  96. data/lib/samagotchi/web/public/review_start.js +1 -1
  97. data/lib/samagotchi/web/public/revise_stack.js +372 -0
  98. data/lib/samagotchi/web/public/scope.js +12 -20
  99. data/lib/samagotchi/web/public/send_turn.js +29 -16
  100. data/lib/samagotchi/web/public/session_actions.js +15 -14
  101. data/lib/samagotchi/web/public/session_fields.js +152 -0
  102. data/lib/samagotchi/web/public/session_payload.js +35 -0
  103. data/lib/samagotchi/web/public/session_state.js +2 -0
  104. data/lib/samagotchi/web/public/session_store.js +191 -0
  105. data/lib/samagotchi/web/public/stage_model.js +4 -4
  106. data/lib/samagotchi/web/public/stage_view.js +11 -16
  107. data/lib/samagotchi/web/public/start_page.js +4 -4
  108. data/lib/samagotchi/web/public/start_settings.js +29 -18
  109. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  110. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  111. data/lib/samagotchi/web/public/task_stop.js +1 -1
  112. data/lib/samagotchi/web/public/timing.js +7 -3
  113. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
  114. data/lib/samagotchi/web/public/turn_events.js +2 -2
  115. data/lib/samagotchi/web/public/turn_html.js +17 -9
  116. data/lib/samagotchi/web/public/turn_model.js +6 -6
  117. data/lib/samagotchi/web/public/turn_range.js +32 -0
  118. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  119. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  120. data/lib/samagotchi/web/public/verify_target.js +7 -10
  121. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  122. data/lib/samagotchi/web/server.rb +4 -4
  123. data/lib/samagotchi/web/session_control.rb +2 -2
  124. data/lib/samagotchi/worker.rb +9 -9
  125. data/lib/samagotchi/worker_inbound.rb +6 -5
  126. metadata +16 -3
  127. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -42,7 +42,7 @@ module Samagotchi
42
42
  MEMORY_REMOVE_RULE = "memory-remove"
43
43
  # The guardrails bundle's shell-touches-chi, for a word it can't
44
44
  # resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
45
- CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
45
+ CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/(?:modules/\S+/)?hooks})
46
46
 
47
47
  module_function
48
48
 
@@ -63,13 +63,15 @@ module Samagotchi
63
63
 
64
64
  # #protected? on an approval's facts (Approval.payload's approval:),
65
65
  # at ask time.
66
+ # @param facts [ApprovalFacts, Hash, nil] as built, or as saved
66
67
  def protected_facts?(facts)
67
- return false unless facts.is_a?(Hash)
68
- return true if PROTECTED_RULES.include?(fetch(facts, :rule).to_s)
69
- return true if fetch(facts, :source).to_s == ProtectedPaths::SOURCE
68
+ facts = ApprovalFacts.from_h(facts)
69
+ return false if facts.nil?
70
+ return true if PROTECTED_RULES.include?(facts.rule.to_s)
71
+ return true if facts.source.to_s == ProtectedPaths::SOURCE
70
72
  # A memory's file sits in the config dir, but removing one is the
71
73
  # memory-remove rule's ask, which a parent may answer.
72
- return false if fetch(facts, :rule).to_s == MEMORY_REMOVE_RULE
74
+ return false if facts.rule.to_s == MEMORY_REMOVE_RULE
73
75
 
74
76
  chi_files?(facts)
75
77
  end
@@ -77,15 +79,17 @@ module Samagotchi
77
79
  # Whether an approval's paths or command name chi's own dirs (#chi_dirs)
78
80
  # or CHI_TEXT, whatever its rule: the part of #protected_facts? that
79
81
  # System 1's SystemOne::SelfProtect shares.
82
+ # @param facts [ApprovalFacts, Hash, nil] as built, or as saved
80
83
  def chi_files?(facts)
81
- return false unless facts.is_a?(Hash)
84
+ facts = ApprovalFacts.from_h(facts)
85
+ return false if facts.nil?
82
86
 
83
87
  dirs = chi_dirs
84
- paths = Array(fetch(facts, :paths)).map(&:to_s).reject(&:empty?)
88
+ paths = Array(facts.paths).map(&:to_s).reject(&:empty?)
85
89
  reals = paths.flat_map { |path| PathResolve.reals(path) }
86
90
  return true if reals.any? { |real| dirs.any? { |dir| PathResolve.within?(real, dir) } }
87
91
 
88
- command = fetch(facts, :command).to_s
92
+ command = facts.command.to_s
89
93
  return false if command.empty?
90
94
 
91
95
  command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(PathResolve.real(dir)) }
@@ -103,8 +107,7 @@ module Samagotchi
103
107
  # The offered scopes, in option order, or nil when they are missing,
104
108
  # empty, not strings, or leave no option after them for Deny.
105
109
  def scopes(pending)
106
- facts = pending.approval
107
- scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
110
+ scopes = ApprovalFacts.from_h(pending.approval)&.scopes
108
111
  return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
109
112
  return nil unless scopes.size < options(pending).size
110
113
 
@@ -112,12 +115,6 @@ module Samagotchi
112
115
  end
113
116
 
114
117
  def options(pending) = Array(pending.options)
115
-
116
- def fetch(hash, key)
117
- return nil unless hash.is_a?(Hash)
118
-
119
- hash.key?(key) ? hash[key] : hash[key.to_s]
120
- end
121
118
  end
122
119
  end
123
120
  end
@@ -17,7 +17,9 @@ module Samagotchi
17
17
  # sh -c, eval, timeout, …), a background &, a $ expansion other than
18
18
  # $HOME / the XDG dirs / $TMPDIR / $PWD at a word's start, a brace
19
19
  # expansion, a < or << redirection, a path to a verb (/bin/ls), and
20
- # less/more/vim (they run commands) are all not read-only.
20
+ # less/more/vim (they run commands) are all not read-only. test and
21
+ # [ … ] read only as one file or string check (.test_expr): `[[` and
22
+ # anything else they can do are not.
21
23
  #
22
24
  # .read_only_but_writes? (the child boundary's) also lets through the
23
25
  # writes it can name: a > or >> target, cp's destination and tee's
@@ -98,12 +100,44 @@ module Samagotchi
98
100
  text = command.to_s
99
101
  return false if text.strip.empty?
100
102
 
101
- commands = split(ShellLex.lex(text)) or return false
102
- !commands.empty? && commands.all? { |words| simple_read_only?(words) }
103
+ tokens_read_only?(ShellLex.lex(text))
103
104
  rescue ArgumentError, EncodingError
104
105
  false
105
106
  end
106
107
 
108
+ def tokens_read_only?(tokens)
109
+ commands = split(tokens) or return false
110
+ !commands.empty? && commands.all? { |words| simple_read_only?(words) }
111
+ end
112
+
113
+ # Operators that end a part of a list (.read_only_parts) outside ( … ).
114
+ PART_ENDS = ["&&", "||", ";", "\n"].freeze
115
+
116
+ # Per simple command of +tokens+ (ShellLex.lex's; the word lists of
117
+ # ShellLex.simple_commands, in order), whether the part of the list it
118
+ # is in only reads: the list split at ;, &&, || and newlines outside
119
+ # ( … ). A pipe or a group keeps its commands in one part, so
120
+ # `find . -name x | xargs rm` reads as one part that writes.
121
+ # @return [Array<Boolean>]
122
+ def read_only_parts(tokens)
123
+ parts = [[]]
124
+ depth = 0
125
+ tokens.each do |token|
126
+ kind, value = token
127
+ depth += 1 if kind == :op && value == "("
128
+ depth -= 1 if kind == :op && value == ")" && depth.positive?
129
+ next parts << [] if kind == :op && depth.zero? && PART_ENDS.include?(value)
130
+
131
+ parts.last << token
132
+ end
133
+ parts.flat_map do |part|
134
+ read_only = tokens_read_only?(part)
135
+ ShellLex.simple_commands(part).grep(Array).map { read_only }
136
+ end
137
+ rescue ArgumentError, EncodingError
138
+ ShellLex.simple_commands(tokens).grep(Array).map { false }
139
+ end
140
+
107
141
  # Whether the command only reads but for writes whose resolved paths
108
142
  # the block accepts: > and >> targets, cp's destination (and the
109
143
  # files it makes in it) and tee's files. Paths resolve against +cwd+
@@ -199,6 +233,7 @@ module Samagotchi
199
233
 
200
234
  writes&.walk&.step(args)
201
235
  verb = args.shift
236
+ return args.last == "]" && test_expr(args[0...-1]) if verb == "["
202
237
  return false unless verb&.match?(/\A[a-z][\w.-]*\z/)
203
238
  return true if PLAIN.include?(verb)
204
239
  return WRITE_CHECKS[verb].call(args, writes) if writes && WRITE_CHECKS[verb]
@@ -244,6 +279,25 @@ module Samagotchi
244
279
 
245
280
  def rg(args) = args.none? { |a| a.start_with?("--pre") }
246
281
 
282
+ # test's checks that only look: a file's type, mode or size, a string
283
+ # being empty or not.
284
+ TEST_UNARY = %w[-e -f -d -s -r -w -x -L -h -b -c -p -S -g -u -k -O -G -n -z].freeze
285
+ # Comparisons of two strings or integers.
286
+ TEST_BINARY = %w[= == != -eq -ne -lt -le -gt -ge].freeze
287
+
288
+ # test's (and [ … ]'s, without the ]) expression when it is one check:
289
+ # an optional !, then a unary TEST_UNARY with its operand or a
290
+ # TEST_BINARY between two. Any other shape (-a/-o, ( ), -v, one bare
291
+ # word, a missing operand) is not read-only.
292
+ def test_expr(args)
293
+ args = args.drop(1) if args.first == "!"
294
+ case args.size
295
+ when 2 then TEST_UNARY.include?(args[0])
296
+ when 3 then TEST_BINARY.include?(args[1])
297
+ else false
298
+ end
299
+ end
300
+
247
301
  FIND_ACTIONS = %w[-delete -exec -execdir -ok -okdir -fprint -fprint0 -fprintf -fls].freeze
248
302
 
249
303
  def find(args) = args.none? { |a| FIND_ACTIONS.any? { |action| a.start_with?(action) } }
@@ -458,7 +512,7 @@ module Samagotchi
458
512
  CHECKS = {
459
513
  "rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
460
514
  "sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
461
- "git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh)
515
+ "git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh), "test" => method(:test_expr)
462
516
  }.freeze
463
517
 
464
518
  # A sed script parsed far enough to tell that it only reads: its
@@ -52,7 +52,7 @@ module Samagotchi
52
52
  def git_call(words)
53
53
  words = words.drop_while { |w| w.match?(/\A[A-Za-z_]\w*=/) || ShellGitDirs::PREFIXES.include?(w) }
54
54
  verb = words.first
55
- return nil unless verb == "git" || verb.to_s.end_with?("/git")
55
+ return nil unless ShellLex.program?(verb, "git")
56
56
 
57
57
  args = words.drop(1).grep_v(ShellLex::REDIRECT)
58
58
  until args.empty?
@@ -28,7 +28,12 @@ module Samagotchi
28
28
  STASH_READS = %w[list show].freeze
29
29
  PREFIXES = %w[command builtin exec time nohup env ! { if then else elif do while until].freeze
30
30
  # git global options that take the next word as their value.
31
- VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace].freeze
31
+ VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace --attr-source --shallow-file].freeze
32
+ # The variables that hand git config of their own (another global or
33
+ # system file, settings as -c gives them) or a template (an init's
34
+ # hooks): what they set may run a command (Targets::SHELL_REDEFINING_ENV,
35
+ # PairCommand's refusal). GIT_CONFIG_NOSYSTEM only turns one off.
36
+ CONFIG_ENV = /\A(?:GIT_CONFIG(?:_GLOBAL|_SYSTEM|_PARAMETERS|_COUNT|_KEY_\d+|_VALUE_\d+)?|GIT_TEMPLATE_DIR)\z/
32
37
 
33
38
  # @param command [String] the shell command
34
39
  # @param cwd [String] where it starts (the call's cwd:, else the session's)
@@ -92,7 +97,7 @@ module Samagotchi
92
97
  @pushed << @dirs
93
98
  @dirs = args.first ? dirs_after(args.first) : [nil]
94
99
  when "popd" then @dirs = @pushed.pop unless @pushed.empty?
95
- else git(args, env) if verb == "git" || verb.end_with?("/git")
100
+ else git(args, env) if ShellLex.program?(verb, "git")
96
101
  end
97
102
  end
98
103
 
@@ -39,6 +39,28 @@ module Samagotchi
39
39
  Lexer.new(text).tokens
40
40
  end
41
41
 
42
+ # A program word's name as the guardrails compare it: its basename,
43
+ # case-folded (GIT, Git, /usr/bin/SH, ſh → git, sh), since a
44
+ # case-insensitive file system (macOS's default) runs `GIT` as git.
45
+ # Every check that tells a program by its name goes through this
46
+ # (PairCommand, ImplicitWriters, SelfProtect, ShellGitDirs, SharedGit).
47
+ # A word with a NUL (a SUBST) raises, as File.basename does.
48
+ def self.program_name(word)
49
+ name = File.basename(word.to_s)
50
+ name.valid_encoding? ? name.downcase(:fold) : name
51
+ end
52
+
53
+ # Whether +word+ runs the program +name+ (.program_name). A word with a
54
+ # NUL (a SUBST: "$(dirname $(which git))/git") by what follows its last
55
+ # `/`, folded the same way: no `/`, no name.
56
+ def self.program?(word, name)
57
+ word = word.to_s
58
+ return program_name(word) == name unless word.include?("\0")
59
+
60
+ last = word[%r{/([^/]*)\z}, 1] or return false
61
+ (last.valid_encoding? ? last.downcase(:fold) : last) == name
62
+ end
63
+
42
64
  # Whether +text+ ends inside a quote, a $(…) or a heredoc: its tokens
43
65
  # are a best guess.
44
66
  def self.unterminated?(text)
@@ -63,6 +85,29 @@ module Samagotchi
63
85
  commands.reject { |c| c.is_a?(Array) && c.empty? }
64
86
  end
65
87
 
88
+ # For each word list .simple_commands gives (its :open / :close
89
+ # markers left out), whether it reads a pipe: a | or |& comes before
90
+ # it, a ( between them included (`x | (sh)`).
91
+ # @return [Array<Boolean>]
92
+ def self.piped_flags(tokens)
93
+ flags = []
94
+ words = 0
95
+ pending = false
96
+ piped = false
97
+ tokens.each do |kind, value|
98
+ if kind == :op
99
+ flags << piped if words.positive?
100
+ words = 0
101
+ pending = %w[| |&].include?(value) || (value == "(" && pending)
102
+ else
103
+ piped = pending if words.zero?
104
+ words += 1
105
+ end
106
+ end
107
+ flags << piped if words.positive?
108
+ flags
109
+ end
110
+
66
111
  # Whether an ( or { group is still open at token +at+: a pipe after
67
112
  # the group's ) or } takes what the group prints. The counters never go
68
113
  # below 0 (a case pattern's "x)" closes nothing); ((…)) arithmetic and
@@ -11,7 +11,8 @@ module Samagotchi
11
11
  # command is in by then (cd, pushd/popd and ( … ) followed as in
12
12
  # ShellGitDirs), with ~, $HOME and the XDG dirs expanded and symlinks
13
13
  # resolved. A word names a dir when it is in it. Any path with a
14
- # .git/hooks part counts too, outside the tmp dirs.
14
+ # .git/hooks part (a submodule's .git/modules/<name>/hooks too) counts
15
+ # too, outside the tmp dirs.
15
16
  #
16
17
  # A word it can't resolve (another $VAR, $(…), a glob, a relative path
17
18
  # after cd $X) or one that reads as a script (sh -c '…', ruby -e '…':
@@ -23,7 +24,7 @@ module Samagotchi
23
24
  # A word that is a script rather than a path.
24
25
  SCRIPT = /[\s;&|<>()'"`]/
25
26
  GLOB = /[*?\[\]{}]/
26
- HOOKS_PART = %r{(?:\A|/)\.git/hooks(?:/|\z)}
27
+ HOOKS_PART = %r{(?:\A|/)\.git/(?:modules/.+/)?hooks(?:/|\z)}
27
28
  # $HOME, ${HOME} and the XDG dirs at a word's start (PathResolve's).
28
29
  EXPANSION = PathResolve::EXPANSION
29
30
  # Where XDG dirs default when unset.
@@ -46,7 +46,8 @@ module Samagotchi
46
46
  # goes, under every shell chi may use or in the programs it runs: a
47
47
  # startup file (BASH_ENV, ENV), an exported function (BASH_FUNC_…), a
48
48
  # cd search path, word splitting, shell options, a library loaded into
49
- # every program, git config or a diff program that runs. A call whose
49
+ # every program, git config or a diff program that runs (and the rest
50
+ # of git's config variables: ShellGitDirs::CONFIG_ENV). A call whose
50
51
  # env: sets one is never read-only (.redefines_shell?). PATH is not
51
52
  # one (the tool's own description suggests it); nor are
52
53
  # PROMPT_COMMAND (interactive shells only) and ZDOTDIR (zsh in sh
@@ -250,9 +251,13 @@ module Samagotchi
250
251
  {}
251
252
  end
252
253
 
253
- # Whether +env+ sets a SHELL_REDEFINING_ENV variable.
254
+ # Whether +env+ sets a SHELL_REDEFINING_ENV variable (or a git config
255
+ # one: ShellGitDirs::CONFIG_ENV).
254
256
  def self.redefines_shell?(env)
255
- env.each_key.any? { |key| SHELL_REDEFINING_ENV.include?(key) || key.start_with?(SHELL_REDEFINING_PREFIX) }
257
+ env.each_key.any? do |key|
258
+ SHELL_REDEFINING_ENV.include?(key) || key.start_with?(SHELL_REDEFINING_PREFIX) ||
259
+ key.match?(ShellGitDirs::CONFIG_ENV)
260
+ end
256
261
  end
257
262
 
258
263
  # +path+ (a file tool's, as Tools::ToolPath has it) absolute against
@@ -7,6 +7,7 @@ require_relative "cli/exit"
7
7
  require_relative "cli/flags"
8
8
  require_relative "config"
9
9
  require_relative "bridge/card_store"
10
+ require_relative "guardrails/approval"
10
11
  require_relative "guardrails/approvals"
11
12
  require_relative "guardrails/parent_policy"
12
13
  require_relative "host_registry"
@@ -71,7 +72,7 @@ module Samagotchi
71
72
  }.freeze
72
73
 
73
74
  # One ask a replay judges: the card's question id, the session it came
74
- # from, its approval as the card saved it, the label its answer gives,
75
+ # from, its approval as the card saved it (Guardrails::ApprovalFacts), the label its answer gives,
75
76
  # and when the user was asked.
76
77
  Ask = Data.define(:question_id, :session_id, :approval, :label, :asked_at)
77
78
 
@@ -208,17 +209,19 @@ module Samagotchi
208
209
 
209
210
  pending = entry[:pending_question]
210
211
  return :no_approval unless pending.is_a?(Hash) && pending[:approval].is_a?(Hash)
212
+
211
213
  # A participant's turn's ask is never System 1's to judge, live or here
212
214
  # (GuardrailWiring#system_one_judge): no record of it is the user's
213
215
  # rule-level decision (it offered once only).
214
- return :participant if pending[:approval][:author]
216
+ approval = Guardrails::ApprovalFacts.from_h(pending[:approval])
217
+ return :participant if approval.author
215
218
 
216
219
  question_id = pending[:id].to_s
217
220
  return :no_question_id if question_id.empty?
218
221
 
219
222
  origin = judged.question_ids[question_id]
220
223
  return origin == "replay" ? :already_replayed : :judged_live if origin
221
- return :judged_live if judged.take_live(session_id, pending[:approval])
224
+ return :judged_live if judged.take_live(session_id, approval)
222
225
 
223
226
  answer = entry[:answer]
224
227
  label = if entry[:cancelled]
@@ -230,15 +233,15 @@ module Samagotchi
230
233
  # label only where the parent could have allowed: a parent
231
234
  # that was not allowed to permit says nothing about the user
232
235
  # (docs/guardrails.md, "System 1").
233
- return :answered_by_parent unless label_for(answer, pending[:approval][:scopes]) == "deny"
236
+ return :answered_by_parent unless label_for(answer, approval.scopes) == "deny"
234
237
  return :parent_cannot_allow unless parent_may_permit?(pending, session_id, sessions_dir)
235
238
 
236
239
  "deny"
237
240
  else
238
- label_for(answer, pending[:approval][:scopes])
241
+ label_for(answer, approval.scopes)
239
242
  end
240
243
 
241
- Ask.new(question_id: question_id, session_id: session_id, approval: pending[:approval],
244
+ Ask.new(question_id: question_id, session_id: session_id, approval: approval,
242
245
  label: label, asked_at: pending[:created_at])
243
246
  end
244
247
 
@@ -298,15 +301,15 @@ module Samagotchi
298
301
  p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
299
302
 
300
303
  unless @dry_run
301
- id = log.judgment(session_id: ask.session_id, rule: ask.approval[:rule], source: ask.approval[:source],
302
- tool: ask.approval[:tool], **SystemOne::ApprovalQuestion.call_fields(ask.approval),
303
- repo_name: ask.approval[:repo_name], p: p, threshold: threshold, model: client.model,
304
+ id = log.judgment(session_id: ask.session_id, rule: ask.approval.rule, source: ask.approval.source,
305
+ tool: ask.approval.tool, **SystemOne::ApprovalQuestion.call_fields(ask.approval),
306
+ repo_name: ask.approval.repo_name, p: p, threshold: threshold, model: client.model,
304
307
  latency_ms: latency_ms, decision: "replay", origin: "replay",
305
308
  question_id: ask.question_id, asked_at: ask.asked_at)
306
309
  log.label(id, ask.label)
307
310
  end
308
311
 
309
- { rule: ask.approval[:rule].to_s, label: ask.label, p: p, session: ask.session_id }
312
+ { rule: ask.approval.rule.to_s, label: ask.label, p: p, session: ask.session_id }
310
313
  end
311
314
 
312
315
  # The session's title, as the ask's state carries it; nil when there is
@@ -3,6 +3,7 @@
3
3
  require "rbconfig"
4
4
  require_relative "chi_home"
5
5
  require_relative "home_doctor"
6
+ require_relative "home_notice"
6
7
  require_relative "cli/command"
7
8
  require_relative "cli/exit"
8
9
  require_relative "cli/flags"
@@ -25,8 +26,9 @@ module Samagotchi
25
26
  set --ruby PATH record that Ruby (its own gem dirs) as the home; it must have samagotchi
26
27
  (gem install samagotchi with it first)
27
28
  doctor check the home (its Ruby is there and has samagotchi) and what should
28
- follow it: this chi, the desktop helper, sessions, the tools' chi script,
29
- this project's Gemfile.lock, mise and the last worker's project env
29
+ follow it: this chi, the last worker boot, the desktop helper, sessions,
30
+ the tools' chi script, this project's Gemfile.lock, mise and the last
31
+ worker's project env
30
32
  Every chi launch (workers, chi web's restart and Update, the macOS helper's next
31
33
  chi desktop install or upgrade) boots the home's Ruby with the home's gems. Saved in
32
34
  #{Paths.tilde(ChiHome.path)}.
@@ -42,14 +44,16 @@ module Samagotchi
42
44
  # @param path [String] home.json
43
45
  # @param probe [#call] (ruby) → ChiHome::Record or nil (ChiHome.probe)
44
46
  # @param doctor [#call] (home) → HomeDoctor
47
+ # @param bundler [Boolean, nil] running under Bundler (nil: found out)
45
48
  def initialize(argv, stdout: $stdout, stderr: $stderr, path: ChiHome.path, gem_spec: InstalledGem.spec,
46
- env: ENV, probe: nil, doctor: nil)
49
+ env: ENV, probe: nil, doctor: nil, bundler: nil)
47
50
  @argv = argv.dup
48
51
  @stdout = stdout
49
52
  @stderr = stderr
50
53
  @path = path
51
54
  @gem_spec = gem_spec
52
55
  @env = env
56
+ @bundler = bundler.nil? ? ChiHome.under_bundler?(env) : bundler
53
57
  @probe = probe || ->(ruby) { ChiHome.probe(ruby, env: env) }
54
58
  @doctor = doctor || lambda { |home|
55
59
  HomeDoctor.new(home: home, env: env, gem_spec: gem_spec, helper: HomeDoctor.default_helper(env: env))
@@ -70,6 +74,9 @@ module Samagotchi
70
74
  rescue ChiHome::Invalid => e
71
75
  error_line("#{command_name}: #{e.message} (chi home set rewrites it)")
72
76
  CLI::Exit::FAILED
77
+ rescue ChiHome::UnderBundler => e
78
+ error_line("#{command_name}: #{e.message}")
79
+ CLI::Exit::FAILED
73
80
  end
74
81
 
75
82
  private
@@ -98,12 +105,12 @@ module Samagotchi
98
105
  list
99
106
  end
100
107
 
101
- def running_home?(home) = @gem_spec && home.running_here? && home.newest_version.to_s == VERSION
108
+ def running_home?(home) = @gem_spec && HomeNotice.home?(home)
102
109
 
103
110
  def running_ruby = "#{RbConfig.ruby}#{" (a checkout)" unless @gem_spec}"
104
111
 
105
112
  def run_set(options)
106
- home = options[:ruby] ? probed(options[:ruby]) : ChiHome.from_running(env: @env)
113
+ home = options[:ruby] ? probed(options[:ruby]) : running_record
107
114
  return CLI::Exit::FAILED unless home
108
115
 
109
116
  if (problem = ChiHome.problem(home))
@@ -127,13 +134,22 @@ module Samagotchi
127
134
  error_line("#{command_name}: no Ruby at #{ruby}")
128
135
  return nil
129
136
  end
130
- return ChiHome.from_running(env: @env) if ChiHome.real(path) == ChiHome.real(RbConfig.ruby)
137
+ return running_record if ChiHome.real(path) == ChiHome.real(RbConfig.ruby)
131
138
 
132
139
  home = @probe.call(path)
133
140
  error_line("#{command_name}: #{ruby} didn't answer as a Ruby") unless home
134
141
  home
135
142
  end
136
143
 
144
+ # This process's Ruby and gem path, never under Bundler (the bundle's
145
+ # gems). Another Ruby is probed with Bundler's variables unset.
146
+ # @raise [ChiHome::UnderBundler]
147
+ def running_record
148
+ raise ChiHome::UnderBundler if @bundler
149
+
150
+ ChiHome.from_running(env: @env)
151
+ end
152
+
137
153
  # The home's rows and what should follow it (HomeDoctor); exit 1 when
138
154
  # one fails.
139
155
  def run_doctor
@@ -7,6 +7,7 @@ require_relative "config"
7
7
  require_relative "desktop"
8
8
  require_relative "home_notice"
9
9
  require_relative "installed_gem"
10
+ require_relative "log_line"
10
11
  require_relative "log_path"
11
12
  require_relative "paths"
12
13
  require_relative "project_env"
@@ -51,18 +52,21 @@ module Samagotchi
51
52
  def rows
52
53
  list = [home_row]
53
54
  if @home && !ChiHome.problem(@home)
54
- list.concat([running_row, helper_row, sessions_row, shim_row].compact)
55
+ list.concat([running_row, boot_row, helper_row, sessions_row, shim_row].compact)
55
56
  end
56
57
  list.concat([gemfile_row, mise_row, project_env_row].compact)
57
58
  end
58
59
 
59
60
  # The desktop helper chi home doctor checks: this machine's, when it
60
- # is installed.
61
+ # is installed and its chi reads this chi's config dir (home.json's,
62
+ # Desktop::MacOS#reads_this_config?).
61
63
  def self.default_helper(env: ENV)
62
64
  return nil unless Desktop.supported?
63
65
 
64
66
  helper = Desktop::MacOS.new(env: env, register: false)
65
- File.file?(helper.launch_path) ? helper : nil
67
+ return nil unless File.file?(helper.launch_path)
68
+
69
+ helper.reads_this_config? ? helper : nil
66
70
  end
67
71
 
68
72
  private
@@ -89,6 +93,35 @@ module Samagotchi
89
93
  text && warn_row("#{text}: launches boot the home; chi home set makes this chi the home")
90
94
  end
91
95
 
96
+ # The newest worker boot in the debug log since the home was set, when
97
+ # it is SessionManager::HOME_BOOT_FAILED's line (the home's Ruby
98
+ # couldn't load chi) rather than a worker's `start`. Without a log
99
+ # file (log.file off) there is no line to read.
100
+ def boot_row
101
+ failed = last_boot_failure
102
+ return nil unless failed
103
+
104
+ ruby = failed.fields["ruby"] || @home.ruby
105
+ fail_row("the last worker (#{failed.ts}, session #{failed.sid}) failed to boot the home's chi on #{ruby}: " \
106
+ "#{failed.fields["error"]}: gem install samagotchi with #{ruby} (or chi home set another Ruby), " \
107
+ "then chi sessions restart #{failed.sid}")
108
+ end
109
+
110
+ def last_boot_failure
111
+ return nil unless @log_path && File.file?(@log_path)
112
+
113
+ since = Time.iso8601(@home.set_at.to_s)
114
+ newest = nil
115
+ LogLine.each_tail_record(@log_path, bytes: ProjectEnv::LOG_TAIL_BYTES) do |record|
116
+ next unless record.tag == "worker" && %w[start boot_failed].include?(record.event)
117
+
118
+ newest = record if Time.iso8601(record.ts) >= since
119
+ end
120
+ newest if newest&.event == "boot_failed"
121
+ rescue SystemCallError, ArgumentError
122
+ nil
123
+ end
124
+
92
125
  def helper_row
93
126
  return nil unless @helper
94
127
 
@@ -48,11 +48,18 @@ module Samagotchi
48
48
  nil
49
49
  end
50
50
 
51
+ # Whether this chi is +home+'s: it runs on the home's Ruby and gems and
52
+ # is its newest chi. The one rule for "not the home's chi" (this line,
53
+ # chi home doctor, chi home's "running" row, chi self's home row).
54
+ def home?(home, ruby: RbConfig.ruby, gem_dir: Gem.dir, version: VERSION)
55
+ home.running_here?(ruby: ruby, gem_dir: gem_dir) && home.newest_version.to_s == version
56
+ end
57
+
51
58
  # This chi isn't the home's: the text, also chi home doctor's row; nil
52
59
  # when this process runs on +home+ (or there is none).
53
60
  # @return [String, nil]
54
61
  def mismatch(home, ruby: RbConfig.ruby, gem_dir: Gem.dir, version: VERSION)
55
- return nil if home.nil? || (home.running_here?(ruby: ruby, gem_dir: gem_dir) && home.newest_version.to_s == version)
62
+ return nil if home.nil? || home?(home, ruby: ruby, gem_dir: gem_dir, version: version)
56
63
 
57
64
  where = ruby
58
65
  # The same Ruby on other gems (a chruby GEM_HOME): name them both.
@@ -165,6 +165,11 @@ module Samagotchi
165
165
  end
166
166
  end
167
167
 
168
+ # Forgets the cached plugin instances (load_plugin builds new ones;
169
+ # Ruby's require cache keeps their classes). For tests: the cache is
170
+ # process-wide.
171
+ def self.reset_cache! = @plugin_cache = nil
172
+
168
173
  # The plugin's #call under the entry's error policy: a required
169
174
  # before_tool_call hook that raises denies the call; otherwise
170
175
  # on_error: log warns and skip is silent.
@@ -429,8 +429,8 @@ module Samagotchi
429
429
  # The shape the engine persists: role and content (parts stay an
430
430
  # Array), plus a model turn's tool_calls and thinking (the host's
431
431
  # reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
432
- # refs of a user message or a tool result, a prompt's turn_id (Engine,
433
- # never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
432
+ # refs of a user message or a tool result, a prompt's turn_id and its
433
+ # Verify/Revise anchors (Engine, never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
434
434
  # (the live row's params line and label, never sent back), an
435
435
  # edit/write result's tool_diffs (never sent), an empty-answer
436
436
  # note's marker (TurnNote.empty, never sent), and an answer's display
@@ -448,6 +448,7 @@ module Samagotchi
448
448
  message[:turn_id] = entry[:turn_id] if entry[:turn_id]
449
449
  message[:turn_start] = true if entry[:turn_start] == true
450
450
  message[:verify] = entry[:verify] if entry[:verify].is_a?(Hash)
451
+ message[:revises] = entry[:revises] if entry[:revises].is_a?(Hash)
451
452
  message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
452
453
  message[:anthropic_content] = entry[:anthropic_content] if entry[:anthropic_content].is_a?(Array)
453
454
  ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
@@ -706,7 +707,7 @@ module Samagotchi
706
707
  end
707
708
 
708
709
  # The host's reasoning, kept on the model message as +thinking+ for
709
- # the web turn view's reload (the whole of it, as the live view
710
+ # the web turn block's reload (the whole of it, as the live block
710
711
  # shows), and sent back to the model as ChatLoop#wire_messages says
711
712
  # (reasoning_history). A wire that sends its blocks back keeps them
712
713
  # as +anthropic_content+ (ChatResponse#provider_content: string
@@ -104,6 +104,18 @@ module Samagotchi
104
104
  line.start_with?(INDENT)
105
105
  end
106
106
 
107
+ # Yields each Record in the last +bytes+ of the log at +path+ (a torn
108
+ # first line skipped): chi home doctor's readers.
109
+ # @raise [SystemCallError]
110
+ def each_tail_record(path, bytes:, &)
111
+ File.open(path, "rb") do |io|
112
+ size = io.size
113
+ io.seek([size - bytes, 0].max)
114
+ io.gets if size > bytes
115
+ each_record(io, &)
116
+ end
117
+ end
118
+
107
119
  # Yields each Record of a log (payload lines joined onto theirs). Lines
108
120
  # that are neither (an older format, a torn write) go to on_invalid.
109
121
  def each_record(io, on_invalid: nil)
@@ -13,7 +13,7 @@ require_relative "row_refs"
13
13
  require_relative "tool_output_cut"
14
14
 
15
15
  module Samagotchi
16
- # What a saved assistant message did, for a turn view's reload: its
16
+ # What a saved assistant message did, for the web turn block's reload: its
17
17
  # thinking and its tool calls (name, the params line the live row shows,
18
18
  # the output from the tool_response messages after it). Two storage
19
19
  # shapes, best effort: