samagotchi 0.149.0 → 0.150.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +2 -2
  5. data/docs/cli.md +59 -24
  6. data/docs/configuration.md +5 -5
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bridge.md +8 -0
  9. data/docs/internals/bundles.md +42 -14
  10. data/docs/internals/objectives.md +7 -2
  11. data/docs/internals/web-frontend.md +1 -1
  12. data/docs/internals/web-push.md +2 -2
  13. data/docs/internals/working-set.md +5 -2
  14. data/docs/memory.md +47 -9
  15. data/docs/plugins.md +7 -1
  16. data/docs/sessions.md +5 -4
  17. data/docs/testing.md +5 -0
  18. data/lib/samagotchi/atomic_file.rb +3 -0
  19. data/lib/samagotchi/bootstrap_command.rb +5 -2
  20. data/lib/samagotchi/bridge/card_store.rb +52 -7
  21. data/lib/samagotchi/bridge.rb +1 -1
  22. data/lib/samagotchi/bundle_command.rb +91 -68
  23. data/lib/samagotchi/bundler_env.rb +24 -0
  24. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  26. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  27. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  28. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  32. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  33. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  34. data/lib/samagotchi/chi_home.rb +4 -2
  35. data/lib/samagotchi/chi_shim.rb +2 -1
  36. data/lib/samagotchi/child_env.rb +10 -3
  37. data/lib/samagotchi/engine.rb +1 -1
  38. data/lib/samagotchi/git_env.rb +43 -0
  39. data/lib/samagotchi/guardrails/context.rb +2 -0
  40. data/lib/samagotchi/guardrails/targets.rb +4 -1
  41. data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
  42. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  43. data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
  44. data/lib/samagotchi/memory_bundle/installer.rb +161 -52
  45. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  46. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  47. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  48. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  49. data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
  50. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  51. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  53. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  54. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  55. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  56. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  57. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  58. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  59. data/lib/samagotchi/objectives_check.rb +37 -7
  60. data/lib/samagotchi/project_env.rb +3 -2
  61. data/lib/samagotchi/self_report.rb +2 -0
  62. data/lib/samagotchi/testing/boundary.rb +1 -0
  63. data/lib/samagotchi/testing/minitest.rb +3 -0
  64. data/lib/samagotchi/testing/rspec.rb +3 -0
  65. data/lib/samagotchi/testing/sandbox.rb +3 -1
  66. data/lib/samagotchi/tools/delegate.rb +2 -1
  67. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  68. data/lib/samagotchi/turn_flow.rb +3 -1
  69. data/lib/samagotchi/update_command.rb +76 -28
  70. data/lib/samagotchi/update_hint.rb +4 -1
  71. data/lib/samagotchi/version.rb +1 -1
  72. data/lib/samagotchi/web/auto_update.rb +8 -8
  73. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  74. data/lib/samagotchi/web/participant_view.rb +1 -1
  75. data/lib/samagotchi/web/public/app.js +5 -3
  76. data/lib/samagotchi/web/public/block_open.js +13 -0
  77. data/lib/samagotchi/web/public/bubbles.js +4 -2
  78. data/lib/samagotchi/web/public/cards_place.js +10 -5
  79. data/lib/samagotchi/web/public/details_motion.js +2 -1
  80. data/lib/samagotchi/web/public/index.html +18 -14
  81. data/lib/samagotchi/web/public/stage_model.js +5 -6
  82. data/lib/samagotchi/web/public/stage_view.js +8 -7
  83. data/lib/samagotchi/web/public/turn_block.js +34 -19
  84. data/lib/samagotchi/web/public/turn_model.js +1 -1
  85. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  86. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  87. data/lib/samagotchi/web/push/notifier.rb +7 -9
  88. data/lib/samagotchi/web/session_hub.rb +2 -1
  89. data/lib/samagotchi/web/update_ops.rb +2 -1
  90. metadata +8 -1
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: dev
3
- version: 0.7.5
3
+ version: 0.7.6
4
4
  scope: system
5
5
  description: "Developer tools"
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.149.0"
7
+ requires_chi: ">= 0.150.0"
8
8
  includes:
9
9
  - known-names
10
10
  - mcp
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.6.2
3
+ version: 0.6.3
4
4
  scope: system
5
5
  description: Proposes the branch's open GitHub pull request to a session, attached once you mention it (#N, the branch or its URL), and attaches any PR URL with chi context add or the web's "+ URL"; the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR; the auto_attach setting can attach the PR at once instead, or do nothing
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.140.0"
7
+ requires_chi: ">= 0.150.0"
8
8
  needs:
9
9
  - command: gh
10
10
  why: reads the pull request
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:114e999c49d4d427b7570e3f4bec8db5565ee0dd1a99f2f5ec3000748a1aec3a
14
+ sha256: sha256:82f8c76d38f314c3503eef3a5de0f8c0b20d0bb5bf4a3daebca43b60d00f8c7a
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -5,6 +5,7 @@ require "json"
5
5
  require "open3"
6
6
  require "time"
7
7
  require "tmpdir"
8
+ require "samagotchi/git_env"
8
9
 
9
10
  # github-pr: a session on a branch with an open GitHub PR gets the PR
10
11
  # proposed as a candidate (docs/context.md, Candidates) when its worker
@@ -334,7 +335,7 @@ class Plugin
334
335
 
335
336
  # stdout of a command that succeeded within COMMAND_SECONDS, else nil.
336
337
  def run(ctx, *command, chdir: ctx.cwd)
337
- Open3.popen2(*command, chdir: chdir, err: File::NULL, pgroup: true) do |stdin, stdout, wait|
338
+ Open3.popen2(Samagotchi::GitEnv::UNSET, *command, chdir: chdir, err: File::NULL, pgroup: true) do |stdin, stdout, wait|
338
339
  stdin.close
339
340
  reader = Thread.new { stdout.read }
340
341
  unless wait.join(COMMAND_SECONDS)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "samagotchi/git_env"
4
+
3
5
  # A before_tool_call guard against near-misses of protected identifiers in
4
6
  # a tool call's command or paths: the user's home folder name, login, git
5
7
  # name and email, the repo name, and any names from config. A local model
@@ -187,7 +189,7 @@ class KnownNames
187
189
  end
188
190
 
189
191
  def git_config(root, key)
190
- IO.popen(["git", "-C", root, "config", "--get", key], err: File::NULL, &:read).to_s.strip
192
+ IO.popen(Samagotchi::GitEnv::UNSET, ["git", "-C", root, "config", "--get", key], err: File::NULL, &:read).to_s.strip
191
193
  rescue StandardError
192
194
  ""
193
195
  end
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: known-names
3
- version: 0.1.8
3
+ version: 0.1.9
4
+ requires_chi: ">= 0.150.0"
4
5
  scope: system
5
6
  description: Catches a near-miss of a protected name (home folder, login, git name, repo name, names from config) in a tool call and rejects it with the right spelling; mode ask / correct optional
6
7
  trust_level: reviewed
@@ -8,7 +9,7 @@ files:
8
9
  known_names.md: sha256:d5348b95b6c655017b541910b36e384220da280818160201f6ec9a50e1e4287c
9
10
  hooks:
10
11
  known_names.rb:
11
- sha256: sha256:a851637e9292f825dc5b8877cd060184de976aa4297b114dab412a89fa3cf091
12
+ sha256: sha256:d7eed0dd0f418d8099a147ed124c6e6062b8c752035dd6b5aa5e926e29941c8b
12
13
  event: before_tool_call
13
14
  on_error: log
14
15
  priority: 50
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Loaded through module_eval: it requires what it uses.
4
4
  require "open3"
5
+ require "samagotchi/git_env"
5
6
  require "samagotchi/ref"
6
7
 
7
8
  # An after_turn (and question_asked) hook that links the source refs the
@@ -621,8 +622,7 @@ class SourceLinks
621
622
  end
622
623
 
623
624
  def read_remote(dir, name)
624
- out, status = Open3.capture2({ "GIT_DIR" => nil, "GIT_WORK_TREE" => nil },
625
- "git", "-C", dir, "remote", "get-url", name, err: File::NULL)
625
+ out, status = Open3.capture2(Samagotchi::GitEnv::UNSET, "git", "-C", dir, "remote", "get-url", name, err: File::NULL)
626
626
  unless status.success?
627
627
  Samagotchi::Log.debug(:hooks, "source_links_no_remote", remote: name, exit: status.exitstatus)
628
628
  return nil
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: source-links
3
- version: 0.6.2
3
+ version: 0.6.3
4
4
  scope: system
5
5
  description: Links source refs (JIRA tickets, GitHub issues, …) in the model's answer — inline links in the web, refs in a waiting question's reply, and a one-line note after the turn (note false turns it off); sources are configured patterns
6
6
  trust_level: reviewed
7
- requires_chi: ">= 0.98.0"
7
+ requires_chi: ">= 0.150.0"
8
8
  files:
9
9
  source_links.md: sha256:ace01a0f7c308a0ae54ece714c31c0e6d7a49c372d98111030e6d268320bed3a
10
10
  hooks:
11
11
  source_links.rb:
12
- sha256: sha256:7e3fafb843f1a9006b1a42f4ec647b41a358d814515cd0be73b60eb08b898992
12
+ sha256: sha256:21d7a9f7c381c653b126b625feed0e0b6422eaa84c8dfe716b6c5b41644a33b3
13
13
  events: [after_turn, question_asked]
14
14
  on_error: log
15
15
  priority: 90
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.149.0
3
+ version: 0.150.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
- self_map.md: sha256:c9ece2d56c63fb0f9842963cb0b123e89abadec888645d7a765a2578ceefee3a
8
+ self_map.md: sha256:e695f78f858c29ccfef2ed6f9d6dd86d0ec067ed704a5a1956c01e53617948a1
9
9
  config_modification_protocol.md: sha256:8896d1d364ce835040f69b504518d43dbdf959b93fdd9b10f87a8de61cae84a8
10
- memory_guide.md: sha256:64c3fa0703a3bdd8ae33b9e7cb341caa88a205c289d767f4877b38dfa6629fda
10
+ memory_guide.md: sha256:bfe9f13e8a6c929056fe531c4c74c7e05d0f17f025191c1a111c8e7dfd224fd6
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -114,9 +114,9 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
114
114
  | Command | Purpose |
115
115
  |---------|---------|
116
116
  | `install <source> [--scope system\|project] [--force]` | Install from dir/zip/tar.gz/git URL. `--force` overwrites existing entries, otherwise skips them; a skipped file stays the user's (not recorded, never upgraded or removed by the bundle). Writes provenance to `.bundles/<name>/` (base snapshots + `manifest.json`) and updates `index.md`. Warns on double-brace placeholders and checksum mismatches (strict mode). |
117
- | `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger.classify`: `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `conflict` (both edited → warn, needs `--force` or interactive `memory_write` resolution). Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
117
+ | `upgrade <source> [--force] [--dry-run] [--agent]` | 3-way merge upgrade (base vs current vs incoming) per `Merger::Upgrade#outcome` (`Merger.classify`, then a text merge where both sides changed): `install` (new file), `noop` (current==incoming), `fast_forward` (current==base, not edited → auto-update), `keep` (incoming==base → preserve local edits), `merged` (both edited, a text merge with `git` comes out clean → the file takes it, the old one goes to the bundle trash, and entries both sides added under a `Changelog` heading are kept), `conflict` (both edited, a real conflict, or no `git` or base → the file stays untouched and marked, reported as `X stays behind 0.8.0: …` with why and what it missed, needs a retry, `--force`, which copies it to the trash first, or interactive `memory_write` resolution). A marked file is retried at the same version: the system bundle's at each chi start and on `chi update`, another shipped bundle's on `chi update`, any other bundle's on `chi bundle upgrade`. A file marked by an older chi merges normally when its recorded base is a version chi shipped (an installed samagotchi gem still has it); otherwise it merges only if the bundle takes out or rewords no line it still holds, and if it does, it stays behind until resolved by hand, with `--agent` or with `--force`, and the retry can't fix that. Pruned files (removed from new bundle) are kept if locally edited, otherwise warned. |
118
118
  | `uninstall <bundle> [--force]` | Moves the bundle's memory files to `.bundles/.trash/<bundle>-<time>/` (refuses locally edited ones unless `--force`; keeps a file another installed bundle also lists), removes their `index.md` lines, deletes the provenance dir. |
119
- | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|no-index` vs stored checksum and base snapshot; a model overlay (`<name>.<key>.md`) has no index line and reads `ok (model overlay)`. |
119
+ | `status [<bundle>]` | Provenance + per-file `ok|modified|missing|unreadable|no-index` vs stored checksum and base snapshot, and what stays behind or merged at a chi start; `issues=` counts files behind, missing or unreadable, not edits; a model overlay (`<name>.<key>.md`) has no index line and reads `ok (model overlay)`. |
120
120
  | `diff <bundle> [file]` | Prints `base` (provenance snapshot) vs `current` (on-disk) for each file. |
121
121
  | `list` | Lists installed bundles (`name v<version> scope files installed_at`, plus the shipped version when newer) and the bundles shipped with chi that are not installed (`install <name>` installs one), grouped under their profile; an installed profile shows `includes=` and `left out=`. |
122
122
  | `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. |
@@ -138,7 +138,7 @@ chi bundle install https://github.com/org/bundle.git#v1.2.0 --scope system --for
138
138
 
139
139
  # check what would change and upgrade
140
140
  chi bundle upgrade ./my-bundle --dry-run
141
- chi bundle upgrade ./my-bundle # auto-merges, warns on conflicts
141
+ chi bundle upgrade ./my-bundle # merges your edits, warns on a real conflict
142
142
 
143
143
  # share again after editing
144
144
  chi bundle build --scope system --out updated.zip
@@ -155,9 +155,9 @@ chi bundle build --scope system --out updated.zip
155
155
  2. **Prefer project scope** for repo decisions; `system` for cross-project identity/preferences.
156
156
  3. **One concept per file:** small files merge and share better than monoliths.
157
157
  4. **Use bundles for sharing:** `build` → zip → share → `install`. Do not copy raw `~/.config` paths in docs — give `chi bundle install <url>` instructions.
158
- 5. **Respect local edits:** installs skip existing files by default; use `--force` only when the user explicitly wants overwrite. Upgrades preserve edits (`keep`) or report `conflict` — guide the user to resolve via `memory_read`/`memory_write` or `chi bundle diff <bundle>` + `--force`.
158
+ 5. **Respect local edits:** installs skip existing files by default; use `--force` only when the user explicitly wants overwrite. Upgrades preserve edits (`keep`, `merged`) or report `conflict`, leaving the file behind — guide the user to resolve via `memory_read`/`memory_write` or `chi bundle diff <bundle>` + `--force`.
159
159
  6. **Keep secrets out:** never write tokens/keys to memories — they are plain files and go into bundles.
160
160
 
161
161
  ## Current system bundle
162
162
 
163
- `samagotchi-system` (`lib/samagotchi/bundles/system/manifest.yml`) ships `identity.md` + `self_map.md` + `config_modification_protocol.md` + `delegated.md` + this guide itself. It is auto-installed/upgraded on first `Engine` creation (`SystemBundle.ensure!`) — no manual install needed. Its files are managed by chi: a newer chi upgrades them (a 3-way merge keeps local edits and reports conflicts), so put your own preferences in separate memories rather than editing these.
163
+ `samagotchi-system` (`lib/samagotchi/bundles/system/manifest.yml`) ships `identity.md` + `self_map.md` + `config_modification_protocol.md` + `delegated.md` + this guide itself. It is auto-installed/upgraded on first `Engine` creation (`SystemBundle.ensure!`) — no manual install needed. Its files are managed by chi: a newer chi upgrades them (a text merge keeps local edits, and a real conflict leaves the file behind), so put your own preferences in separate memories rather than editing these.
@@ -83,7 +83,7 @@
83
83
  `file:line`. Read the source `chi self` reports, not some other checkout on disk.
84
84
  - Installed gem files and bundled memories (`identity`, `memory_guide`,
85
85
  `config_modification_protocol`, `self_map`, `delegated`) are managed: don't edit them
86
- (local edits conflict on upgrade).
86
+ (an upgrade merges local edits when it can, and a real conflict leaves the file behind).
87
87
  Put notes in my own memories. Guidance for some models only is a model note
88
88
  (`model_notes_<name>`, first line `models: <glob>`; memory_guide); a model-only
89
89
  addition to one memory is an overlay on it (`current_model_only: true`), and it
@@ -6,6 +6,7 @@ require "open3"
6
6
  require "rbconfig"
7
7
  require "time"
8
8
  require_relative "atomic_file"
9
+ require_relative "bundler_env"
9
10
  require_relative "installed_gem"
10
11
  require_relative "log"
11
12
  require_relative "paths"
@@ -33,8 +34,9 @@ module Samagotchi
33
34
  # Where `mise` usually is when PATH doesn't have it (launchd's bare PATH).
34
35
  MISE_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", File.join(Dir.home, ".local", "bin")].freeze
35
36
  # What a probe of another Ruby must not inherit: they would move it off
36
- # its own gems.
37
- PROBE_UNSET = %w[GEM_HOME GEM_PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH].freeze
37
+ # its own gems. BUNDLER_SETUP alone does: RubyGems requires what it names at
38
+ # boot, and a bundle with a path then replaces GEM_HOME and GEM_PATH.
39
+ PROBE_UNSET = (%w[GEM_HOME GEM_PATH] + BundlerEnv::CHILD_RUBY).freeze
38
40
  PROBE = "require 'json'; print JSON.generate('ruby' => RbConfig.ruby, 'gem_home' => Gem.dir, " \
39
41
  "'gem_path' => Gem.path.join(File::PATH_SEPARATOR))"
40
42
  GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "shellwords"
5
5
  require_relative "atomic_file"
6
+ require_relative "bundler_env"
6
7
  require_relative "chi_home"
7
8
  require_relative "log"
8
9
  require_relative "paths"
@@ -25,7 +26,7 @@ module Samagotchi
25
26
  # ruby: unset before the exec.
26
27
  UNSET = "unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS GEMRC LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES " \
27
28
  "DYLD_LIBRARY_PATH\n" \
28
- "for v in $(/usr/bin/env | /usr/bin/sed -n 's/^\\(BUNDLE[A-Za-z0-9_]*\\)=.*/\\1/p'); do unset \"$v\"; done\n"
29
+ "for v in $(/usr/bin/env | /usr/bin/sed -n 's/^\\(#{BundlerEnv::PREFIX}[A-Za-z0-9_]*\\)=.*/\\1/p'); do unset \"$v\"; done\n"
29
30
 
30
31
  module_function
31
32
 
@@ -20,16 +20,23 @@ module Samagotchi
20
20
  # (Tools::Builtins.parent_env): that session's, not a child chi's.
21
21
  SESSION_MARKERS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING
22
22
  SAMAGOTCHI_SESSION_REASONING_HISTORY].freeze
23
+ # "1" in the chi update an automatic run starts (Web::UpdateOps): no person
24
+ # reads its rows. An env marker, so it reaches the chi a hand-over or the home
25
+ # execs, and an older chi that doesn't know it simply ignores it.
26
+ UNATTENDED_UPDATE = "SAMAGOTCHI_UPDATE_UNATTENDED"
23
27
 
24
28
  module_function
25
29
 
26
30
  # The keys of +env+ a child chi must not inherit: the guardrails
27
31
  # settings, the session markers, a smoke's fake "newest installed"
28
- # (InstalledVersions::ENV_KEY, for the spawner's notices only) and the
29
- # worker's project env marker and record (a child worker applies its own).
32
+ # (InstalledVersions::ENV_KEY, for the spawner's notices only), the
33
+ # worker's project env marker and record (a child worker applies its own) and
34
+ # an automatic update's marker (a session someone starts from one isn't it; the
35
+ # update's own hand-over execs, which this doesn't touch).
30
36
  # @return [Array<String>]
31
37
  def spawner_keys(env = ENV)
32
- env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS + [InstalledVersions::ENV_KEY, ProjectEnv::MARKER_ENV, ProjectEnv::ORIG_ENV]
38
+ env.each_key.select { |key| key.start_with?(GUARDRAILS_PREFIX) } + SESSION_MARKERS +
39
+ [InstalledVersions::ENV_KEY, ProjectEnv::MARKER_ENV, ProjectEnv::ORIG_ENV, UNATTENDED_UPDATE]
33
40
  end
34
41
 
35
42
  # #spawner_keys mapped to nil: Process.spawn unsets a key mapped to nil.
@@ -961,7 +961,7 @@ module Samagotchi
961
961
  ANNOUNCEABLE_EVENTS = %i[turn_enqueued turn_dequeued input_merged prompt_restored continue_offered continue_resolved
962
962
  command_queued command_ran context_added card hook_notice guardrail_warning
963
963
  plugin_init_started plugin_init_finished working_set_updated participants_changed
964
- verdict_recorded objectives_updated].freeze
964
+ verdict_recorded objectives_updated turn_rolled_back].freeze
965
965
 
966
966
  # Put a transport-level event into the ordered event log. Unlike turn
967
967
  # events it reaches only persistent observers (no turn sink, no memory
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The variables that tell a git which repo to act on. A process started by
5
+ # git (`rebase --exec`, `bisect run`, a hook) inherits them, and then its
6
+ # `git -C dir ...`, or a clone into a temp dir, reads or writes that repo
7
+ # instead of dir. chi's own git calls on a path chi chose spawn with UNSET;
8
+ # git then finds the repo from its cwd or -C. A model's and a user's commands
9
+ # (execute, tasks, !cmd) are theirs: they keep what chi was given.
10
+ module GitEnv
11
+ # `git rev-parse --local-env-vars` (git_env_spec checks it) less
12
+ # GIT_CONFIG_COUNT, which only adds settings and which git never sets for
13
+ # a child: like ~/.gitconfig it is the user's (a CI's clone credentials),
14
+ # and the suite's own (spec_helper). spec/e2e/support/env.js asks git for
15
+ # the same list.
16
+ VARS = %w[
17
+ GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_DIR GIT_GRAFT_FILE
18
+ GIT_IMPLICIT_WORK_TREE GIT_INDEX_FILE GIT_NO_REPLACE_OBJECTS GIT_OBJECT_DIRECTORY GIT_PREFIX
19
+ GIT_REPLACE_REF_BASE GIT_SHALLOW_FILE GIT_WORK_TREE
20
+ ].freeze
21
+
22
+ # What a spawn takes as its env: Process.spawn unsets a key mapped to nil.
23
+ # Not GIT_CONFIG_PARAMETERS, GIT_CONFIG_COUNT's older form (`git -c ...` around
24
+ # chi): settings for this git, not a repo, like a url.insteadOf for a clone.
25
+ # A test run clears it (VARS); what wants no config at all says so (TextMerge).
26
+ UNSET = (VARS - ["GIT_CONFIG_PARAMETERS"]).to_h { |name| [name, nil] }.freeze
27
+
28
+ module_function
29
+
30
+ # For a test run, as spec_helper and the kit's adapters load: its specs'
31
+ # `git -C tmp init` must not reach the real repo. Clears them for this
32
+ # process and all it starts, also from the copy Bundler keeps of the
33
+ # environment it started in: Bundler.with_unbundled_env and
34
+ # InstalledGem.unbundled_env_changes would put them back in a child.
35
+ def scrub!
36
+ clear(ENV)
37
+ clear(::Bundler::ORIGINAL_ENV) if defined?(::Bundler::ORIGINAL_ENV)
38
+ end
39
+
40
+ # @param env [#delete] ENV or a Hash
41
+ def clear(env) = VARS.each { |name| env.delete(name) }
42
+ end
43
+ end
@@ -93,6 +93,8 @@ module Samagotchi
93
93
  return {} unless File.directory?(dir)
94
94
 
95
95
  paths = ["--show-toplevel", "--git-path", "hooks", "--git-common-dir"]
96
+ # In chi's own environment, on purpose: a tool's git inherits it, and a
97
+ # card or a rule must judge the repo that git acts on (GIT_DIR around chi too).
96
98
  out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths, "--abbrev-ref", "HEAD")
97
99
  # A repo with no commits yet has no HEAD: only the paths answer.
98
100
  out, status = Open3.capture2e("git", "-C", dir, "rev-parse", *paths) unless status.success?
@@ -401,7 +401,10 @@ module Samagotchi
401
401
  # :unknown for one the text doesn't tell); [] for other tools.
402
402
  def git_dirs
403
403
  @git_dirs ||= if shell?
404
- # chi's own GIT_DIR is not the command's concern
404
+ # The text's and the call's env: only. chi's own GIT_DIR and
405
+ # GIT_WORK_TREE aren't counted here, though execute inherits
406
+ # them and the command does act on that repo; GitInfo (the
407
+ # repo root and common dir this is compared with) follows them.
405
408
  shell_envs.each_with_index.flat_map do |(home, vars), i|
406
409
  ShellGitDirs.for(@command, cwd: @cwd, home: home, env: i.zero? ? {} : @env, vars: vars)
407
410
  end.uniq
@@ -19,6 +19,8 @@ module Samagotchi
19
19
  # warnings, and #run returns what the record (Provenance#write) keeps.
20
20
  # A dev install (+dev+, `chi bundle install --dev`) links each file to
21
21
  # the source instead of copying it, and warns of no checksum mismatch.
22
+ # An edited file it replaces or removes is copied to the trash first,
23
+ # under its subfolder (Trash#copy_edit); a link never is.
22
24
  class AssetInstaller
23
25
  # What was installed: the paths Provenance#write records (each
24
26
  # empty / nil on a dry run).
@@ -33,7 +35,8 @@ module Samagotchi
33
35
  # @param existing [InstalledBundle, nil] the record before this install
34
36
  # @param results, warnings, conflicts the Installer's (results and
35
37
  # warnings are added to)
36
- def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:,
38
+ # @param trash [Trash] the Installer's
39
+ def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:, trash:,
37
40
  force: false, strict: true, upgrade: false, dry_run: false, dev: false)
38
41
  @name = name
39
42
  @bundle_dir = source_dir
@@ -43,6 +46,7 @@ module Samagotchi
43
46
  @results = results
44
47
  @warnings = warnings
45
48
  @conflicts = conflicts
49
+ @trash = trash
46
50
  @force = force
47
51
  @strict = strict
48
52
  @upgrade = upgrade
@@ -112,6 +116,7 @@ module Samagotchi
112
116
 
113
117
  FileUtils.mkdir_p(@provenance.hooks_dir)
114
118
  unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
119
+ @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: prev_sha, incoming: src)
115
120
  place(src, dest)
116
121
  @results[basename] = if unchanged
117
122
  { status: "skipped", reason: "already up to date" }
@@ -135,6 +140,7 @@ module Samagotchi
135
140
  else
136
141
  FileUtils.mkdir_p(@provenance.hooks_dir)
137
142
  dest = File.join(@provenance.hooks_dir, basename)
143
+ @trash.copy_edit(dest, as: "hooks/#{basename}", recorded: @existing&.hooks&.dig(basename)&.sha256, incoming: src)
138
144
  place(src, dest)
139
145
  @results[basename] = { status: "installed" }
140
146
  @hook_files[basename] = dest
@@ -152,6 +158,7 @@ module Samagotchi
152
158
  if File.exist?(old_dest) && !@force && !File.symlink?(old_dest)
153
159
  @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
154
160
  end
161
+ @trash.copy_edit(old_dest, as: "hooks/#{old_key_str}", recorded: @existing.hooks[old_key_str]&.sha256)
155
162
  FileUtils.rm_f(old_dest)
156
163
  end
157
164
  end
@@ -190,6 +197,7 @@ module Samagotchi
190
197
  dest = File.join(target, File.basename(src))
191
198
  File.file?(dest) && FileUtils.identical?(src, dest)
192
199
  end
200
+ @trash.copy_edits(target, "guardrails", @existing&.guardrails, incoming_rules.to_h { |src| [File.basename(src), src] })
193
201
  FileUtils.rm_rf(target)
194
202
  return {} if incoming_rules.empty?
195
203
 
@@ -210,7 +218,10 @@ module Samagotchi
210
218
  def install_plugin
211
219
  plugin = @manifest&.plugin
212
220
  unless plugin
213
- FileUtils.rm_rf(@provenance.plugin_dir) unless @dry_run
221
+ unless @dry_run
222
+ @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas)
223
+ FileUtils.rm_rf(@provenance.plugin_dir)
224
+ end
214
225
  return nil
215
226
  end
216
227
 
@@ -228,6 +239,7 @@ module Samagotchi
228
239
 
229
240
  dest = File.join(@provenance.plugin_dir, plugin[:file])
230
241
  unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
242
+ @trash.copy_edits(@provenance.plugin_dir, "plugin", @existing&.plugin_shas, { plugin[:file] => src })
231
243
  FileUtils.rm_rf(@provenance.plugin_dir)
232
244
  FileUtils.mkdir_p(@provenance.plugin_dir)
233
245
  place(src, dest)
@@ -265,6 +277,8 @@ module Samagotchi
265
277
  dest = File.join(@provenance.scripts_dir, file)
266
278
  File.file?(dest) && FileUtils.identical?(File.join(@bundle_dir, "scripts", file), dest)
267
279
  end
280
+ @trash.copy_edits(@provenance.scripts_dir, "scripts", @existing&.scripts,
281
+ scripts.keys.to_h { |file| [file, File.join(@bundle_dir, "scripts", file)] })
268
282
  FileUtils.rm_rf(@provenance.scripts_dir)
269
283
  return {} if scripts.empty?
270
284
 
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "time"
5
+
6
+ module Samagotchi
7
+ module MemoryBundle
8
+ # What chi says of an edited bundle file, everywhere it reports one: the one
9
+ # owner of these words (chi update's row and --json, chi bundle list and
10
+ # status, chi self, chi bootstrap, SystemBundle.ensure!'s echo, the Installer's
11
+ # summary, the phone's push).
12
+ #
13
+ # A file is merged (it took the update) or behind: marked conflict on its
14
+ # record, with bytes other than the bundle's text it speaks of. A marked file
15
+ # brought level by hand isn't behind, and a missing one is a missing file.
16
+ module FileStanding
17
+ # Why a merge of a mark from an older chi didn't go ahead (Merger.merge_outcome).
18
+ LEGACY_BASE = "an older chi may have taken your text as the base, and the merge would remove lines from your file"
19
+ # A real conflict on such a mark: its base can't say what the bundle changed.
20
+ LEGACY_CONFLICT = "it conflicts with the bundle's text, and an older chi's base can't say what it missed"
21
+ # A marked file Remerge leaves for the next upgrade or restore.
22
+ WAITS_FOR_UPGRADE = "it matches its old base, and the bundle's next version replaces it"
23
+ FILE_MISSING = "the file is missing, and the bundle's next version puts it back"
24
+ UNSURE = "an older chi marked it, and the next upgrade checks it"
25
+ NOT_SHIPPED = "not in the bundle now, nothing to merge"
26
+
27
+ # What --force does to a file that stays behind, and to an edited one being removed.
28
+ FORCE_TAKES = "--force takes the bundle's version, your file copied to the trash first"
29
+ FORCE_MOVES = "--force moves it to the trash"
30
+ # Why a file an agent step was given stays behind (BundleCommand#settle_conflicts).
31
+ STILL_BEHIND = { untouched: "Nothing changed in %s: still behind.",
32
+ markers: "%s still holds conflict markers: still behind.",
33
+ incoming_gone: "The bundle's copy of %s is gone: still behind." }.freeze
34
+ # A chi update row's short word for the trash; the path is long for a table.
35
+ IN_THE_TRASH = "old copies: chi bundle trash"
36
+ # How many missed headings a line names.
37
+ SHOWN_MISSED = 3
38
+
39
+ # One file that stays behind. why: the clause saying why no merge was tried
40
+ # (.no_merge, or a plain reason), nil for a real conflict. missed: the
41
+ # headings of the sections the bundle changed. places: the changes under no
42
+ # heading. unsure: an older mark that no retry has looked at, with nothing to
43
+ # compare the file to, so chi can't say it is behind.
44
+ # What a record gives is read as it is shaped: anything else is nothing known.
45
+ Behind = Data.define(:file, :version, :why, :missed, :places, :unsure) do
46
+ def initialize(file:, version: nil, why: nil, missed: [], places: 0, unsure: false)
47
+ super(file: file, version: version, why: why.is_a?(String) ? FileStanding.text(why) : nil,
48
+ missed: (missed.is_a?(Array) ? missed : []).grep(String).map { |heading| FileStanding.text(heading) },
49
+ places: places.is_a?(Integer) && places.positive? ? places : 0, unsure: unsure)
50
+ end
51
+
52
+ # "stays behind 0.8.0: missed ## Objectives"
53
+ def state(dry_run: false)
54
+ return "may be behind#{" #{version}" if version}: #{UNSURE}" if unsure
55
+
56
+ head = "#{dry_run ? "would stay" : "stays"} behind#{" #{version}" if version}"
57
+ rest = [why, FileStanding.missed_words(missed, places)].compact.join("; ")
58
+ rest.empty? ? head : "#{head}: #{rest}"
59
+ end
60
+
61
+ # "x.md stays behind 0.8.0: missed ## Objectives"
62
+ def line(dry_run: false) = "#{FileStanding.text(file)} #{state(dry_run: dry_run)}"
63
+ end
64
+
65
+ # A merge no report has shown yet, as the file's record keeps it
66
+ # (Provenance#note_merged). Every merge leaves one: a worker's reaches nothing
67
+ # but its log, an automatic update's rows no person. It shows in chi bundle
68
+ # status while it exists. A report someone reads ends it: chi update's row, for
69
+ # what it printed (this run's merges too), and chi bundle upgrade's output. No
70
+ # start says it: most sessions never read that file.
71
+ MergeNote = Data.define(:file, :version, :at, :trash) do
72
+ # "merged 0.8.0 into your x.md on 2026-11-05 14:03"
73
+ def line
74
+ "#{FileStanding.merged_line([file], version)}#{" on #{FileStanding.local_time(at)}" if at}"
75
+ end
76
+ end
77
+
78
+ module_function
79
+
80
+ # +value+ as UTF-8, whatever it was tagged, what isn't valid replaced: a file
81
+ # name under LC_ALL=C and a heading must join in one line.
82
+ def text(value) = value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
83
+
84
+ # "merged 0.8.0 into your x.md, y.md"
85
+ def merged_line(files, version, dry_run: false)
86
+ "#{dry_run ? "would merge" : "merged"}#{" #{version}" if version} into your #{files.map { |file| text(file) }.join(", ")}"
87
+ end
88
+
89
+ # The clause for a merge that couldn't answer: +why+ is TextMerge's reason
90
+ # or LEGACY_BASE, where chi can't even say the file holds edits.
91
+ def no_merge(why)
92
+ why == LEGACY_BASE ? "chi can't tell whether it holds edits of yours (#{why})" : "no merge possible (#{why})"
93
+ end
94
+
95
+ def still_behind(reason, file) = format(STILL_BEHIND.fetch(reason), text(file))
96
+
97
+ def left_as_it_is(message) = "left as it is (#{message})"
98
+
99
+ # "missed ## A, ## B, ## C and 2 more", "missed 2 places"; nil for nothing known.
100
+ def missed_words(headings, places)
101
+ return places.positive? ? "missed #{counted(places, "place")}" : nil if headings.empty?
102
+
103
+ more = [headings.size - SHOWN_MISSED, 0].max + places
104
+ words = "missed #{headings.first(SHOWN_MISSED).join(", ")}"
105
+ more.positive? ? "#{words} and #{more} more" : words
106
+ end
107
+
108
+ # "1 file behind"; "2 files may be behind" if chi isn't sure of one of them.
109
+ def behind_count(count, unsure: false) = "#{counted(count, "file")} #{"may be " if unsure}behind"
110
+
111
+ # "1 file stays behind", "2 files stay behind"; "1 file may be behind"
112
+ def stay_behind(count, unsure: false)
113
+ return behind_count(count, unsure: true) if unsure
114
+
115
+ "#{counted(count, "file")} #{count == 1 ? "stays" : "stay"} behind"
116
+ end
117
+
118
+ # The sentence of a push, merged and behind as counts: "" for neither.
119
+ def counts_sentence(merged:, behind:)
120
+ parts = []
121
+ parts << "Merged the update into #{counted(merged, "file")} you edited." if merged.to_i.positive?
122
+ parts << "#{stay_behind(behind)}." if behind.to_i.positive?
123
+ parts.join(" ")
124
+ end
125
+
126
+ # A report's clauses for one bundle: what merged, what stays behind with
127
+ # how to see it, what an earlier merge did.
128
+ # @param merged [Array<String>] files
129
+ # @param behind [Array<Behind>]
130
+ # @param earlier [Array<MergeNote>]
131
+ # @return [Array<String>]
132
+ def clauses(bundle, version, merged: [], behind: [], earlier: [], dry_run: false)
133
+ behind_lines = behind.map { |file| file.line(dry_run: dry_run) }
134
+ behind_lines[0] += " (#{diff_hint(bundle, behind.first.file)})" unless behind.empty?
135
+ [*(merged.empty? ? nil : merged_line(merged, version, dry_run: dry_run)), *behind_lines, *earlier.map(&:line)]
136
+ end
137
+
138
+ def diff_hint(bundle, file) = "chi bundle diff #{text(bundle)} #{text(file)}"
139
+
140
+ def local_time(stamp)
141
+ Time.iso8601(stamp.to_s).localtime.strftime("%Y-%m-%d %H:%M")
142
+ rescue ArgumentError
143
+ stamp.to_s
144
+ end
145
+
146
+ # The one answer to whether a file stays behind, for a report and a retry
147
+ # alike: marked, there, and not level with the bundle's text the mark speaks
148
+ # of. A missing file is a missing file (a project-scope record doesn't even
149
+ # say which project it means).
150
+ # @param incoming_sha [String, nil] that text's digest, for a mark with none
151
+ def behind?(path, entry, incoming_sha: entry[:incoming_sha])
152
+ entry[:conflict] == true && File.exist?(path) && differs?(path, incoming_sha)
153
+ end
154
+
155
+ # The file's bytes aren't what the mark says the bundle ships. One it can't
156
+ # read isn't known to be level. No digest (an older mark): it isn't known
157
+ # either, and a retry would have settled it.
158
+ def differs?(path, incoming_sha)
159
+ incoming_sha.nil? || Digest::SHA256.file(path).hexdigest != incoming_sha
160
+ rescue SystemCallError
161
+ true
162
+ end
163
+
164
+ def counted(count, noun) = "#{count} #{noun}#{"s" unless count == 1}"
165
+ end
166
+ end
167
+ end