samagotchi 0.19.0 → 0.20.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/docs/architecture.md +6 -0
  4. data/docs/cli.md +14 -3
  5. data/docs/configuration.md +4 -1
  6. data/docs/guardrails.md +92 -14
  7. data/docs/plugins.md +5 -0
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  10. data/lib/samagotchi/bootstrap_command.rb +26 -5
  11. data/lib/samagotchi/bundle_command.rb +22 -4
  12. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  14. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  15. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  20. data/lib/samagotchi/commands/registry.rb +4 -3
  21. data/lib/samagotchi/config.rb +3 -0
  22. data/lib/samagotchi/context_usage.rb +3 -3
  23. data/lib/samagotchi/engine.rb +16 -2
  24. data/lib/samagotchi/guardrail_wiring.rb +2 -1
  25. data/lib/samagotchi/guardrails/approval.rb +4 -3
  26. data/lib/samagotchi/guardrails/approvals.rb +11 -5
  27. data/lib/samagotchi/guardrails/context.rb +16 -9
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  30. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  31. data/lib/samagotchi/guardrails/rules.rb +69 -5
  32. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  33. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  34. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  35. data/lib/samagotchi/guardrails/targets.rb +65 -4
  36. data/lib/samagotchi/guardrails.rb +2 -0
  37. data/lib/samagotchi/hooks/registry.rb +6 -2
  38. data/lib/samagotchi/idle_recap.rb +7 -2
  39. data/lib/samagotchi/llm/usage.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/builder.rb +1 -5
  41. data/lib/samagotchi/memory_bundle/installer.rb +46 -13
  42. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  43. data/lib/samagotchi/memory_bundle/status.rb +12 -4
  44. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -5
  45. data/lib/samagotchi/memory_paths.rb +6 -0
  46. data/lib/samagotchi/self_report.rb +38 -2
  47. data/lib/samagotchi/session.rb +35 -6
  48. data/lib/samagotchi/session_commands.rb +13 -2
  49. data/lib/samagotchi/session_metrics.rb +224 -19
  50. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  51. data/lib/samagotchi/token_usage.rb +57 -41
  52. data/lib/samagotchi/tool_runner.rb +21 -2
  53. data/lib/samagotchi/tools/execute.rb +9 -8
  54. data/lib/samagotchi/version.rb +1 -1
  55. data/lib/samagotchi/web/app.rb +19 -0
  56. data/lib/samagotchi/web/public/app.js +34 -15
  57. data/lib/samagotchi/web/public/card.js +34 -2
  58. data/lib/samagotchi/web/public/ctx.js +59 -0
  59. data/lib/samagotchi/web/public/format.js +11 -0
  60. data/lib/samagotchi/web/public/index.html +26 -2
  61. data/lib/samagotchi/web/public/model_pick.js +6 -2
  62. data/lib/samagotchi/web/public/model_picker.js +2 -1
  63. data/lib/samagotchi/web/public/question_card.js +2 -1
  64. data/lib/samagotchi/web/public/timing.js +6 -2
  65. data/lib/samagotchi/web/public/turn_events.js +1 -1
  66. data/lib/samagotchi/web/session_summary.rb +6 -2
  67. metadata +5 -1
@@ -5,8 +5,12 @@
5
5
  #
6
6
  # Fields: id, tool (a name, a list, or shell = execute + task_create),
7
7
  # command (Ruby regex), path (outside_repo or a glob), git (outside_repo:
8
- # a shell call runs commit/add/reset/… in another checkout), verdict (ask|deny),
9
- # reason, scopes (for an ask: once, session, repo, rule; default all).
8
+ # a shell call runs commit/add/reset/… in another checkout), touches
9
+ # (chi_dirs: a shell command names a path in chi's own dirs), skip_read_only
10
+ # (true: a command that only reads, ls/cat/rg/sed -n/git log…, doesn't
11
+ # match), modes (strict: only with guardrails.mode: strict; untagged rules
12
+ # ask in every mode), verdict (ask|deny), reason, scopes (for an ask: once,
13
+ # session, repo, rule; default all).
10
14
  # [^;&|\n]* keeps a match inside one command of a chain; the git rules match
11
15
  # the subcommand after optional global options (git -C dir push).
12
16
  rules:
@@ -31,9 +35,11 @@ rules:
31
35
  command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+branch\b[^;&|\n]*\s(-[a-zA-Z]*D|--delete\s+--force|--force\s+--delete)\b'
32
36
  verdict: ask
33
37
  reason: git branch -D deletes a branch even if it is not merged
38
+ # Strict mode only: a rebase rewrites local history, which the reflog keeps.
34
39
  - id: git-rebase
35
40
  tool: shell
36
41
  command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+rebase\b'
42
+ modes: [strict]
37
43
  verdict: ask
38
44
  reason: git rebase rewrites history
39
45
  - id: git-history-rewrite
@@ -44,10 +50,13 @@ rules:
44
50
 
45
51
  # ── deleting ───────────────────────────────────────────────────────────
46
52
  # rm with both -r and -f on an absolute path, ~, $HOME or a path with ..
47
- # (a text heuristic; rm -rf build/ in the repo is not asked).
53
+ # (a text heuristic; rm -rf build/ in the repo is not asked), unless
54
+ # every rm -rf target is inside a tmp dir (rm: outside_tmp; /tmp itself,
55
+ # /tmp/* and a link out of it still ask).
48
56
  - id: rm-rf-wide
49
57
  tool: shell
50
58
  command: '\brm\b(?=[^;&|\n]*\s(-[a-zA-Z]*[rR][a-zA-Z]*|--recursive)(\s|$))(?=[^;&|\n]*\s(-[a-zA-Z]*f[a-zA-Z]*|--force)(\s|$))[^;&|\n]*\s(/|~|\$HOME|\S*\.\.)'
59
+ rm: outside_tmp
51
60
  verdict: ask
52
61
  reason: rm -rf on a path outside the working tree
53
62
 
@@ -65,10 +74,13 @@ rules:
65
74
 
66
75
  # ── work outside this session's repo, and git hooks ───────────────────
67
76
  # outside_repo: not in the session's repo (its folder outside a repo);
68
- # tmp dirs and memory files don't count.
77
+ # tmp dirs and memory files don't count. Strict mode only, as is
78
+ # git-outside-repo; chi's own config and hooks still ask (core protected
79
+ # paths, shell-touches-chi).
69
80
  - id: write-outside-repo
70
81
  tool: [write, edit]
71
82
  path: outside_repo
83
+ modes: [strict]
72
84
  verdict: ask
73
85
  reason: writes outside this session's repo
74
86
  # cd/git -C into another checkout, then commit, add, reset, checkout,
@@ -80,6 +92,7 @@ rules:
80
92
  - id: git-outside-repo
81
93
  tool: shell
82
94
  git: outside_repo
95
+ modes: [strict]
83
96
  verdict: ask
84
97
  scopes: [once, session, rule]
85
98
  reason: runs git that changes another checkout, outside this session's repo
@@ -90,9 +103,15 @@ rules:
90
103
  reason: git hooks run code on every git command
91
104
 
92
105
  # ── chi's own config, hooks, rules and approvals, from the shell ───────
106
+ # The real dirs (config.yml's dir, hooks, the approval store, installed
107
+ # bundles, this repo's git hooks), not a look-alike under /tmp or chi's
108
+ # own lib/samagotchi/hooks source; a word that can't be resolved ($VAR,
109
+ # $(…), a glob) is matched as text. A command that only reads them (ls,
110
+ # cat, rg, sed -n) isn't asked about.
93
111
  - id: shell-touches-chi
94
112
  tool: shell
95
- command: '(\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles|\.git/hooks)'
113
+ touches: chi_dirs
114
+ skip_read_only: true
96
115
  verdict: ask
97
116
  reason: the command names chi's config, hooks, guardrails or git hooks
98
117
 
@@ -1,10 +1,11 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.4.0
3
+ version: 0.5.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, writes and git in another checkout outside the session's repo, git hooks, answering chi around chi answer), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config from the shell, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
- # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0
8
- requires_chi: ">= 0.18.0"
7
+ # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
8
+ # skip_read_only:, touches:, rm: and modes: need 0.20.0
9
+ requires_chi: ">= 0.20.0"
9
10
  files:
10
11
  guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.0
3
+ version: 0.4.1
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:009dbb3b754958a6d9edb1e3b828f486aa0ddd09ff5b548f0c576abbfea18807
10
+ sha256: sha256:7c87e14dbdb20d32c37ef2a83f8d56448807ad26564a4034db4f6afbcf8a0323
11
11
  requires_chi: ">= 0.8.0"
@@ -218,7 +218,11 @@ class Plugin
218
218
 
219
219
  def ended
220
220
  status = @wait.value
221
- reason = "the server exited (#{status.exitstatus ? "status #{status.exitstatus}" : "signal #{status.termsig}"})"
221
+ reason = if status.nil?
222
+ "the server exited"
223
+ else
224
+ "the server exited (#{status.exitstatus ? "status #{status.exitstatus}" : "signal #{status.termsig}"})"
225
+ end
222
226
  waiting, closing = @mutex.synchronize do
223
227
  @dead ||= reason
224
228
  [@pending.values, @closing]
@@ -652,7 +656,7 @@ class Plugin
652
656
 
653
657
  notes = paths.map do |path|
654
658
  images << { path: path, name: File.basename(path) }
655
- "[image #{images.size}: #{File.basename(path)}, attached]"
659
+ "[image #{images.size}: #{File.basename(path)}]"
656
660
  end
657
661
  [line, *notes].join("\n")
658
662
  when "image"
@@ -661,11 +665,21 @@ class Plugin
661
665
  next "[image: #{mime}, empty]" if bytes.empty?
662
666
 
663
667
  images << { bytes: bytes, name: "#{tool}-#{images.size + 1}.#{IMAGE_EXT.fetch(mime, "img")}" }
664
- "[image #{images.size}: #{mime}, attached]"
668
+ "[image #{images.size}: #{mime}]"
665
669
  when "audio" then "[audio: #{block["mimeType"] || "unknown type"}]"
666
670
  when "resource"
667
671
  resource = block["resource"] || {}
668
- resource["text"] || "[resource: #{resource["uri"]}]"
672
+ mime = resource["mimeType"].to_s
673
+ blob = resource["blob"].to_s
674
+ if mime.start_with?("image/") && !blob.empty?
675
+ bytes = blob.unpack1("m")
676
+ next "[image: #{mime}, empty]" if bytes.empty?
677
+
678
+ images << { bytes: bytes, name: "#{tool}-#{images.size + 1}.#{IMAGE_EXT.fetch(mime, "img")}" }
679
+ "[image #{images.size}: #{mime}]"
680
+ else
681
+ resource["text"] || "[resource: #{resource["uri"]}]"
682
+ end
669
683
  when "resource_link" then "[resource link: #{block["uri"]}]"
670
684
  else "[#{block["type"] || "unknown"} content]"
671
685
  end
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.19.0
3
+ version: 0.20.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:69bc0ec091fdaeedf6e3aa7365275461fe3eba32b2dedc9cf45044176c356af5
9
9
  config_modification_protocol.md: sha256:8d8921854e616d840b393cdda6fd911b7d5e8172501bb8293c01f734121ccc21
10
- memory_guide.md: sha256:8819678a243590203fa439ad736bf1959af4b4677bc6d2d507bad92c46fb1ccc
10
+ memory_guide.md: sha256:17126f5adb9ef7b33c40e920ef339388c8c8c045b38a7de825b08760604db4e4
11
11
  delegated.md: sha256:0a71d5dfa7127df2d51cb5f0e7214240ed88777e36559430a8724c796d7ae561
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
94
94
  | `build [--scope system\|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]` | **Inverse of install** — builds a shareable bundle from local memories and installed hooks. Infers `zip` vs `dir`/`tar.gz` from `--out` extension; default `chi_system_memories.zip` (system) or `chi_<repo>_memories.zip` (project, named after the project root) v`1.0.0` in `Dir.pwd`. `FILES...` is an optional allowlist of memory basenames (`identity` or `identity.md`); if omitted, all `*.md` except `index.md`/hidden/non-md and the memories other installed bundles own (each named in a `Left out` line) are included. Installed hooks are copied to `hooks/` with their manifest metadata. Computes `sha256:` checksums and writes `manifest.yml` via `Manifest.write`. No provenance write. |
95
95
 
96
96
  **Scope resolution for install/build:**
97
- - CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`Installer#resolve_target_dir`).
97
+ - CLI `--scope` wins over `manifest.yml` `scope`. Default is `system` if none given (`Installer#run`). For `project`, target is the project folder `<system dir>/projects/<basename>_<hash>` of the project root (`MemoryPaths.scope_dir`).
98
98
 
99
99
  **Example flows:**
100
100
  ```bash
@@ -28,8 +28,10 @@ module Samagotchi
28
28
  def in_ui?(ui) = uis.nil? || uis.include?(ui)
29
29
  end
30
30
 
31
- def initialize
31
+ # @param entries [Array<Entry>] taken as they are, in order (.from_listing)
32
+ def initialize(entries: [])
32
33
  @entries = []
34
+ entries.each { |entry| add(entry) }
33
35
  end
34
36
 
35
37
  # A UI without an Engine (attached) learns the session's commands from
@@ -39,8 +41,7 @@ module Samagotchi
39
41
  # @param listing [Array<Hash>] #listing, with String or Symbol keys
40
42
  # @return [Registry]
41
43
  def self.from_listing(listing, base:)
42
- registry = new
43
- base.entries.each { |entry| registry.send(:add, entry) }
44
+ registry = new(entries: base.entries)
44
45
  known = base.entries.map(&:name)
45
46
  Array(listing).each do |item|
46
47
  item = item.transform_keys(&:to_sym)
@@ -107,6 +107,9 @@ module Samagotchi
107
107
  # The guardrails settings are config.yml only: a parent agent must not switch a child's guardrails off from its
108
108
  # own environment (SAMAGOTCHI_GUARDRAILS_ENABLED=false). SessionManager.spawn_options unsets them for a worker too.
109
109
  Entry.new(key: "guardrails.enabled", yaml_path: %w[guardrails enabled], type: :bool, default: true, expose: %i[config]),
110
+ # Which rules vote: auto (the default) leaves out the rules tagged `modes: [strict]`; strict runs them all. An unknown
111
+ # value warns and is auto.
112
+ Entry.new(key: "guardrails.mode", yaml_path: %w[guardrails mode], type: :enum, default: "auto", expose: %i[config], enum_values: %w[auto strict]),
110
113
  # Which models get the rules with `models: small` (Guardrails::ModelSize): auto (32B or less by the name's size, an MoE's
111
114
  # active size counting), globs on the bare model name or its key, "|"-separated (a YAML list works too), or "" ([]) for none.
112
115
  Entry.new(key: "guardrails.small_models", yaml_path: %w[guardrails small_models], type: :string, default: "auto", expose: %i[config]),
@@ -26,9 +26,9 @@ module Samagotchi
26
26
  usage = {} unless usage.is_a?(Hash)
27
27
 
28
28
  # The token counts come from the same parser SessionMetrics uses.
29
- counts = TokenUsage.from_payload(payload_hash) || {}
30
- prompt_tokens = counts[:prompt_tokens]
31
- completion_tokens = counts[:completion_tokens]
29
+ counts = TokenUsage.from_payload(payload_hash)
30
+ prompt_tokens = counts&.prompt_tokens
31
+ completion_tokens = counts&.completion_tokens
32
32
 
33
33
  total_tokens = first_positive_integer(
34
34
  usage["total_tokens"], usage[:total_tokens],
@@ -2298,6 +2298,7 @@ module Samagotchi
2298
2298
  # not an event of its own.
2299
2299
  next thinking_refused(event) if event[:type] == :thinking_refused
2300
2300
 
2301
+ event = with_generation_report(event) if event[:type] == :generation_completed
2301
2302
  emit_event(on_event, event)
2302
2303
  watch.feed(**progress) if watch && progress
2303
2304
  if event[:type] == :generation_completed
@@ -2307,6 +2308,19 @@ module Samagotchi
2307
2308
  end
2308
2309
  end
2309
2310
 
2311
+ # A :generation_completed with the generation's decode speed (`speed:`
2312
+ # {decode_tps:, source:}, nil without one) and the session's running
2313
+ # token totals (`tokens:`, the snapshot's block): the web's info bar
2314
+ # reads them from the stream. SessionMetrics closes the generation here,
2315
+ # so the event it gets next finds it closed.
2316
+ def with_generation_report(event)
2317
+ report = @metrics.finish_generation
2318
+ event.merge(speed: report.speed&.to_h, tokens: report.tokens)
2319
+ rescue StandardError
2320
+ event
2321
+ end
2322
+ private :with_generation_report
2323
+
2310
2324
  # The turn's StreamWatch, or nil when no hook listens.
2311
2325
  def stream_watch(cancel_controller)
2312
2326
  return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
@@ -2322,8 +2336,8 @@ module Samagotchi
2322
2336
  rescue StandardError
2323
2337
  nil
2324
2338
  end
2325
- # Turn-scoped sink: receives the original event hash (no event_seq),
2326
- # byte-for-byte unchanged. Sink errors are isolated and never break the
2339
+ # Turn-scoped sink: receives the event hash as the stream handler
2340
+ # passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
2327
2341
  # kernel loop (same as KernelLoop's own handling).
2328
2342
  if on_event
2329
2343
  begin
@@ -279,7 +279,8 @@ module Samagotchi
279
279
  @failures.add("rules in config.yml", e.message, required: true, group: :rules)
280
280
  end
281
281
  Guardrails::Rules.new(rules + bundle_rules, disable: disable,
282
- enabled: Samagotchi::Config.get("guardrails.enabled") != false)
282
+ enabled: Samagotchi::Config.get("guardrails.enabled") != false,
283
+ mode: Samagotchi::Config.get("guardrails.mode") || "auto")
283
284
  end
284
285
 
285
286
  # The dry-run diff of an edit/write call for its approval; a preview
@@ -44,6 +44,8 @@ module Samagotchi
44
44
  args: args_for_card(targets),
45
45
  cwd: targets&.cwd,
46
46
  repo_root: targets&.repo_root,
47
+ # The repository's name (a worktree's main checkout's folder).
48
+ repo_name: targets&.repo_name,
47
49
  branch: verdict.context&.branch(targets&.cwd || verdict.context.cwd),
48
50
  rule: verdict.rule,
49
51
  source: verdict.source,
@@ -92,7 +94,7 @@ module Samagotchi
92
94
  end
93
95
 
94
96
  def label(scope, verdict)
95
- place = verdict.targets&.repo_root ? "in this repo" : "in this directory"
97
+ place = verdict.targets&.repo_root ? "in this repo (#{verdict.targets.repo_name})" : "in this directory"
96
98
  case scope
97
99
  when "once" then "Allow once"
98
100
  when "session" then "Allow this call for the session"
@@ -129,9 +131,8 @@ module Samagotchi
129
131
  what = Approval.args_text(targets&.args, limit: ARGS_CHARS) if what.to_s.empty?
130
132
  lines = ["#{tool}: #{what}"]
131
133
  if targets
132
- repo = targets.repo_root
133
134
  branch = verdict.context&.branch(targets.cwd)
134
- where = repo ? "repo #{File.basename(repo)}#{", branch #{branch}" if branch}" : "not in a repo"
135
+ where = targets.repo_root ? "repo #{targets.repo_name}#{", branch #{branch}" if branch}" : "not in a repo"
135
136
  lines << " in #{targets.cwd} (#{where})"
136
137
  end
137
138
  who = verdict.rule ? ["rule #{verdict.rule}", verdict.source].compact.join(", ") : (verdict.source || "hook")
@@ -16,6 +16,10 @@ module Samagotchi
16
16
  # this session;
17
17
  # repo — this exact call in this repo (the cwd outside a repo);
18
18
  # rule — anything this rule (from this source) asks about here.
19
+ # "This repo" is the repository (repo: its common git dir), so an
20
+ # approval holds in every worktree of it; repo_root, the worktree it
21
+ # was given in, is kept for showing, and an entry from before repo:
22
+ # still matches that exact folder.
19
23
  # "once" is never stored. A file that doesn't parse is moved aside to
20
24
  # approvals.json.corrupt-<UTC time>[-N], with a warning, and the store
21
25
  # starts empty: it only means more asks, and nothing is overwritten.
@@ -62,13 +66,14 @@ module Samagotchi
62
66
  def self.find_in(list, verdict)
63
67
  key = key_for(verdict)
64
68
  place = place_for(verdict)
69
+ repo = verdict.targets&.repo
70
+ here = ->(e) { e["repo"] ? e["repo"] == repo : e["repo_root"] == place }
65
71
  session_id = verdict.context&.session_id
66
72
  list.find do |e|
67
73
  case e["scope"]
68
74
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
69
- when "repo" then e["repo_root"] == place && e["key"] == key
70
- when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source &&
71
- e["repo_root"] == place
75
+ when "repo" then here.(e) && e["key"] == key
76
+ when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.(e)
72
77
  end
73
78
  end
74
79
  end
@@ -81,8 +86,9 @@ module Samagotchi
81
86
  "rule" => verdict.rule, "source" => verdict.source, "created_at" => Time.now.utc.iso8601 }
82
87
  case scope
83
88
  when "session" then entry.merge!("session_id" => verdict.context&.session_id, "key" => key_for(verdict))
84
- when "repo" then entry.merge!("repo_root" => place_for(verdict), "key" => key_for(verdict))
85
- when "rule" then entry["repo_root"] = place_for(verdict)
89
+ when "repo"
90
+ entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo, "key" => key_for(verdict))
91
+ when "rule" then entry.merge!("repo_root" => place_for(verdict), "repo" => verdict.targets&.repo)
86
92
  end
87
93
  entry.compact
88
94
  end
@@ -24,6 +24,11 @@ module Samagotchi
24
24
  end
25
25
 
26
26
  def repo_root(dir = @cwd) = @git.root(dir)
27
+ # The git hooks dir of +dir+'s repo (core.hooksPath honoured), or nil.
28
+ def hooks_dir(dir = @cwd) = @git.hooks(dir)
29
+ # +dir+'s repository: its absolute common git dir (<root>/.git for a
30
+ # plain checkout; the same for every worktree of it), or nil.
31
+ def repo(dir = @cwd) = @git.common_dir(dir)
27
32
  def branch(dir = @cwd) = @git.branch(dir)
28
33
 
29
34
  # The hook event's context: hash.
@@ -41,6 +46,8 @@ module Samagotchi
41
46
  end
42
47
 
43
48
  def root(dir) = info(dir)[:root]
49
+ def hooks(dir) = info(dir)[:hooks]
50
+ def common_dir(dir) = info(dir)[:common_dir]
44
51
  def branch(dir) = info(dir)[:branch]
45
52
 
46
53
  private
@@ -53,16 +60,16 @@ module Samagotchi
53
60
  def probe(dir)
54
61
  return {} unless File.directory?(dir)
55
62
 
56
- out, status = Open3.capture2e("git", "-C", dir, "rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD")
57
- lines = out.lines.map(&:strip)
58
- # A repo with no commits yet has no HEAD: only the root answers.
59
- unless status.success?
60
- out, status = Open3.capture2e("git", "-C", dir, "rev-parse", "--show-toplevel")
61
- return {} unless status.success?
63
+ paths = ["--show-toplevel", "--git-path", "hooks", "--git-common-dir"]
64
+ out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths, "--abbrev-ref", "HEAD")
65
+ # A repo with no commits yet has no HEAD: only the paths answer.
66
+ out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths) unless status.success?
67
+ return {} unless status.success?
62
68
 
63
- lines = [out.strip]
64
- end
65
- { root: lines[0], branch: lines[1] }
69
+ lines = out.lines.map(&:strip)
70
+ # --git-path and --git-common-dir are relative to dir (core.hooksPath too).
71
+ { root: lines[0], hooks: lines[1] && File.expand_path(lines[1], dir),
72
+ common_dir: lines[2] && File.expand_path(lines[2], dir), branch: lines[3] }
66
73
  rescue SystemCallError
67
74
  {}
68
75
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../config"
4
4
  require_relative "../paths"
5
+ require_relative "../memory_paths"
5
6
  require_relative "../hooks/loader"
6
7
  require_relative "approval"
7
8
  require_relative "protected_paths"
@@ -42,9 +43,12 @@ module Samagotchi
42
43
  # Rules that ask about chi's own config and hooks: the core
43
44
  # ProtectedPaths asks and the guardrails bundle's shell rule.
44
45
  PROTECTED_RULES = %w[chi-config chi-hooks shell-touches-chi].freeze
45
- # A shell command naming chi's config, hooks or guardrails (the
46
- # guardrails bundle's shell-touches-chi, less .git/hooks).
46
+ # A shell command naming chi's config, hooks or guardrails as text
47
+ # (broad: a parent may not allow what only looks like one).
47
48
  CHI_TEXT = %r{\.config/samagotchi|samagotchi/config\.yml|samagotchi/hooks|samagotchi/guardrails|memories/\.bundles}
49
+ # The guardrails bundle's shell-touches-chi, for a word it can't
50
+ # resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
51
+ CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
48
52
 
49
53
  module_function
50
54
 
@@ -101,10 +105,12 @@ module Samagotchi
101
105
  command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(ProtectedPaths.real(dir)) }
102
106
  end
103
107
 
104
- # chi's config dir, the hooks dir and the approval store's dir.
108
+ # chi's config dir, the hooks dir, the approval store's dir and the
109
+ # installed bundles' dir.
105
110
  def chi_dirs
106
111
  hooks = begin Hooks::Loader.hooks_dir rescue nil end
107
- [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails")]
112
+ bundles = begin MemoryPaths.bundles_dir rescue nil end
113
+ [ConfigFile.config_dir, hooks, File.join(Paths.state_dir, "guardrails"), bundles]
108
114
  .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?).uniq
109
115
  end
110
116