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
data/docs/memory.md CHANGED
@@ -187,7 +187,7 @@ Working habits for this model:
187
187
  `model_notes_<name>.md` you already have and never owns it, so your note
188
188
  is what loads, the two never stack, and upgrades leave it alone.
189
189
  `chi bundle install model-notes` says `Skipped model_notes_deepseek.md
190
- (already exists; use --force to overwrite)`; through the `dev` profile
190
+ (already exists; --force takes the bundle's version, your file copied to the trash first)`; through the `dev` profile
191
191
  (`chi bundle install dev`, `chi bundle upgrade dev`, `chi update`) it is
192
192
  `model-notes: kept your own model_notes_deepseek.md (not the bundle's; to
193
193
  take the bundle's, remove yours with chi memory rm model_notes_deepseek
@@ -197,14 +197,14 @@ Working habits for this model:
197
197
  (`chi memory rm model_notes_deepseek --scope system`, or `memory_write`
198
198
  with `remove: true`, moves it to the trash) and run
199
199
  `chi bundle install model-notes` again: the install writes the missing
200
- file and owns it from then on. Don't use `--force`: it overwrites every
201
- file of the bundle with no trash copy.
200
+ file and owns it from then on. `--force` overwrites every file of the
201
+ bundle instead, each one that differs copied to the bundle trash first.
202
202
  - **Other notes stack.** A note of another name for the same models
203
203
  (`model_notes_my_deepseek`), or a project-scope `model_notes_deepseek`,
204
204
  loads beside the bundle's (notes are told apart by their file, not their
205
205
  name), and the total may pass the 3,000-character warning.
206
- - **Your edits to an installed note** are kept by an upgrade (a 3-way
207
- merge), or come back as a conflict.
206
+ - **Your edits to an installed note** survive an upgrade: it merges them
207
+ with the new version, or leaves your file as it is on a real conflict.
208
208
  - Without a model note the system prompt is what it was.
209
209
 
210
210
 
@@ -487,7 +487,9 @@ for a file another bundle installed.
487
487
  and the output names them and the dir:
488
488
  `Moved to the trash: notes.md (…/.trash/team-notes-20261003-120000)`. So
489
489
  does an upgrade's removal of a file the new version no longer ships. A
490
- file you edited still needs `--force`. `chi bundle trash` lists the trash
490
+ file you edited still needs `--force`. A hook, rule, plugin or script you
491
+ edited isn't asked about: it is copied to the same dir (`Copied to the trash
492
+ first: scripts/x (…)`) and the bundle's own copy goes. `chi bundle trash` lists the trash
491
493
  (oldest first, with file count, size, and age); `--empty` deletes all
492
494
  folders, `--older-than DAYS` keeps only recent ones, and `--dry-run`
493
495
  previews. Hooks, rules and the plugin live in the bundle's own dir and
@@ -509,12 +511,48 @@ for a file another bundle installed.
509
511
  `.tgz` or `.tar` gives that archive, any other path a directory; default
510
512
  `<name>.zip`).
511
513
  - **An upgrade that meets your edits.** `chi bundle upgrade` updates a file
512
- you didn't edit and keeps one you did (a conflict), then asks on a
514
+ you didn't edit. One you edited that the bundle also changed takes a text
515
+ merge (with `git`, which chi doesn't otherwise need): when it comes out clean
516
+ the file holds both, the old one is in the bundle trash, and the output says
517
+ `merged 0.8.0 into your X` (a dry run, `would merge 0.8.0 into your X`). Entries both sides added under a
518
+ `Changelog` heading are kept, yours first. A clean merge is text, not
519
+ meaning: two sentences can still disagree, so read what you care about (`chi
520
+ bundle trash` lists the old copy).
521
+ - **A real conflict** leaves your file untouched, never with markers in it, and
522
+ marks it. So does no `git`, a file with no recorded base, or a file marked by
523
+ an older chi (below). The output says `X stays behind 0.8.0:` with why and what
524
+ it missed, as `chi update`, `chi bundle status` and `chi bundle list` (`1 file
525
+ behind`) do from then on, until the file has the bundle's bytes; the bundle's text
526
+ waits in `.bundles/<name>/incoming/` until it settles. The upgrade then asks on a
513
527
  terminal whether to launch a chi session that merges the two (`/exit`
514
528
  when done). `--agent` launches it without asking, `--no-agent` never does;
515
- with neither and no terminal it keeps your edits and says so. Kept edits
529
+ with neither and no terminal the file just stays behind. Only a file
530
+ the session changed, with no conflict markers left, counts as resolved: any
531
+ other stays a conflict (`Nothing changed in X: still behind.`). A file marked
532
+ this way is retried at the same version: the system bundle's at each chi start
533
+ and on `chi update`, another shipped bundle's on `chi update`, and any other
534
+ bundle's on `chi bundle upgrade`.
535
+ - **A file marked by an older chi** merges normally when its recorded base is a
536
+ version chi shipped, which chi checks against the samagotchi gems still
537
+ installed. Otherwise that base may be your own text, so the file merges only if
538
+ the bundle takes out or rewords no line it still holds. If it does, chi can't
539
+ tell whether the file holds edits of yours and says that; the file
540
+ stays behind until you resolve it by hand, with `--agent` or with `--force`, and
541
+ the retry can't fix that. A file left behind makes the upgrade
516
542
  exit 2; `chi bundle diff NAME FILE` shows the base, and `--force` takes
517
- the bundle's version.
543
+ the bundle's version, copying a file that differs to the bundle trash first
544
+ (the output says `Copied to the trash first: …`).
545
+ - **Every merge is noted on the file's record.** The system bundle's upgrade and
546
+ retry run at each start, a worker's only output is its log, and no one may read
547
+ an automatic update. So `chi bundle status` shows the merge with the old copy's
548
+ folder while the note exists. A `chi update` row or `chi bundle upgrade` output
549
+ printed to you ends it for what it printed (an automatic update leaves it), and
550
+ `--force` replacing the file ends it too. No session is told at start.
551
+ - **`chi bundle status`** says per file what is `modified`, `missing` or
552
+ `unreadable`, and that a file stays behind and why. Its `issues=` counts files
553
+ behind, missing or unreadable (and a plugin or hooks that won't load), not edits. A
554
+ file an older chi marked, which no retry has looked at, says `may be behind`, and so
555
+ do the counts in `chi bundle list`, `chi self` and `chi update`'s last line.
518
556
  - **A dev install** (`chi bundle install --dev PATH`) links the hooks, rules,
519
557
  plugin and scripts to `PATH` but still copies the memories at install, so
520
558
  an edit to one of them in `PATH` isn't seen until the next install.
data/docs/plugins.md CHANGED
@@ -942,6 +942,10 @@ worker gone), then moves out of the block, after the turn's end line, so it
942
942
  stays in sight; a reload puts it in the same place. An `:info` card of a
943
943
  completed turn stays in its step.
944
944
 
945
+ Between turns, a card goes above the first turn prompt after it, live and
946
+ after a reload. A failed turn whose prompt went back to its sender isn't a
947
+ turn: its prompt line comes after the cards.
948
+
945
949
  An [anytime command](#anytime-true)'s cards show as it shows them, after its
946
950
  line, in every UI, whether a turn runs or not.
947
951
 
@@ -1380,7 +1384,9 @@ that calls it, or for a real external bundle that does. No method on the chance
1380
1384
  5. `Samagotchi::Tools::TaskRuntime::TASKS_DIR` and `EXIT_CODE_FILENAME`.
1381
1385
  6. `Samagotchi::AtomicFile.write`, `Samagotchi::Log.warn` /
1382
1386
  `Samagotchi::Log.debug`, `Samagotchi::Config.get`,
1383
- `Samagotchi::VERSION` and `Samagotchi::CancellationController` (`new`, `cancel!`: what `cancel:` takes).
1387
+ `Samagotchi::VERSION`, `Samagotchi::CancellationController` (`new`, `cancel!`: what `cancel:` takes) and
1388
+ `Samagotchi::GitEnv::UNSET` (a spawn env: a bundle's own git, or gh, asks about the folder it names, not the repo
1389
+ a hook's or `rebase --exec`'s environment names).
1384
1390
 
1385
1391
  `Samagotchi::Testing::Boundary::ALLOWED` is the checked copy of this list,
1386
1392
  and `Boundary::EXCEPTIONS` the per-bundle list of what each shipped bundle
data/docs/sessions.md CHANGED
@@ -219,8 +219,9 @@ reword is refused when you changed that objective since. The model never changes
219
219
 
220
220
  **Check this** (`/objectives check O2`, or the button in the web's list, for an objective with a move) asks for an independent read of
221
221
  the latest move: one side request, on your request only, on the session's model or `objectives.check_model`. It sees the
222
- claim, what people and notes said and what tools returned, not the agent's prose, and no file: what the record doesn't
223
- settle comes back `unclear`. **That is up to 24,000 characters of raw tool output, which with a hosted `check_model` leaves the machine on one tap, even from a local session.** chi's redaction takes out known key shapes and `token=`-style values first, by pattern only, so another secret in a tool's output still goes. The verdict (confirmed, refuted, unclear) and its reasons come as a card and under the
222
+ claim, what people and notes said, what tools returned and the agent's visible answers (never its thinking), and no file. The agent's
223
+ words settle what it said or asked, never that its work was done: "I ran the tests" with no run behind it isn't `confirmed`. What the record doesn't
224
+ settle comes back `unclear`. **That is up to 24,000 characters of raw tool output, messages and the agent's visible answers, which with a hosted `check_model` leaves the machine on one tap, even from a local session.** chi's redaction takes out known key shapes and `token=`-style values first, by pattern only, so another secret in them still goes. The verdict (confirmed, refuted, unclear) and its reasons come as a card and under the
224
225
  objective. It is information only: it changes no status, approves nothing, and isn't in the conversation or the model's next request.
225
226
  A second tap while one runs asks nothing. Its cost is in the session's side line. Mid-turn it runs beside the turn; on a one-slot
226
227
  local server it answers after the running request. A participant can't reach it. See
@@ -471,8 +472,8 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
471
472
  included, asks you first; a read-only mention (`find . -name mise.toml`) doesn't. `.envrc` and mise's local files
472
473
  (`mise.local.toml`, `mise.<env>.local.toml`, dotted or not, and `config.local.toml` / `config.<env>.local.toml` in
473
474
  `.config/mise/`, `mise/`, `.mise/`) are secrets to them, as `.env` is.
474
- - **What tools still drop**: every `execute`, task, `!cmd`, context source and MCP server takes no `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`,
475
- `BUNDLE_BIN_PATH` or `BUNDLER_VERSION` from the project's mise env: it runs without `RUBYLIB` and Bundler's
475
+ - **What tools still drop**: every `execute`, task, `!cmd`, context source and MCP server takes no `RUBYOPT`, `RUBYLIB`, or what `bundle exec` sets
476
+ (`BUNDLE_GEMFILE`, `BUNDLE_LOCKFILE`, `BUNDLE_BIN_PATH`, `BUNDLER_VERSION`, `BUNDLER_SETUP`: `BundlerEnv`) from the project's mise env: it runs without `RUBYLIB` and Bundler's
476
477
  variables, and with the worker's own `RUBYOPT`, Bundler's part removed (`TaskRuntime`).
477
478
  - **`chi` in a tool** stays the home's: with a chi home the tools' PATH starts with `$XDG_STATE_HOME/samagotchi/bin`,
478
479
  a dir holding only a `chi` script that boots the home (rewritten at each worker start when the home changed; it
data/docs/testing.md CHANGED
@@ -40,6 +40,11 @@ that way locally: `npx playwright run-server --port 3333`, then `PW_TEST_CONNECT
40
40
 
41
41
  No spec reads your own setup. The suite points `XDG_CONFIG_HOME` and `XDG_STATE_HOME` at temp folders,
42
42
  clears your `SAMAGOTCHI_*` environment, and keeps every spec off the network (WebMock).
43
+ It also clears what tells git which repo to act on (`GIT_DIR`, `GIT_WORK_TREE`, the rest of
44
+ `git rev-parse --local-env-vars`: `GitEnv`), first thing in `spec_helper.rb` and as the bundle test kit's
45
+ adapters load, so a run from `git rebase --exec`, `bisect run` or a hook can't reach the real repo through a spec's git.
46
+ `npm run e2e` clears the same list as `spec/e2e/support/env.js` loads, asked of git. chi's own git calls on a folder chi
47
+ chose spawn with `GitEnv::UNSET`, in any run.
43
48
  `SAMAGOTCHI_TAILSCALE_CLI` points at a file that does not exist (`spec_helper.rb`), so no spec reaches a real `tailscale`; a
44
49
  `chi web --tunnel tailscale` spec writes a fake one and sets the variable.
45
50
 
@@ -18,6 +18,8 @@ module Samagotchi
18
18
  # @param content [String] written as-is, byte for byte
19
19
  # @param perm [Integer, nil] the new file's mode; nil = a fresh file's
20
20
  # (0666 less the umask), like File.write
21
+ # @yield just before the rename, when given: a last check of what is being
22
+ # replaced; raising leaves +path+ as it was
21
23
  # @return [String] +path+
22
24
  def write(path, content, perm: nil)
23
25
  tmp = "#{path}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp"
@@ -25,6 +27,7 @@ module Samagotchi
25
27
  io.write(content)
26
28
  end
27
29
  File.chmod(perm, tmp) if perm
30
+ yield if block_given?
28
31
  File.rename(tmp, path)
29
32
  path
30
33
  ensure
@@ -477,11 +477,14 @@ module Samagotchi
477
477
 
478
478
  def system_bundle_line(result, dry_run)
479
479
  version = "v#{result.to || result.from}"
480
+ standing = MemoryBundle::FileStanding.clauses(MemoryBundle::SystemBundle::BUNDLE_NAME, nil, merged: Array(result.merged),
481
+ behind: Array(result.behind), dry_run: dry_run).join("; ")
480
482
  text = case result.status
481
483
  when :installed then dry_run ? "would install #{version}" : "#{version} installed"
482
- when :updated then "#{result.from} → #{result.to} #{dry_run ? "would update" : "updated"}"
484
+ when :updated then "#{result.from} → #{result.to} #{dry_run ? "would update" : "updated"}#{" (#{standing})" unless standing.empty?}"
483
485
  when :restored then "#{version} #{dry_run ? "would restore" : "restored"} missing files"
484
- when :up_to_date then "#{version} up to date"
486
+ when :merged then "#{version} #{standing}"
487
+ when :up_to_date then "#{version} #{standing.empty? ? "up to date" : standing}"
485
488
  when :newer_installed then "v#{result.from} left as is (newer than this chi's v#{result.to})"
486
489
  when :skipped then "skipped (#{result.error})"
487
490
  else "failed (#{result.error})"
@@ -28,6 +28,10 @@ module Samagotchi
28
28
  # isn't the turn's but comes while one runs (an anytime command's, such
29
29
  # as /btw) goes after that turn: its prompt is already in the history.
30
30
  # A failed turn leaves no prompt, so its cards stay before the next one.
31
+ # A !rollback (:turn_rolled_back) takes a kept turn's prompt out again,
32
+ # and its count with it. Of a turn that left nothing, its cards and hook
33
+ # notices stay as between-turns ones where it was, without their
34
+ # actions; its other rows (no step to sit in) go.
31
35
  #
32
36
  # Notices (hook notices, load warnings, "asking again" rows), ✂ rows
33
37
  # and cards (with questions) are capped apart, +capacity+ each, so a
@@ -50,6 +54,15 @@ module Samagotchi
50
54
  # to a turn no worker runs any more (check-in's Nudge would steer the
51
55
  # new worker's turn). A card shown between turns (github-pr's offer,
52
56
  # btw, coordinator) keeps its buttons.
57
+ #
58
+ # A turn its worker was killed in never ended here, yet the session keeps
59
+ # its prompt (TurnCheckpoint, written before :turn_started) and it ends
60
+ # canceled. So +killed_turn_stays+ (a worker's) saves that a turn runs, and
61
+ # a store started from such a file counts that turn. A turn that ended
62
+ # (a graceful stop's cancel too) saved that it no longer runs. Started
63
+ # from the same file again, the count is the same: nothing is written
64
+ # until the next event. A REPL keeps no running turn in the session: a kill
65
+ # takes the prompt along, so its store saves no such thing.
53
66
  class CardStore
54
67
  CAPACITY = 20
55
68
  NOTICE_TYPES = %i[hook_notice guardrail_warning empty_answer_retry steer_cut].freeze
@@ -59,8 +72,8 @@ module Samagotchi
59
72
  FILE = "cards.json"
60
73
  # The events after which the file is written again: the ones that
61
74
  # change an entry or the turn count.
62
- SAVED_ON = (%i[card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
63
- question_cancelled question_relay turn_failed] + Events::TURN_KEPT).freeze
75
+ SAVED_ON = (%i[turn_started card hook_notice empty_answer_retry steer_cut llm_context_edited question_requested question_answered
76
+ question_cancelled question_relay turn_failed turn_rolled_back] + Events::TURN_KEPT).freeze
64
77
 
65
78
  # What a session's saved file lists, as a store started from it lists
66
79
  # (no turn running): the web's cards for a session no worker runs.
@@ -72,9 +85,12 @@ module Samagotchi
72
85
 
73
86
  # @param path [String, nil] the file the entries are saved in and
74
87
  # started from; nil keeps them in memory only
75
- def initialize(capacity: CAPACITY, path: nil)
88
+ # @param killed_turn_stays [Boolean] the session keeps a running turn's
89
+ # prompt on disk (a worker's TurnCheckpoint): save that a turn runs
90
+ def initialize(capacity: CAPACITY, path: nil, killed_turn_stays: false)
76
91
  @capacity = capacity
77
92
  @path = path
93
+ @killed_turn_stays = killed_turn_stays
78
94
  @mutex = Mutex.new
79
95
  @entries = []
80
96
  @turns_done = 0
@@ -122,21 +138,26 @@ module Samagotchi
122
138
  return if entries.empty? && !File.exist?(@path)
123
139
 
124
140
  FileUtils.mkdir_p(File.dirname(@path))
125
- AtomicFile.write(@path, JSON.generate({ "version" => 1, "turns" => @turns_done, "entries" => entries }))
141
+ saved = { "version" => 1, "turns" => @turns_done, "entries" => entries }
142
+ saved["running"] = true if @killed_turn_stays && @running
143
+ AtomicFile.write(@path, JSON.generate(saved))
126
144
  rescue StandardError
127
145
  nil # a card that can't be saved still shows live
128
146
  end
129
147
 
130
148
  # The entries an earlier worker saved (a missing or broken file:
131
149
  # none), their types as symbols again; a card that came during that
132
- # worker's last turn stays where it was, as after a failed turn.
150
+ # worker's last turn stays where it was, as after a failed turn, or
151
+ # after that turn when the worker was killed in it. A file from before
152
+ # "running" has none: no turn counted.
133
153
  def seed
134
154
  return unless @path && File.file?(@path)
135
155
 
136
156
  data = JSON.parse(File.read(@path), symbolize_names: true)
137
157
  return unless data.is_a?(Hash) && data[:turns].is_a?(Integer) && data[:entries].is_a?(Array)
138
158
 
139
- @turns_done = data[:turns]
159
+ killed_in_turn = data[:running] == true
160
+ @turns_done = data[:turns] + (killed_in_turn ? 1 : 0)
140
161
  @entries = data[:entries].filter_map do |entry|
141
162
  next unless entry.is_a?(Hash) && entry[:type].is_a?(String) && entry[:turns].is_a?(Integer)
142
163
 
@@ -144,7 +165,7 @@ module Samagotchi
144
165
  entry = entry.merge(actions: []) if entry[:type] == :card && entry[:in_turn] && entry.key?(:actions)
145
166
  entry unless entry[:type] == :guardrail_warning || open_question?(entry)
146
167
  end
147
- settle_during(after_turn: false)
168
+ settle_during(after_turn: killed_in_turn)
148
169
  rescue StandardError
149
170
  @turns_done = 0
150
171
  @entries = []
@@ -169,6 +190,8 @@ module Samagotchi
169
190
  kept = Events.kept_turn?(event)
170
191
  @turns_done += 1 if kept
171
192
  settle_during(after_turn: kept)
193
+ unstep_turn_left_nothing unless kept
194
+ when :turn_rolled_back then take_turn_back
172
195
  when :card then add_card(event)
173
196
  when :hook_notice then add_notice(event)
174
197
  # What failed to load (guardrails, plugins): before the first turn
@@ -210,6 +233,28 @@ module Samagotchi
210
233
  end
211
234
  end
212
235
 
236
+ # The last kept turn's prompt left the conversation. An entry from
237
+ # after it (a card shown before the !rollback) stays before the next.
238
+ def take_turn_back
239
+ @turns_done = [@turns_done - 1, 0].max
240
+ unstep_turn_left_nothing
241
+ @entries.each { |entry| entry[:turns] = [entry[:turns], @turns_done].min }
242
+ end
243
+
244
+ # The turn that would be the next one's place has no prompt in the
245
+ # history: what it showed isn't a row of a turn any more (a later
246
+ # turn would read it as its own, +current+).
247
+ def unstep_turn_left_nothing
248
+ @entries = @entries.filter_map do |entry|
249
+ next entry unless entry[:in_turn] && entry[:turns] == @turns_done
250
+
251
+ case entry[:type]
252
+ when :card then entry.merge(in_turn: false, **(entry.key?(:actions) ? { actions: [] } : {}))
253
+ when :hook_notice then entry.except(:iteration, :calls).merge(in_turn: false)
254
+ end
255
+ end
256
+ end
257
+
213
258
  def add_notice(event)
214
259
  notice = event.slice(:type, :hook, :text, :level, :fallback_for)
215
260
  if event[:between_turns] || !@running
@@ -136,7 +136,7 @@ module Samagotchi
136
136
  @accumulator = TurnAccumulator.new
137
137
  # Saved in the session's folder: a later worker, and the web without
138
138
  # one, show the cards where they were.
139
- @cards = CardStore.new(path: File.join(session_dir, CardStore::FILE))
139
+ @cards = CardStore.new(path: File.join(session_dir, CardStore::FILE), killed_turn_stays: true)
140
140
  @pending_card = PendingCard.new(session_dir)
141
141
  @heartbeat_interval = heartbeat_interval
142
142
  @epoch = SecureRandom.hex(4)
@@ -15,17 +15,19 @@ module Samagotchi
15
15
  install <source> [--scope system|project] [--force]
16
16
  Install a memory bundle from a directory, zip, tar archive, or git URL.
17
17
  Source is the path/URL to the bundle (dir/zip/tar.gz/git).
18
- --force overwrites existing entries; default skips them.
18
+ --force overwrites existing entries (one that differs is copied to the trash first); default skips them.
19
19
 
20
20
  upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
21
- Upgrade a bundle (3-way merge: auto-merge if not edited, conflict otherwise).
21
+ Upgrade a bundle (3-way: a file you didn't edit updates; one you edited takes a text
22
+ merge, the old file in the bundle trash; a real conflict leaves it as it is).
22
23
  --dry-run shows what would change. --agent launches interactive session on conflict.
23
24
 
24
25
  uninstall <bundle> [--scope system|project] [--force]
25
- Remove a bundle and its index entries (--force if locally edited).
26
+ Remove a bundle and its index entries (--force moves a locally edited file to the trash).
26
27
 
27
28
  status [<bundle>]
28
- Show provenance + modification status for bundles.
29
+ Show provenance and each file's status for bundles. issues= counts what
30
+ needs you: a file that stays behind, is missing or can't be read, not one you edited.
29
31
 
30
32
  diff <bundle> [file]
31
33
  Show diffs between base/current/incoming for a bundle.
@@ -66,7 +68,7 @@ module Samagotchi
66
68
  install <source> [--scope system|project] [--force]
67
69
  Install a memory bundle from a directory, zip, tar archive, or git URL,
68
70
  or a bundle shipped with chi by name (see: chi bundle list).
69
- --force overwrites existing entries; default skips them.
71
+ --force overwrites existing entries (one that differs is copied to the trash first); default skips them.
70
72
  install --dev PATH
71
73
  Install the bundle in the local directory PATH as a link to it: its plugin, hooks, rules and scripts
72
74
  are PATH's own files (an edit applies at the next worker or chi web restart, with no re-install) and
@@ -231,6 +233,9 @@ module Samagotchi
231
233
  run_installer(installer, failed: "Upgrade") do |manifest|
232
234
  print_hooks_and_plugin(manifest, plugin: !dry_run)
233
235
  next dry_run_done if dry_run
236
+
237
+ # Its summary said so, to someone reading.
238
+ Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).clear_merge_notes([], files: installer.merged_files)
234
239
  next upgrade_conflicts(installer, bundle_name, expanded_source, agent) if installer.conflicts.any? && !force
235
240
 
236
241
  print_provenance(bundle_name, manifest)
@@ -321,42 +326,64 @@ module Samagotchi
321
326
  # (scripts already read it so): not CLI::Exit::USAGE by meaning.
322
327
  CONFLICTS_KEPT = 2
323
328
 
324
- # @return [Integer] 0 resolved, CONFLICTS_KEPT kept the edits
329
+ # @return [Integer] 0 all resolved, CONFLICTS_KEPT kept some edits
325
330
  def upgrade_conflicts(installer, bundle_name, expanded_source, agent)
326
- @stdout.puts "\n#{installer.conflicts.size} conflict(s) need resolution."
327
- installer.conflicts.each { |k, _| @stdout.puts " conflict: #{k}" }
331
+ standing = Samagotchi::MemoryBundle::FileStanding
332
+ @stdout.puts "\n#{standing.stay_behind(installer.behind.size)}: #{installer.behind.map(&:file).join(", ")}"
328
333
  launch = if agent.nil? && @stdin.tty?
329
- @stdout.print "Conflicts detected — launch interactive agent to resolve? [y/N] "
334
+ @stdout.print "Launch an interactive agent to merge them? [y/N] "
330
335
  ans = begin; @stdin.gets; rescue StandardError => _e; nil; end
331
336
  ans && ans.strip.downcase.start_with?("y")
332
337
  elsif agent.nil?
333
- @stdout.puts "Non-interactive terminal: kept your edits in the file(s) above; the rest is upgraded. Re-run with --force to take the bundle's version, or --agent in a TTY to merge."
338
+ @stdout.puts "Non-interactive terminal: the rest is upgraded. #{standing::FORCE_TAKES}; --agent in a TTY merges."
334
339
  return CONFLICTS_KEPT
335
340
  else
336
341
  agent
337
342
  end
338
343
  unless launch
339
- @stdout.puts "Kept your edits in the file(s) above; the rest is upgraded. chi bundle diff #{bundle_name} FILE shows the base; re-run with --force to take the bundle's version."
344
+ @stdout.puts "The rest is upgraded. #{standing.diff_hint(bundle_name, "FILE")} shows the base; #{standing::FORCE_TAKES}."
340
345
  return CONFLICTS_KEPT
341
346
  end
342
347
 
343
348
  prompt = build_conflict_prompt(bundle_name, installer.conflicts, expanded_source)
344
- @stdout.puts "Launching interactive session for conflict resolution… (/exit when done)"
349
+ before = installer.conflicts.to_h { |key, info| [key, Samagotchi::MemoryBundle::Merger.digest(info[:current])] }
350
+ @stdout.puts "Launching an interactive session to merge them… (/exit when done)"
345
351
  require "samagotchi/terminal_ui"
346
352
  Samagotchi::TerminalUI.new(prompt: prompt).run
347
- # The installer already recorded the upgrade (conflicted files kept
348
- # their old base); the resolved files now start from the bundle's
349
- # version.
350
- Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).resolve_conflicts(installer.conflicts)
351
- @stdout.puts "Provenance updated after interactive resolution."
352
- @stdout.puts "Upgrade resolved interactively."
353
- 0
353
+ settle_conflicts(installer.conflicts, before, bundle_name)
354
354
  ensure
355
355
  # An owned source (git/zip/tar) was kept for the incoming files above;
356
356
  # the agent step is done with them now.
357
357
  installer.cleanup_source!
358
358
  end
359
359
 
360
+ # After the agent step. The installer already recorded the upgrade
361
+ # (conflicted files kept their old base); a file the step changed, with
362
+ # no conflict markers left, now starts from the bundle's version. Any
363
+ # other stays a conflict: moving its base would turn a visible miss into
364
+ # a kept edit the next upgrade says nothing about.
365
+ # @param before [Hash{String => String, nil}] each file's digest before the step
366
+ # @return [Integer] 0 all resolved, CONFLICTS_KEPT some still behind
367
+ def settle_conflicts(conflicts, before, bundle_name)
368
+ merger = Samagotchi::MemoryBundle::Merger
369
+ untouched, changed = conflicts.partition { |key, info| merger.digest(info[:current]) == before[key] }
370
+ marked, clean = changed.partition { |_, info| merger.markers?(info[:current]) }
371
+ settled = Samagotchi::MemoryBundle::Provenance.new(name: bundle_name).resolve_conflicts(clean.to_h)
372
+ gone = clean.map(&:first) - settled
373
+ standing = Samagotchi::MemoryBundle::FileStanding
374
+ untouched.each { |key, _| @stdout.puts standing.still_behind(:untouched, key) }
375
+ marked.each { |key, _| @stdout.puts standing.still_behind(:markers, key) }
376
+ gone.each { |key| @stdout.puts standing.still_behind(:incoming_gone, key) }
377
+ if untouched.empty? && marked.empty? && gone.empty?
378
+ @stdout.puts "Provenance updated after interactive resolution."
379
+ @stdout.puts "Upgrade resolved interactively."
380
+ return 0
381
+ end
382
+
383
+ @stdout.puts "Resolved: #{settled.join(", ")}" unless settled.empty?
384
+ CONFLICTS_KEPT
385
+ end
386
+
360
387
  def uninstall(rest)
361
388
  parsed = parse_flags(UNINSTALL_FLAGS, rest, "uninstall", help: UNINSTALL_HELP)
362
389
  return parsed if parsed.is_a?(Integer)
@@ -377,6 +404,7 @@ module Samagotchi
377
404
  uninstaller.run
378
405
  @stdout.puts "Uninstalled bundle '#{bundle_name}'"
379
406
  puts_trashed(uninstaller.trashed_files, uninstaller.trash_dir)
407
+ @stdout.puts uninstaller.trash_copy_line if uninstaller.trash_copy_line
380
408
  @stdout.puts "Removed: #{uninstaller.removed_files.join(", ")}" unless uninstaller.removed_files.empty?
381
409
  hook_removed = uninstaller.removed_files.count { |f| f.start_with?("hooks/") }
382
410
  @stdout.puts "Hooks removed: #{hook_removed}" if hook_removed > 0
@@ -402,6 +430,7 @@ module Samagotchi
402
430
  result.skipped.each { |member, why| lines << "skipped #{member}: #{why}" }
403
431
  result.failed.each { |member, why| lines << "failed #{member}: #{why}" }
404
432
  lines.concat(result.kept_own_lines)
433
+ lines.concat(result.trash_copies)
405
434
  lines << "nothing new to install" if lines.empty?
406
435
  lines.each { |line| @stdout.puts " #{line}" }
407
436
  @stdout.puts "(dry-run: no changes written)" if dry_run
@@ -453,16 +482,21 @@ module Samagotchi
453
482
  else
454
483
  @stdout.puts "Target: #{st.target_dir}"
455
484
  end
485
+ standing = Samagotchi::MemoryBundle::FileStanding
456
486
  st.files.each do |k, info|
457
487
  next @stdout.puts(" #{k}: unchecked") if info[:unchecked]
458
488
 
459
489
  mods = []
460
- mods << "conflict (kept your edits over v#{st.provenance.version}: chi bundle diff #{name} #{k})" if info[:conflict]
461
490
  mods << "modified" if info[:modified]
462
491
  mods << "missing" if info[:missing]
492
+ mods << "unreadable" if info[:unreadable]
463
493
  mods << "no-index" unless info[:index_present] || info[:overlay]
464
- label = mods.empty? ? "ok" : mods.join(",")
494
+ label = mods.join(",")
495
+ label = "ok" if mods.empty? && !info[:behind]
465
496
  label += " (model overlay)" if info[:overlay]
497
+ behind = info[:behind] && "#{info[:behind].state} (#{standing.diff_hint(name, k)})"
498
+ label = [label, behind].reject { |part| part.nil? || part.empty? }.join("; ")
499
+ label += "; #{info[:merge_note].line}#{" (old copy in #{info[:merge_note].trash})" if info[:merge_note].trash}" if info[:merge_note]
466
500
  @stdout.puts " #{k}: #{label}"
467
501
  end
468
502
  # Hooks
@@ -499,7 +533,7 @@ module Samagotchi
499
533
  next @stdout.puts(" #{bname} (manifest.json unreadable)") if record.error?
500
534
 
501
535
  st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
502
- mods = st.files.values.count { |v| v[:modified] || v[:missing] }
536
+ mods = st.files.values.count { |v| v[:behind] || v[:missing] || v[:unreadable] }
503
537
  hooks_count = st.provenance.hooks.size
504
538
  hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
505
539
  plugin = st.plugin
@@ -543,77 +577,62 @@ module Samagotchi
543
577
  rescue ArgumentError => e
544
578
  @stderr.puts "Bundle '#{bname}': #{e.message}"; return 1
545
579
  end
546
- files = record.files
547
580
  hooks = record.hooks
548
581
  plugin_path = prov.plugin_path(record)
549
582
  plugin_file = plugin_path && File.basename(plugin_path)
550
- show_plugin = lambda do
551
- @stdout.puts "=== plugin/#{plugin_file} ==="
552
- @stdout.puts "--- base (provenance) ---"
553
- base = prov.plugin_base_path(plugin_file)
554
- @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
555
- @stdout.puts "--- current (on-disk) ---"
556
- @stdout.puts File.exist?(plugin_path) ? File.read(plugin_path) : "(missing)"
557
- @stdout.puts "--- metadata: sha256=#{record.plugin.sha256}#{" requires_chi=#{record.requires_chi}" if record.requires_chi}"
558
- end
559
583
  if file_arg
560
584
  # Try file first, then hook
561
585
  if record.owns?(file_arg)
562
- base = prov.base_path(file_arg)
563
- cur = File.join(target_dir, file_arg)
564
- @stdout.puts "=== #{file_arg} ==="
565
- @stdout.puts "--- base (provenance) ---"
566
- @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
567
- @stdout.puts "--- current (on-disk) ---"
568
- @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
586
+ diff_memory(prov, target_dir, file_arg)
569
587
  elsif hooks.key?(file_arg)
570
- base = prov.base_path(file_arg)
571
- cur = File.join(prov.hooks_dir, file_arg)
572
- @stdout.puts "=== hooks/#{file_arg} ==="
573
- @stdout.puts "--- base (provenance) ---"
574
- @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
575
- @stdout.puts "--- current (on-disk) ---"
576
- @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
577
- # Show hook metadata
578
- @stdout.puts hook_metadata_line(hooks[file_arg])
588
+ diff_hook(prov, file_arg, hooks[file_arg])
579
589
  elsif plugin_file && [plugin_file, "plugin/#{plugin_file}"].include?(file_arg)
580
- show_plugin.call
590
+ diff_plugin(prov, record, plugin_path)
581
591
  else
582
592
  @stdout.puts "=== #{file_arg} ==="
583
593
  @stdout.puts "(not found in bundle)"
584
594
  end
585
595
  @stdout.puts ""
586
596
  else
587
- files.each_key do |k|
588
- base = prov.base_path(k)
589
- cur = File.join(target_dir, k)
590
- @stdout.puts "=== #{k} ==="
591
- @stdout.puts "--- base (provenance) ---"
592
- @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
593
- @stdout.puts "--- current (on-disk) ---"
594
- @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
597
+ record.files.each_key do |key|
598
+ diff_memory(prov, target_dir, key)
595
599
  @stdout.puts ""
596
600
  end
597
- # Hooks diff
598
- hooks.each do |k, hook|
599
- base = prov.base_path(k)
600
- cur = File.join(prov.hooks_dir, k)
601
- @stdout.puts "=== hooks/#{k} ==="
602
- @stdout.puts "--- base (provenance) ---"
603
- @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
604
- @stdout.puts "--- current (on-disk) ---"
605
- @stdout.puts File.exist?(cur) ? File.read(cur) : "(missing)"
606
- @stdout.puts hook_metadata_line(hook)
601
+ hooks.each do |key, hook|
602
+ diff_hook(prov, key, hook)
607
603
  @stdout.puts ""
608
604
  end
609
605
  if plugin_file
610
- show_plugin.call
606
+ diff_plugin(prov, record, plugin_path)
611
607
  @stdout.puts ""
612
608
  end
613
609
  end
614
610
  0
615
611
  end
616
612
 
613
+ def diff_memory(prov, target_dir, key)
614
+ print_base_and_current(key, prov.base_path(key), File.join(target_dir, key))
615
+ end
616
+
617
+ def diff_hook(prov, key, hook)
618
+ print_base_and_current("hooks/#{key}", prov.base_path(key), File.join(prov.hooks_dir, key))
619
+ @stdout.puts hook_metadata_line(hook)
620
+ end
621
+
622
+ def diff_plugin(prov, record, plugin_path)
623
+ file = File.basename(plugin_path)
624
+ print_base_and_current("plugin/#{file}", prov.plugin_base_path(file), plugin_path)
625
+ @stdout.puts "--- metadata: sha256=#{record.plugin.sha256}#{" requires_chi=#{record.requires_chi}" if record.requires_chi}"
626
+ end
627
+
628
+ def print_base_and_current(heading, base, current)
629
+ @stdout.puts "=== #{heading} ==="
630
+ @stdout.puts "--- base (provenance) ---"
631
+ @stdout.puts File.exist?(base) ? File.read(base) : "(no base)"
632
+ @stdout.puts "--- current (on-disk) ---"
633
+ @stdout.puts File.exist?(current) ? File.read(current) : "(missing)"
634
+ end
635
+
617
636
  def list
618
637
  listing = Samagotchi::MemoryBundle::Listing
619
638
  shipped = listing.shipped
@@ -634,6 +653,10 @@ module Samagotchi
634
653
  line = " #{b.name.ljust(name_w)} #{"v#{b.version || "?"}".ljust(ver_w)} scope=#{b.scope || "?"} " \
635
654
  "#{b.includes ? profile_members(b.includes, installed) : "files=#{b.files}"} installed=#{b.installed_at || "?"}"
636
655
  line += " (shipped v#{b.upgrade.version}: chi bundle upgrade #{b.upgrade.source})" if b.upgrade
656
+ if b.behind&.any?
657
+ count = Samagotchi::MemoryBundle::FileStanding.behind_count(b.behind.size, unsure: b.behind.any?(&:unsure))
658
+ line += " (#{count}: chi bundle status #{b.name})"
659
+ end
637
660
  @stdout.puts line
638
661
  end
639
662
  @stdout.puts " (or all at once: chi update)" if installed.any?(&:upgrade)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # Bundler's variables, in one place. Every chi spawn that must not carry a
5
+ # `bundle exec` around it takes its names from here.
6
+ module BundlerEnv
7
+ # All Bundler's own and its settings (BUNDLE_PATH, BUNDLE_WITHOUT, ...), for
8
+ # what must take none: a pattern's start (ChiShim, ProjectEnv::RESTORE_NEVER).
9
+ PREFIX = "BUNDLE"
10
+ # What `bundle exec` sets for a child (Bundler::SharedHelpers#set_bundle_variables;
11
+ # bundler_env_spec asks it). Without them, and without Bundler's RUBYOPT, a
12
+ # child ruby is free of the bundle; BUNDLER_SETUP on its own isn't: RubyGems
13
+ # requires what it names at boot, and a Gemfile it then finds writes its lock
14
+ # to BUNDLE_LOCKFILE, the bundle's. A project's own settings (BUNDLE_PATH and
15
+ # the like) aren't here: its `bundle` keeps them. Nor is BUNDLER_ORIG_*, which
16
+ # also reaches a tool: a project's `bundle exec` there reads it, for
17
+ # Bundler.original_env and with_unbundled_env, whose children get the PATH and
18
+ # GEM_* chi's own `bundle exec` started from.
19
+ VARS = %w[BUNDLE_BIN_PATH BUNDLE_GEMFILE BUNDLE_LOCKFILE BUNDLER_SETUP BUNDLER_VERSION].freeze
20
+ # With what else `bundle exec` steers a child ruby by: what a command that
21
+ # isn't the bundle's runs without (TaskRuntime, ProjectEnv, ChiHome.probe).
22
+ CHILD_RUBY = (%w[RUBYOPT RUBYLIB] + VARS).freeze
23
+ end
24
+ end