samagotchi 0.151.0 → 0.152.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/bundle-authoring.md +4 -4
  4. data/docs/cli.md +32 -7
  5. data/docs/guardrails.md +4 -0
  6. data/docs/internals/block-renderers.md +3 -1
  7. data/docs/internals/bundles.md +48 -14
  8. data/docs/internals/objectives.md +6 -4
  9. data/docs/memory.md +6 -1
  10. data/docs/plugins.md +51 -14
  11. data/docs/releasing.md +7 -2
  12. data/docs/sessions.md +1 -1
  13. data/lib/samagotchi/block_renderers.rb +9 -8
  14. data/lib/samagotchi/bundle_command/check.rb +43 -3
  15. data/lib/samagotchi/bundle_command/installed_check.rb +6 -2
  16. data/lib/samagotchi/bundle_command/try/sandbox.rb +11 -3
  17. data/lib/samagotchi/bundle_command.rb +88 -10
  18. data/lib/samagotchi/bundle_needs.rb +4 -6
  19. data/lib/samagotchi/bundle_settings_check.rb +14 -13
  20. data/lib/samagotchi/bundles/agents-md/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/memory_guide.md +3 -3
  28. data/lib/samagotchi/child_env.rb +15 -0
  29. data/lib/samagotchi/context_providers.rb +42 -10
  30. data/lib/samagotchi/engine.rb +27 -4
  31. data/lib/samagotchi/extension_load.rb +4 -3
  32. data/lib/samagotchi/guardrail_wiring.rb +28 -19
  33. data/lib/samagotchi/guardrails/approval.rb +2 -2
  34. data/lib/samagotchi/guardrails/rules.rb +1 -0
  35. data/lib/samagotchi/guardrails/verdict.rb +20 -0
  36. data/lib/samagotchi/hooks/bundle_loader.rb +5 -7
  37. data/lib/samagotchi/memory_bundle/asset_installer.rb +11 -8
  38. data/lib/samagotchi/memory_bundle/builder.rb +36 -30
  39. data/lib/samagotchi/memory_bundle/bundle_hook.rb +3 -2
  40. data/lib/samagotchi/memory_bundle/file_digest.rb +31 -0
  41. data/lib/samagotchi/memory_bundle/file_standing.rb +2 -2
  42. data/lib/samagotchi/memory_bundle/installed_bundle.rb +55 -46
  43. data/lib/samagotchi/memory_bundle/installed_copy.rb +38 -15
  44. data/lib/samagotchi/memory_bundle/installed_manifest.rb +224 -0
  45. data/lib/samagotchi/memory_bundle/installer.rb +90 -16
  46. data/lib/samagotchi/memory_bundle/manifest.rb +146 -6
  47. data/lib/samagotchi/memory_bundle/manifest_standing.rb +171 -0
  48. data/lib/samagotchi/memory_bundle/merger.rb +7 -8
  49. data/lib/samagotchi/memory_bundle/profile.rb +3 -2
  50. data/lib/samagotchi/memory_bundle/provenance.rb +217 -18
  51. data/lib/samagotchi/memory_bundle/shas.rb +4 -3
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +52 -24
  53. data/lib/samagotchi/memory_bundle/status.rb +11 -10
  54. data/lib/samagotchi/memory_bundle/system_bundle.rb +13 -2
  55. data/lib/samagotchi/plugin/loader.rb +1 -1
  56. data/lib/samagotchi/session_config.rb +5 -4
  57. data/lib/samagotchi/system_one/record.rb +10 -2
  58. data/lib/samagotchi/tools/delegate.rb +20 -2
  59. data/lib/samagotchi/update_command.rb +13 -2
  60. data/lib/samagotchi/version.rb +1 -1
  61. data/lib/samagotchi/web/app.rb +2 -1
  62. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  63. data/lib/samagotchi/web/session_hub.rb +6 -0
  64. metadata +4 -1
data/docs/releasing.md CHANGED
@@ -21,7 +21,9 @@ can run the whole release; the user approves the notes before the tag and the
21
21
  - a bundle that uses something new in chi marks it in the same change with
22
22
  `requires_chi_next: true` (or a short reason) in its `manifest.yml`, and
23
23
  leaves `requires_chi` alone: raising it to a chi that isn't released yet
24
- would make chi on main refuse its own bundle. `release:bump` raises a
24
+ would make chi on main refuse its own bundle outright — an unmet
25
+ `requires_chi` now stops the install, not just the load (`--force` is the
26
+ only way past it). `release:bump` raises a
25
27
  marked bundle's `requires_chi` to `">= X.Y.Z"` and drops the marker;
26
28
  `release:check` fails while a marker is left. chi itself ignores the key,
27
29
  and `bundles:check` notes it (and fails a marker that isn't `true` or a
@@ -30,6 +32,9 @@ can run the whole release; the user approves the notes before the tag and the
30
32
  - a new `ctx.*` / `chi.*` call a plugin may make gets its release's version in `Plugin::Api::SINCE` once
31
33
  released (`chi bundle check`'s `requires-chi-floor` reads it); an unreleased one waits there as an `# Unreleased` comment, and
32
34
  `release:check` fails while that comment is left: move each call up with the new version;
35
+ - a new manifest key or shape gets its release's version in `Manifest::SINCE` the same way (check's
36
+ `requires-chi-floor` reads it for the manifest too), waits as an `# Unreleased` comment while unreleased,
37
+ and `release:check` fails while that comment is left;
33
38
  - a new shipped bundle joins a profile (`includes:` in `core` or `dev`; a
34
39
  spec checks every bundle is in exactly one), and that profile's version
35
40
  goes up: `chi update` then installs only the new bundle for those who
@@ -72,7 +77,7 @@ as the GitHub release body.
72
77
  | `rake gem:validate`, `gem:build`, `gem:install`, `gem:clean` | Check the gemspec; build the gem into `pkg/`; build and install it locally; remove the built gems. |
73
78
  | `rake release:draft_changelog` | A draft Unreleased section from the commit subjects since the last tag, grouped. Stdout only. |
74
79
  | `rake release:bump[X.Y.Z]` | VERSION, the system manifest, Gemfile.lock, CHANGELOG, and `requires_chi: ">= X.Y.Z"` for each bundle marked `requires_chi_next` (the marker dropped), then a profile left below a member's `requires_chi` raised to it, with a patch bump of its version (none when it moved since the last tag) and named in the CHANGELOG's "Update with `chi update`" line. No commit. |
75
- | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, no `requires_chi_next` left, no `# Unreleased` comment left in `Plugin::Api::SINCE`, `bundles:check`, `rake lint` (RuboCop) + `npm run lint` (the web frontend's ESLint), rspec (both roots: `spec` and the bundles' own `spec/`) + `rake bundles:specs` + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
80
+ | `rake release:check` | Clean tracked tree, VERSION == system bundle, a CHANGELOG section, no `requires_chi_next` left, no `# Unreleased` comment left in `Plugin::Api::SINCE` or `Manifest::SINCE`, `bundles:check`, `rake lint` (RuboCop) + `npm run lint` (the web frontend's ESLint), rspec (both roots: `spec` and the bundles' own `spec/`) + `rake bundles:specs` + npm test, `gem build`, then a clean install into a temp GEM_HOME that runs `chi --version`, `chi self` and `chi bundle list` with a temp HOME. Needs the network (gem dependencies). |
76
81
  | `rake release:notes[X.Y.Z]` | Prints the CHANGELOG section (default: the current VERSION). |
77
82
 
78
83
  ## Runbook
data/docs/sessions.md CHANGED
@@ -495,7 +495,7 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
495
495
  The `delegate` tool hands a task to a **child session**: an ordinary chi session in a worker of its own, started in the parent's `working_directory` with the task, verbatim, as its first user message, on the parent's model unless the call names one (`model:` takes a name or an alias, resolved as `--model` is; an unknown host comes back as the tool's `Error:` line; an id the host's saved model list doesn't have starts the child with a `Warning: host 'h' doesn't list model 'x' (did you mean: …?)` line in the result). Children run in parallel and only their **final reply** comes back to the parent: the child's newest `output/<timestamp>.txt` file, which the worker writes at the end of each turn that produced visible text, cut head-and-tail above 64 KB (`DelegateWait.cut`); a reply longer than `max_tool_output_chars` keeps its start and its end (the conclusion), with a gap line naming that `output/` file, where the whole reply is (`ToolOutputCut`'s `:head_tail_ref`; see [Tool output guardrails](internals/tool-output-limits.md#the-runners-cut-max_tool_output_chars)). Nothing of the child's trace enters the parent's context. Nothing is hidden: a child shows in `chi sessions list` (`↳ <parent>`), the web (folded into its parent's card on the strip and in all sessions, whose `▸ N delegates` chip lists the delegates at any depth, see Web UI above; a card of its own with a `↳` chip when its parent isn't listed; `↑ <parent id>` in its info bar, its tooltip `delegated by session …`; the parent's info bar carries a `⑂ N` chip counting its delegates, with how many run, wait, are done, failed, stopped or idle in the tooltip and the waiting ones named in the attention colour; a click opens all sessions), the terminal's status row (a parent's `⑂ 2 running · 1 waiting`, see [Status Line](cli.md#status-line)) and the `list_sessions` tool (`child`; the parent shows as `parent` in the child's own list); the user can `chi --attach <child id>` and steer it mid-run, and `chi send` and `send_note` reach it like any session.
496
496
 
497
497
  - `delegate(task, model:, session:, cwd:, wait:, timeout:)`: starts a child and, by default, waits for its reply. `wait: false` is the usual way for work that takes a while, several children side by side, or to keep talking with the user: it returns at once, the parent ends its turn or works on, and the reply comes back by itself as a *delegate report* (below), in a turn of its own if the parent is idle. The tool descriptions tell the model so, and to wait (`wait: true`, `delegate_result`) only when its turn can't go on without the reply. `timeout`, default 600 s, leaves a slow child running; 0 checks once without waiting. With `session:` (a child's id or prefix) the task goes to that child as a follow-up instead, delivered like `chi send` (client id `delegate:<parent short id>`, shown as `delegate>` in the child's UIs); only this session's children take one. The wait (`wait: true`, a later `delegate_result`) and the delegate report are for the reply of the child's turn the follow-up went to: its running turn, which it merges into, or the turn of its own it runs when the child is idle (`turn` in `delegates.json`, set when the child's worker names `turn_ids`), not a newer reply of a turn queued behind it. A follow-up merged as a steer into a turn the child started at that moment (a wake, a reminder, another message) gets that turn's reply or question. A child on its first turn, or with an older worker, gives its next reply, as before.
498
- - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's. With no `cwd:` and a task that names another checkout of the same repository (a path in the task belonging to a linked worktree, read from the files with `Guardrails::GitInfo.checkouts`, no git process), the result carries `Warning: this child runs in <folder>, but its task names <checkout>. If it should work there, stop it (chi sessions stop <id>) and call delegate again without session, with cwd: <checkout>.` One line however many are named; nothing for a path of the child's own checkout, a folder that isn't one, or a path with spaces. The child starts as it would anyway, and with `wait: true` (the default) the line comes after the child ran, so it only helps with `wait: false`. A `Warning:` for an unknown model id comes first, then this one. `Log.info(:worker, "delegate_other_checkout", parent:)` records each line.
498
+ - `cwd:` starts the child in another folder of the parent's repository: a linked worktree (`git worktree add ../<repo>-<task> -b <branch>`, which the model runs with `execute` first, under guardrails; chi runs no git here) or a subfolder; a relative path is taken from the parent's folder. The folder must exist, have the parent's project root (the same common git dir, `MemoryPaths.project_root`) and be in a work tree: a git dir (`.git`, `.git/objects`, a bare layout's `.bare`) or a bare layout's container is refused (`git rev-parse --is-inside-work-tree`, read-only, decides). Outside git only a subfolder of the parent's folder is allowed. Otherwise the tool returns `Error: cwd must be a folder of this session's repository …` or `… is not in a work tree …`. A follow-up (`session:`) refuses `cwd:`: the child keeps its folder. The child's guardrails measure from its own worktree, and project memories, `list_sessions` and the web's project are the same as the parent's. With no `cwd:` and a task that names another checkout of the same repository (a path in the task belonging to a linked worktree, read from the files with `Guardrails::GitInfo.checkouts`, no git process), the result carries `Warning: this child runs in <folder>, but its task names <checkout>. If it should work there, stop it (chi sessions stop <id>) and call delegate again without session, with cwd: <checkout>.` One line however many are named; nothing for a path of the child's own checkout, a folder that isn't one, a path with spaces, or the main checkout when the child runs in a linked worktree of it (the work belongs in the worktree, so naming a main-checkout file is not a forgotten `cwd:`). The child starts as it would anyway, and with `wait: true` (the default) the line comes after the child ran, so it only helps with `wait: false`. A `Warning:` for an unknown model id comes first, then this one. `Log.info(:worker, "delegate_other_checkout", parent:)` records each line.
499
499
  - `delegate_result(session:, timeout:)`: waits for a child's next reply: the one named, or the newest running child (`timeout` as for `delegate`: 0 checks once and returns at once). The reply comes back once; a later call waits for the next one. After `wait: false` it isn't needed: the report brings the reply.
500
500
  - A wait also ends early when a person's message waits for the parent's turn's next step (the `task_wait` rule: `Engine#input_waiting?`, `SessionInbox.steer_waiting?`, never in a participant's turn): `ReplyWait`'s `message_waiting:` probe gives `:message_waiting`, checked after every outcome the child can arrive at (a reply, a question, an end, a gone worker) and a cancel, before the deadline, and the result says `no reply yet: a message from the user is waiting; the child keeps running. Read the message first.` with the same last line as a timeout's. The child is never touched, the cursor doesn't move (the reply still comes, as a report or at the next `delegate_result`), a relayed approval's outcome line stays in the result, and a reserved report reads as for a timeout. A delegate child's report doesn't end a wait. This holds for `delegate`'s own wait too (both go through `DelegateWait`).
501
501
  - A wait that timed out or was canceled still gets its delegate report: neither moves the parent's cursor, so the child's reply comes as a report when its turn ends. The result says so (`chi brings its reply here by itself as a delegate report …`; with `queue`, that it joins the next step or turn) and that `delegate_result` waits again only if the turn needs the reply now. A parent that gets no reports (`session.delegate_reports: off`, a `--no-shared` REPL, `--non-interactive`) is told `delegate_result <id> waits again` instead, and `wait: false`'s result says `delegate_result waits for its reply`.
@@ -76,10 +76,10 @@ module Samagotchi
76
76
  def for_installed
77
77
  taken = {}
78
78
  MemoryBundle::Provenance.each_installed do |bundle, record|
79
- next unless record.block_renderers?
80
- next if MemoryBundle::Manifest.requires_chi_failure(record.requires_chi, VERSION)
79
+ manifest = record.manifest
80
+ next if manifest.requires_chi_failure(VERSION)
81
81
 
82
- renderers(bundle, record).each do |renderer|
82
+ renderers(bundle, manifest).each do |renderer|
83
83
  taken[renderer.lang] ||= Installed.new(lang: renderer.lang, bundle: bundle, files: renderer.load)
84
84
  end
85
85
  end
@@ -104,11 +104,12 @@ module Samagotchi
104
104
  raise Invalid, "bundle #{bundle}'s scripts/#{file} differs from the installed one (reinstall the bundle)"
105
105
  end
106
106
 
107
- def renderers(bundle, record)
108
- parse_list(record.block_renderers)
109
- rescue ArgumentError => e # Invalid is one; so are a record's bytes that aren't UTF-8
110
- Log.warn(:web, "block_renderers_unreadable", bundle: bundle, msg: e.message)
111
- []
107
+ # Its parsed renderers; a list that doesn't parse is none (logged).
108
+ def renderers(bundle, manifest)
109
+ if (problem = manifest.problems["block_renderers"])
110
+ Log.warn(:web, "block_renderers_unreadable", bundle: bundle, msg: problem)
111
+ end
112
+ manifest.block_renderers
112
113
  end
113
114
  private_class_method :renderers
114
115
  end
@@ -84,6 +84,7 @@ module Samagotchi
84
84
  if manifest
85
85
  missing = check_files(manifest)
86
86
  check_source_tree(manifest, missing) unless @installed
87
+ check_keys(manifest)
87
88
  check_requires_chi(manifest)
88
89
  check_trust(manifest) unless @installed
89
90
  check_clash(manifest)
@@ -147,6 +148,44 @@ module Samagotchi
147
148
  index && (index + 1)
148
149
  end
149
150
 
151
+ # ── the manifest's own keys ───────────────────────────────────────
152
+
153
+ # A key this chi's parser never reads is one it ignores, and a key whose
154
+ # floor is above what requires_chi admits is one an older chi would
155
+ # ignore too. The floor is the author's fix, so an installed bundle gets
156
+ # only the unknown-key line, as check_floor's call floor does today.
157
+ def check_keys(manifest)
158
+ manifest.unknown_keys.each do |key|
159
+ add(:warn, :"unknown-key", "manifest.yml", MemoryBundle::Manifest.unknown_key_line(key), line: manifest_line(key),
160
+ fix: "update chi, or remove #{key}: if it's a typo")
161
+ end
162
+ check_key_floor(manifest) unless @installed
163
+ end
164
+
165
+ # The manifest uses a key the oldest chi requires_chi admits never read.
166
+ # No plugin needed: the key is the bundle's own claim.
167
+ def check_key_floor(manifest)
168
+ key, version = manifest.floor_keys.max_by { |_, floor| Gem::Version.new(floor) }
169
+ return if key.nil?
170
+
171
+ bound = lower_bound(manifest.requires_chi)
172
+ return if bound && Gem::Version.new(version) <= bound
173
+
174
+ add(:warn, :"requires-chi-floor", "manifest.yml",
175
+ "#{manifest.requires_chi ? "requires_chi #{manifest.requires_chi.inspect} admits chi #{bound}," : "no requires_chi,"} " \
176
+ "but the manifest uses #{key_label(key)}, which needs chi #{version}",
177
+ line: manifest_line(key.split(".").first), fix: "requires_chi: \">= #{version}\"")
178
+ end
179
+
180
+ # How to name a SINCE key in a message: a shape is not a key on its own.
181
+ def key_label(key)
182
+ case key
183
+ when "files.description" then "a files: entry given as a mapping"
184
+ when "session_settings.types", "settings.types" then "#{key.split(".").first}: as a mapping of keys to types"
185
+ else "#{key}:"
186
+ end
187
+ end
188
+
150
189
  # ── files ─────────────────────────────────────────────────────────
151
190
 
152
191
  # @return [Boolean] whether a listed file is missing
@@ -340,15 +379,16 @@ module Samagotchi
340
379
  return if manifest.settings.nil? || @settings.empty?
341
380
 
342
381
  require_relative "../bundle_settings_check"
343
- record = MemoryBundle::InstalledBundle.new(name: manifest.name, settings: manifest.settings, setting_types: manifest.setting_types,
344
- session_settings: manifest.session_settings)
345
- BundleSettingsCheck.record_warnings(record, @settings).each { |line| add(:warn, :settings, "manifest.yml", line, line: manifest_line("settings")) }
382
+ BundleSettingsCheck.record_warnings(manifest, @settings).each { |line| add(:warn, :settings, "manifest.yml", line, line: manifest_line("settings")) }
346
383
  end
347
384
 
348
385
  def install_warnings(warnings)
349
386
  stale_reported = @findings.any? { |f| f.code == :sha }
350
387
  warnings.each do |warning|
351
388
  next if stale_reported && warning.start_with?("Checksum mismatch")
389
+ # An unknown manifest key is check_keys' row, with the fix the install
390
+ # can't offer; the same condition twice would be two rows.
391
+ next if warning.include?("doesn't know the manifest key")
352
392
  next if @too_new && warning.include?("won't load:")
353
393
  # This machine's PATH, said at install; nothing a chi update changed.
354
394
  next if @installed && warning.start_with?("needs ")
@@ -58,13 +58,17 @@ module Samagotchi
58
58
  private_class_method :in_child
59
59
 
60
60
  # What the child sends: an edited file, which the copy's own shas can't
61
- # tell, and Check's warnings and errors, naming the installed bundle's
62
- # folder rather than the copy's.
61
+ # tell, a manifest key that doesn't parse (the copy holds the mirror's
62
+ # value or none: InstalledManifest#key_problems), and Check's warnings
63
+ # and errors, naming the installed bundle's folder rather than the copy's.
63
64
  def answer_of(bundle)
64
65
  edited = MemoryBundle::ShippedUpdate.edited_executables(bundle.name, bundle).map do |file|
65
66
  Check::Finding.new(level: :warn, code: :edited, file: file, line: nil, fix: "chi bundle upgrade #{bundle.name}, or reinstall it",
66
67
  message: "changed since it was installed, so chi doesn't load it")
67
68
  end
69
+ edited += bundle.manifest.key_problems.values.map do |message|
70
+ Check::Finding.new(level: :error, code: :manifest, file: "manifest.yml", line: nil, message: message, fix: nil)
71
+ end
68
72
  edited = edited.map { |finding| { level: finding.level, text: finding.to_s } }
69
73
  installed_dir = MemoryBundle::Provenance.new(name: bundle.name).bundle_dir
70
74
  edited + MemoryBundle::InstalledCopy.with(bundle) do |dir|
@@ -101,6 +101,9 @@ module Samagotchi
101
101
  raise
102
102
  end
103
103
 
104
+ # The environment to spawn a child with: what the sandbox keeps, with
105
+ # every key it dropped present as nil (ChildEnv.spawn_env), so the
106
+ # child doesn't inherit it from this process.
104
107
  def child_env = @child_env.dup
105
108
 
106
109
  # Installs the tried bundle, then the shipped guardrails and
@@ -219,7 +222,12 @@ module Samagotchi
219
222
  # The user's environment (their hosts and keys) minus what no child
220
223
  # chi takes from its spawner (ChildEnv), the worker markers, this
221
224
  # chi's log and test settings, and the env forms of the config
222
- # sections the sandbox drops.
225
+ # sections the sandbox drops. ChildEnv.spawn_env makes every dropped
226
+ # key an unset in the child: a Hash handed to Open3 or Process.spawn
227
+ # is merged onto this process's environment, so deleting a key from
228
+ # it alone still passes it on. The Bundler changes come from ENV, not
229
+ # from this copy: read from the copy, a key dropped above would look
230
+ # like one Bundler added and come back with its value.
223
231
  def build_child_env
224
232
  env = ChildEnv.scrub(ENV.to_h)
225
233
  env.delete("SAMAGOTCHI_LOG_FILE")
@@ -235,8 +243,8 @@ module Samagotchi
235
243
  "SAMAGOTCHI_KEYCHAIN" => "on",
236
244
  **@layout.env
237
245
  )
238
- env.merge!(ChildEnv.unbundled(env))
239
- env
246
+ env.merge!(ChildEnv.unbundled)
247
+ ChildEnv.spawn_env(env)
240
248
  end
241
249
  end
242
250
  end
@@ -16,11 +16,14 @@ module Samagotchi
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
18
  --force overwrites existing entries (one that differs is copied to the trash first); default skips them.
19
+ A bundle whose requires_chi this chi doesn't meet is refused; --force installs it anyway.
19
20
 
20
- upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
21
+ upgrade <source|name> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
21
22
  Upgrade a bundle (3-way: a file you didn't edit updates; one you edited takes a text
22
23
  merge, the old file in the bundle trash; a real conflict leaves it as it is).
24
+ A bare NAME upgrades the bundle installed under that name from the source it came from.
23
25
  --dry-run shows what would change. --agent launches interactive session on conflict.
26
+ A bundle whose requires_chi this chi doesn't meet is refused; --force installs it anyway.
24
27
 
25
28
  uninstall <bundle> [--scope system|project] [--force]
26
29
  Remove a bundle and its index entries (--force moves a locally edited file to the trash).
@@ -69,13 +72,19 @@ module Samagotchi
69
72
  Install a memory bundle from a directory, zip, tar archive, or git URL,
70
73
  or a bundle shipped with chi by name (see: chi bundle list).
71
74
  --force overwrites existing entries (one that differs is copied to the trash first); default skips them.
75
+ It also installs a bundle whose requires_chi this chi doesn't meet: without --force that install is
76
+ refused, and the bundle is not written anywhere.
72
77
  install --dev PATH
73
78
  Install the bundle in the local directory PATH as a link to it: its plugin, hooks, rules and scripts
74
79
  are PATH's own files (an edit applies at the next worker or chi web restart, with no re-install) and
75
80
  their sha256 isn't checked, for this bundle only. Its memories are copied; re-install with --force to
76
81
  pick up memory edits. Upgrade refuses it; uninstall removes the link, never PATH's files.
77
82
  TEXT
78
- UPGRADE_HELP = "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
83
+ UPGRADE_HELP = <<~TEXT
84
+ Usage: chi bundle upgrade <source|name> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]
85
+ --force takes the bundle's file over an edited one, and installs a bundle whose requires_chi this chi
86
+ doesn't meet (without it such a bundle is refused).
87
+ TEXT
79
88
  UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
80
89
  BUILD_HELP = <<~TEXT
81
90
  Usage: chi bundle build [--scope system|project] [--name NAME] [--version VER] [--description DESC] [--out PATH] [FILES...]
@@ -210,7 +219,13 @@ module Samagotchi
210
219
  @stderr.puts UPGRADE_HELP
211
220
  return CLI::Exit::USAGE
212
221
  end
213
- expanded_source = expand_source(source)
222
+ expanded_source, record = upgrade_source(source)
223
+ return 1 if expanded_source.nil?
224
+
225
+ # The record's source: said once, so it is clear which bundle and where
226
+ # it comes from when the name alone was given.
227
+ @stdout.puts "Upgrading #{record.name} from #{expanded_source}" if record
228
+
214
229
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
215
230
  return install_profile(expanded_source, scope: scope, force: force, dry_run: dry_run, word: "Upgrade")
216
231
  end
@@ -305,7 +320,11 @@ module Samagotchi
305
320
 
306
321
  def print_hooks_and_plugin(manifest, plugin: true)
307
322
  @stdout.puts "Hooks: #{manifest.hooks.size} hook(s) (#{manifest.hooks.keys.join(", ")})" if manifest&.hooks&.any?
308
- @stdout.puts "Plugin: #{manifest.plugin[:file]} (loads at the next chi start)" if plugin && manifest&.plugin
323
+ return unless plugin && manifest&.plugin
324
+
325
+ # Installed with --force past its requires_chi: the Engine won't load it.
326
+ failure = Samagotchi::MemoryBundle::Manifest.requires_chi_failure(manifest.requires_chi, Samagotchi::VERSION)
327
+ @stdout.puts "Plugin: #{manifest.plugin[:file]} (#{failure ? "won't load on this chi: #{failure}" : "loads at the next chi start"})"
309
328
  end
310
329
 
311
330
  # @return [Integer] 0
@@ -499,9 +518,11 @@ module Samagotchi
499
518
  label += "; #{info[:merge_note].line}#{" (old copy in #{info[:merge_note].trash})" if info[:merge_note].trash}" if info[:merge_note]
500
519
  @stdout.puts " #{k}: #{label}"
501
520
  end
521
+ @stdout.puts " #{manifest_line(st.provenance)}"
502
522
  # Hooks
503
- hooks = st.provenance.hooks
504
- trust = st.provenance.trust_level || "experimental"
523
+ manifest = st.provenance.manifest
524
+ hooks = manifest.hooks
525
+ trust = manifest.trust_level || "experimental"
505
526
  commit = st.provenance.source_commit
506
527
  @stdout.puts " trust_level: #{trust}" if hooks.any? || trust
507
528
  @stdout.puts " source_commit: #{commit}" if commit
@@ -512,7 +533,7 @@ module Samagotchi
512
533
  exists = File.exist?(hook_path) ? "ok" : "missing"
513
534
  @stdout.puts " #{hk}: #{hook.events_field} on_error=#{hook.on_error} priority=#{hook.priority} [#{exists}]"
514
535
  end
515
- @stdout.puts " requires_chi: #{st.provenance.requires_chi}" if st.hooks_requires_failure
536
+ @stdout.puts " requires_chi: #{manifest.requires_chi}" if st.hooks_requires_failure
516
537
  @stdout.puts " not loaded: #{st.hooks_requires_failure}" if st.hooks_requires_failure
517
538
  end
518
539
  if (plugin = st.plugin)
@@ -534,7 +555,7 @@ module Samagotchi
534
555
 
535
556
  st = Samagotchi::MemoryBundle::Status.bundle_status(bname)
536
557
  mods = st.files.values.count { |v| v[:behind] || v[:missing] || v[:unreadable] }
537
- hooks_count = st.provenance.hooks.size
558
+ hooks_count = st.provenance.manifest.hooks.size
538
559
  hook_info = hooks_count > 0 ? " hooks=#{hooks_count}" : ""
539
560
  plugin = st.plugin
540
561
  mods += 1 if plugin && (plugin[:state] != "ok" || plugin[:requires_failure])
@@ -549,6 +570,14 @@ module Samagotchi
549
570
  0
550
571
  end
551
572
 
573
+ # "Manifest: [kept]", or "[from the install record]", and what its case
574
+ # says (MemoryBundle::ManifestStanding).
575
+ def manifest_line(record)
576
+ require_relative "memory_bundle/manifest_standing"
577
+ standing = Samagotchi::MemoryBundle::ManifestStanding
578
+ ["Manifest:", standing.label(record), standing.status_line(record)].compact.join(" ")
579
+ end
580
+
552
581
  # " dev → /path (missing)" when a dev install's source isn't there, else "".
553
582
  def dev_label(record)
554
583
  return "" unless record.dev
@@ -577,7 +606,7 @@ module Samagotchi
577
606
  rescue ArgumentError => e
578
607
  @stderr.puts "Bundle '#{bname}': #{e.message}"; return 1
579
608
  end
580
- hooks = record.hooks
609
+ hooks = record.manifest.hooks
581
610
  plugin_path = prov.plugin_path(record)
582
611
  plugin_file = plugin_path && File.basename(plugin_path)
583
612
  if file_arg
@@ -612,6 +641,26 @@ module Samagotchi
612
641
 
613
642
  def diff_memory(prov, target_dir, key)
614
643
  print_base_and_current(key, prov.base_path(key), File.join(target_dir, key))
644
+ print_incoming(prov, key)
645
+ end
646
+
647
+ # The bundle's text a file stays behind (Provenance#incoming_path), so the
648
+ # sections the "stays behind" line names can be read. No saved copy: nothing.
649
+ def print_incoming(prov, key)
650
+ path = prov.incoming_path(key)
651
+ return unless File.exist?(path)
652
+
653
+ text = begin
654
+ File.read(path)
655
+ rescue SystemCallError
656
+ nil
657
+ end
658
+ if text
659
+ @stdout.puts "--- incoming (the bundle's #{prov.record.version}, which this file stays behind) ---"
660
+ @stdout.puts text
661
+ else
662
+ @stdout.puts "--- incoming: the bundle's saved copy can't be read ---"
663
+ end
615
664
  end
616
665
 
617
666
  def diff_hook(prov, key, hook)
@@ -622,7 +671,8 @@ module Samagotchi
622
671
  def diff_plugin(prov, record, plugin_path)
623
672
  file = File.basename(plugin_path)
624
673
  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}"
674
+ requires_chi = record.manifest.requires_chi
675
+ @stdout.puts "--- metadata: sha256=#{record.plugin.sha256}#{" requires_chi=#{requires_chi}" if requires_chi}"
626
676
  end
627
677
 
628
678
  def print_base_and_current(heading, base, current)
@@ -926,6 +976,34 @@ module Samagotchi
926
976
  Samagotchi::MemoryBundle::SourceNormalizer.shipped_bundle_dir(src) || File.expand_path(src)
927
977
  end
928
978
 
979
+ # Where an upgrade comes from: the source as given — a git URL, an
980
+ # existing path, a shipped bundle's name, as install takes them — or,
981
+ # for the bare name of an installed bundle, the source its record keeps.
982
+ # A dev install is refused (it already follows its source), and a record
983
+ # whose source is relative, empty or gone says so. nil after such a
984
+ # refusal, written to stderr.
985
+ # @return [Array(String, InstalledBundle, nil), nil] [the source, the
986
+ # record that named it (nil for a source as given)]
987
+ def upgrade_source(source)
988
+ normalizer = Samagotchi::MemoryBundle::SourceNormalizer
989
+ given = normalizer.git_url?(source) || normalizer.shipped_bundle_dir(source) || File.exist?(File.expand_path(source))
990
+ return [expand_source(source), nil] if given
991
+
992
+ record = Samagotchi::MemoryBundle::Provenance.new(name: source.to_s.strip).record
993
+ return [expand_source(source), nil] if record.nil? || record.error?
994
+
995
+ if record.dev
996
+ @stderr.puts "Upgrade failed: #{Samagotchi::MemoryBundle::Installer.dev_install_line(record.name, record.source)}"
997
+ return nil
998
+ end
999
+
1000
+ recorded = record.source.to_s.strip
1001
+ return [recorded, record] if normalizer.git_url?(recorded) || (recorded.start_with?("/") && File.exist?(recorded))
1002
+
1003
+ @stderr.puts "Upgrade failed: #{Samagotchi::MemoryBundle::Installer.source_gone_line(record.name, recorded)}"
1004
+ nil
1005
+ end
1006
+
929
1007
  def build_conflict_prompt(bundle_name, conflicts, expanded_source)
930
1008
  lines = []
931
1009
  lines << "Memory bundle upgrade has conflicts that need interactive resolution."
@@ -66,13 +66,11 @@ module Samagotchi
66
66
  # missing need. A manifest.json or needs: that doesn't parse is skipped.
67
67
  def self.entry_markers(scope, path:, bundles_dir:)
68
68
  MemoryBundle::Provenance.each_installed(dir: bundles_dir).each_with_object({}) do |(_name, bundle), acc|
69
- next if bundle.error? || bundle.effective_scope != scope.to_s || !bundle.needs?
69
+ next if bundle.error? || bundle.effective_scope != scope.to_s
70
+
71
+ needs = bundle.manifest.needs
72
+ next if needs.empty?
70
73
 
71
- needs = begin
72
- MemoryBundle::Manifest.parse_needs(bundle.needs)
73
- rescue MemoryBundle::Manifest::ValidationError
74
- next
75
- end
76
74
  marker = marker(missing(needs, path: path))
77
75
  next unless marker
78
76
 
@@ -32,7 +32,7 @@ module Samagotchi
32
32
  next ["config.yml bundles: #{name}: #{SessionConfig.no_installed_bundle(name)}#{near(name, installed.keys)}"] unless bundle
33
33
  next [] if bundle.error?
34
34
 
35
- record_warnings(bundle, values)
35
+ record_warnings(bundle.manifest, values)
36
36
  end
37
37
  end
38
38
 
@@ -62,23 +62,24 @@ module Samagotchi
62
62
  lines
63
63
  end
64
64
 
65
- # One bundle's +values+ against its record (or a manifest as one); none
66
- # while it declares no settings:.
67
- # @param bundle [MemoryBundle::InstalledBundle]
65
+ # One bundle's +values+ against its manifest (an installed one's or a
66
+ # source's); none while it declares no settings:.
67
+ # @param manifest [MemoryBundle::InstalledManifest, MemoryBundle::Manifest]
68
+ # name, settings, session_settings, setting_types
68
69
  # @return [Array<String>]
69
- def record_warnings(bundle, values)
70
- return [] if bundle.settings.nil?
70
+ def record_warnings(manifest, values)
71
+ return [] if manifest.settings.nil?
71
72
 
72
- values.flat_map { |key, value| value_warnings(bundle, key.to_s, value) }
73
+ values.flat_map { |key, value| value_warnings(manifest, key.to_s, value) }
73
74
  end
74
75
 
75
- def value_warnings(bundle, key, value)
76
- where = "config.yml bundles: #{bundle.name}.#{key}"
77
- known = bundle.settings | bundle.session_settings
78
- return ["#{where}: #{SessionConfig.takes_keys(bundle.name, known)}#{near(key, known)}"] unless known.include?(key)
76
+ def value_warnings(manifest, key, value)
77
+ where = "config.yml bundles: #{manifest.name}.#{key}"
78
+ known = manifest.settings | manifest.session_settings
79
+ return ["#{where}: #{SessionConfig.takes_keys(manifest.name, known)}#{near(key, known)}"] unless known.include?(key)
79
80
 
80
- takes, what = SessionConfig::TYPES[bundle.setting_types[key]]
81
- takes && !takes.call(value) ? ["#{where}: #{SessionConfig.wrong_type(bundle.name, key, what, value)}"] : []
81
+ takes, what = SessionConfig::TYPES[manifest.setting_types[key]]
82
+ takes && !takes.call(value) ? ["#{where}: #{SessionConfig.wrong_type(manifest.name, key, what, value)}"] : []
82
83
  end
83
84
  private_class_method :value_warnings
84
85
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agents-md
3
- version: 0.1.1
3
+ version: 0.1.2
4
4
  scope: system
5
5
  description: "Steers a nested AGENTS.md into the conversation the first time a tool touches a file under its directory"
6
6
  trust_level: reviewed
@@ -8,6 +8,6 @@ plugin:
8
8
  file: plugin.rb
9
9
  sha256: sha256:56527b9d12cb9acd8f57ca00881ab159894ae2fb8def22bce66ac3774bb4187b
10
10
  # ctx.prompt_files, read when the plugin loads (0.60.0)
11
- requires_chi: ">= 0.60.0"
11
+ requires_chi: ">= 0.143.0"
12
12
  # config.yml bundles: agents-md: (another key warns; an older chi ignores this)
13
13
  settings: [tools, max_chars]
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: btw
3
- version: 0.1.3
3
+ version: 0.1.4
4
4
  scope: system
5
5
  description: "/btw <question>: a side answer about the conversation, as a card in every UI, even mid-turn; Keep as session forks it into a child session"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
9
  sha256: sha256:6497129b06d1c3061c1d7d9d7a9c3dfdbcf790e8f2049addad0c26d24771015f
10
- requires_chi: ">= 0.1.30"
10
+ requires_chi: ">= 0.143.0"
11
11
  # config.yml bundles: btw: (another key warns; an older chi ignores this)
12
12
  settings: [max_tokens, timeout]
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: loop-guard
3
- version: 0.3.9
3
+ version: 0.3.10
4
4
  scope: system
5
5
  description: "Breaks loops: the same tool call with the same result again and again in one turn is denied with advice (after a few denies the turn stops with a card), and thinking that repeats itself while it streams is cut and the model asked again"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
9
  sha256: sha256:a4ac7c48a903716b7e1c4fa35348439e90ac97d3ad421378ce7d3ac819229a09
10
- requires_chi: ">= 0.6.0"
10
+ requires_chi: ">= 0.143.0"
11
11
  # config.yml bundles: loop-guard: (another key warns; an older chi ignores this)
12
12
  settings: [deny_after, stop_after, ignore_tools, mode, thinking]
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.7.5
3
+ version: 0.7.6
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; their tools go into chi's tool index: the model searches them with find_tools and calls one with call_tool (<server>/<tool>); /mcp lists them"
6
6
  trust_level: reviewed
@@ -9,6 +9,6 @@ plugin:
9
9
  file: plugin.rb
10
10
  sha256: sha256:718e53db877eacd2714e69427c69cfc6c551aaf3417bb760a27dac3331363db5
11
11
  # 0.67.0 is the release with chi.tool_source (the core tool index the bundle publishes into).
12
- requires_chi: ">= 0.121.0"
12
+ requires_chi: ">= 0.143.0"
13
13
  # config.yml bundles: mcp: (another key warns; an older chi ignores this)
14
14
  settings: [servers, timeout, startup_timeout]
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: skills
3
- version: 0.1.8
3
+ version: 0.1.9
4
4
  scope: system
5
5
  description: "Skills (skill_<name> memories, the steps of a task done together): /skill save, list, show and diff; older versions kept with a short diff line on every update; a nudge when a followed skill's step fails"
6
6
  trust_level: reviewed
7
7
  plugin:
8
8
  file: plugin.rb
9
9
  sha256: sha256:781d943749d59ade0e24d04ba774449d31267eb8ca435835c36c67a3e523e632
10
- requires_chi: ">= 0.11.0"
10
+ requires_chi: ">= 0.143.0"
11
11
  # config.yml bundles: skills: (another key warns; an older chi ignores this)
12
12
  settings: [history_keep, nudge]
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: slow-commands
3
- version: 0.2.4
3
+ version: 0.2.5
4
4
  scope: system
5
5
  description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
6
6
  trust_level: reviewed
@@ -9,6 +9,6 @@ plugin:
9
9
  sha256: sha256:e51c099c1552bd0cd1fe46e4fd235a3fe8340693e6ca338eb7ed09d8398a6f0f
10
10
  # Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
11
11
  # call:, elapsed_ms: and result: (0.57.0)
12
- requires_chi: ">= 0.59.0"
12
+ requires_chi: ">= 0.143.0"
13
13
  # config.yml bundles: slow-commands: (another key warns; an older chi ignores this)
14
14
  settings: [mode, warn_ratio, ttl_days]
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.151.0
3
+ version: 0.152.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:e695f78f858c29ccfef2ed6f9d6dd86d0ec067ed704a5a1956c01e53617948a1
9
9
  config_modification_protocol.md: sha256:8896d1d364ce835040f69b504518d43dbdf959b93fdd9b10f87a8de61cae84a8
10
- memory_guide.md: sha256:bfe9f13e8a6c929056fe531c4c74c7e05d0f17f025191c1a111c8e7dfd224fd6
10
+ memory_guide.md: sha256:8b7727b939b9c04486ca59859561023dcc319a73e0f5b47b3fcf5741edaa69e2
11
11
  delegated.md: sha256:a0d6ddcd4a8be06526d964898611775fd251cfd9d145a9d16afaccaa503597bd
@@ -113,8 +113,8 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
113
113
 
114
114
  | Command | Purpose |
115
115
  |---------|---------|
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::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. |
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). It also installs a bundle whose `requires_chi` this chi doesn't meet: without `--force` that install is refused (`NAME can't be installed: it requires chi …`). 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\|name> [--force] [--dry-run] [--agent]` | A bare `name` is an installed bundle: chi upgrades it from the source its record keeps (its git URL with the `#ref` it recorded, its folder, or the archive it came from) — a dev install is refused, and a record whose source is gone says so and asks for the path or URL. Otherwise 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
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. |
@@ -146,7 +146,7 @@ chi bundle build --scope system --out updated.zip
146
146
 
147
147
  ### Provenance internals (for debugging)
148
148
 
149
- - Each installed bundle is recorded at `<system dir>/.bundles/<name>/manifest.json` + `bases/<file>.md` snapshots (`Provenance`). Used only for upgrade `Merger` and `status`/`diff`. Build does **not** write provenance.
149
+ - Each installed bundle is recorded at `<system dir>/.bundles/<name>/manifest.json` + `bases/<file>.md` snapshots (`Provenance`), with the bundle's own `manifest.yml` kept beside it (chi reads the manifest from there at load). Used only for upgrade `Merger` and `status`/`diff`. Build does **not** write provenance.
150
150
  - A bundle records only the files it wrote. `index.md` updates aren't fatal: a failed one is a warning in the install/uninstall output.
151
151
 
152
152
  ## Best practices for the agent