samagotchi 0.149.0 → 0.151.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/bundle-authoring.md +1 -1
  6. data/docs/cli.md +59 -24
  7. data/docs/configuration.md +6 -6
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/block-renderers.md +96 -0
  10. data/docs/internals/bridge.md +53 -9
  11. data/docs/internals/bundles.md +43 -14
  12. data/docs/internals/objectives.md +7 -2
  13. data/docs/internals/web-frontend.md +2 -2
  14. data/docs/internals/web-push.md +2 -2
  15. data/docs/internals/working-set.md +5 -2
  16. data/docs/memory.md +48 -10
  17. data/docs/plugins.md +49 -3
  18. data/docs/sessions.md +5 -4
  19. data/docs/testing.md +5 -0
  20. data/lib/samagotchi/atomic_file.rb +3 -0
  21. data/lib/samagotchi/block_renderers.rb +115 -0
  22. data/lib/samagotchi/bootstrap_command.rb +5 -2
  23. data/lib/samagotchi/bridge/card_places.rb +93 -0
  24. data/lib/samagotchi/bridge/card_store.rb +224 -82
  25. data/lib/samagotchi/bridge.rb +12 -4
  26. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  31. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  32. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  33. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  34. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  35. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  38. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  39. data/lib/samagotchi/chi_home.rb +4 -2
  40. data/lib/samagotchi/chi_shim.rb +2 -1
  41. data/lib/samagotchi/child_env.rb +10 -3
  42. data/lib/samagotchi/engine.rb +19 -1
  43. data/lib/samagotchi/git_env.rb +43 -0
  44. data/lib/samagotchi/guardrails/context.rb +2 -0
  45. data/lib/samagotchi/guardrails/targets.rb +4 -1
  46. data/lib/samagotchi/memory_bundle/asset_installer.rb +23 -3
  47. data/lib/samagotchi/memory_bundle/builder.rb +5 -3
  48. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  49. data/lib/samagotchi/memory_bundle/installed_bundle.rb +70 -6
  50. data/lib/samagotchi/memory_bundle/installed_copy.rb +1 -0
  51. data/lib/samagotchi/memory_bundle/installer.rb +162 -52
  52. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  53. data/lib/samagotchi/memory_bundle/manifest.rb +23 -2
  54. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  55. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  56. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  57. data/lib/samagotchi/memory_bundle/provenance.rb +182 -22
  58. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  59. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  60. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  61. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  62. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  63. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  64. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  65. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  66. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  67. data/lib/samagotchi/objectives_check.rb +37 -7
  68. data/lib/samagotchi/project_env.rb +3 -2
  69. data/lib/samagotchi/self_report.rb +2 -0
  70. data/lib/samagotchi/session_chain.rb +2 -7
  71. data/lib/samagotchi/session_commands.rb +15 -9
  72. data/lib/samagotchi/session_manager.rb +10 -7
  73. data/lib/samagotchi/steer.rb +17 -0
  74. data/lib/samagotchi/terminal_ui.rb +4 -4
  75. data/lib/samagotchi/testing/boundary.rb +1 -0
  76. data/lib/samagotchi/testing/minitest.rb +3 -0
  77. data/lib/samagotchi/testing/rspec.rb +3 -0
  78. data/lib/samagotchi/testing/sandbox.rb +3 -1
  79. data/lib/samagotchi/tools/delegate.rb +2 -1
  80. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  81. data/lib/samagotchi/turn_checkpoint.rb +11 -5
  82. data/lib/samagotchi/turn_flow.rb +2 -1
  83. data/lib/samagotchi/update_command.rb +76 -28
  84. data/lib/samagotchi/update_hint.rb +4 -1
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +22 -12
  87. data/lib/samagotchi/web/auto_update.rb +8 -8
  88. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  89. data/lib/samagotchi/web/block_renderer_routes.rb +32 -0
  90. data/lib/samagotchi/web/markdown_renderer.rb +39 -6
  91. data/lib/samagotchi/web/message_display.rb +3 -3
  92. data/lib/samagotchi/web/public/app.js +7 -3
  93. data/lib/samagotchi/web/public/block_frame.js +202 -0
  94. data/lib/samagotchi/web/public/block_host.js +502 -0
  95. data/lib/samagotchi/web/public/block_open.js +13 -0
  96. data/lib/samagotchi/web/public/block_renderers.css +20 -0
  97. data/lib/samagotchi/web/public/block_source.js +37 -0
  98. data/lib/samagotchi/web/public/bubbles.js +11 -5
  99. data/lib/samagotchi/web/public/card.js +17 -10
  100. data/lib/samagotchi/web/public/cards_place.js +10 -5
  101. data/lib/samagotchi/web/public/claim_link.js +4 -2
  102. data/lib/samagotchi/web/public/details_motion.js +2 -1
  103. data/lib/samagotchi/web/public/history_reveal.js +4 -1
  104. data/lib/samagotchi/web/public/index.html +20 -14
  105. data/lib/samagotchi/web/public/markdown_place.js +2 -3
  106. data/lib/samagotchi/web/public/stage_model.js +5 -6
  107. data/lib/samagotchi/web/public/stage_view.js +8 -7
  108. data/lib/samagotchi/web/public/text_roots.js +6 -0
  109. data/lib/samagotchi/web/public/turn_block.js +34 -19
  110. data/lib/samagotchi/web/public/turn_html.js +4 -1
  111. data/lib/samagotchi/web/public/turn_model.js +1 -1
  112. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  113. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  114. data/lib/samagotchi/web/push/notifier.rb +7 -9
  115. data/lib/samagotchi/web/session_hub.rb +2 -1
  116. data/lib/samagotchi/web/session_show.rb +1 -1
  117. data/lib/samagotchi/web/update_ops.rb +2 -1
  118. data/lib/samagotchi/worker.rb +4 -2
  119. metadata +17 -1
@@ -15,7 +15,8 @@ rescues its own errors: an observer must never break the running turn.
15
15
  |-------|------------------|-------|
16
16
  | `RingBuffer` | `ring_buffer.rb` | The last `ring_capacity` (256) raw events by `event_seq`, in memory, for a reconnect's replay. The capture observer feeds it. |
17
17
  | `TurnAccumulator` | `turn_accumulator.rb` | The turn in progress, folded from events. |
18
- | `CardStore` | `card_store.rb` | The last cards and notices, saved in `cards.json`. |
18
+ | `CardStore` | `card_store.rb` | The last cards and notices, saved in `cards.json`, each anchored to its turn. |
19
+ | `CardPlaces` | `card_places.rb` | Pure: where `CardStore`'s entries sit in the messages the page draws (`turns_since`). |
19
20
  | `PendingCard` | `pending_card.rb` | `pending_card.json` `{id:, bundle:}`: the running turn's open card with actions (check-in's Nudge), written and removed with that card, so chi web's hub, which watches files and not workers, can notify. |
20
21
 
21
22
  Per connection there is an `SSEWriter` (with a `BoundedQueue`); `ClientEvent` and `EventId` are pure helpers. The
@@ -49,17 +50,60 @@ The cards a plugin or the Engine shows (`Engine#show_card`), hook notices, load
49
50
  the "asking again" and steer-cut rows, ✂ rows and questions (with how they were answered), for a UI that joins later:
50
51
  `snapshot[:cards]` and `GET /tail`'s `cards`.
51
52
 
52
- - **Placement is relative.** An entry carries `turns` and lists with `turns_since` (turns completed after it) and
53
- `current` (a card of the running turn). A card that comes mid-turn but isn't the turn's (an anytime command's, such as
54
- `/btw`) goes after that turn (`during`). A card with an earlier card's id replaces it in place.
53
+ - **Anchored to its turn.** The store keeps a **turn log**, every id it saw at `:turn_started`, oldest first. An entry
54
+ holds `turn_id`: the running turn's, else the log's last (nil before any); a load warning, which the Engine announces
55
+ inside the first turn, takes the one before it. `list(messages:)` computes `turns_since` from the messages the page draws
56
+ (`CardPlaces`, the snapshot's own list; a stopped session's file for `.saved`): the turn openings (`Steer.turn_opening?`, as
57
+ the page's `TURN_PROMPT` and the recap count) after the last one whose id is in the log at or before the entry's, else from
58
+ the first one whose id is later in the log, else 0. The log only orders; the messages count. So a turn with no opening (a
59
+ reminder, a Continue, a failed or rolled-back one, a prompt stopped before the model) moves nothing, a wake's note does, and
60
+ an opening with no id (`/continue no`'s reason, Qwen's recovery line) counts as on the page. Nothing orders such an opening
61
+ against an entry, so the store reads the session's messages (its `messages:` lambda) when an entry is shown between turns, and
62
+ at the turn's end for what a running turn showed that isn't its own: `past_idless` is how many of them, right after the opening
63
+ it counts from, the entry already came after, and they don't count for it. A `!rollback` usually only shortens that run, and the count
64
+ is capped at the run still there, so a stale number doesn't eat a later turn. It can also move an entry's counting point back to a
65
+ run the entry never recorded (T1, `/continue no, too slow`, a card, a held T2 stopped, a second card, `!rollback`: the second card,
66
+ anchored to T2, now counts from T1 and reads above the reason and the first card). That rare inversion is accepted. A turn's own rows don't read it: they count such a line, which sits below them. `current` is a card of the running turn. A card that
67
+ comes mid-turn but isn't the turn's (an anytime command's, such as `/btw`) goes after that turn (`during`), and so does a
68
+ between-turns notice. A card with an earlier card's id replaces it in place and keeps its anchor. A `list` with no messages
69
+ (`?cards=1`'s bodies, `chi bundle try`'s trace) has no `turns_since`.
70
+ - **A turn that leaves nothing** is read, not told: an in-turn entry not `current` whose turn has no opening in the messages
71
+ (a `!rollback`, a failed turn with nothing kept, a prompt stopped before the model) lists as a between-turns entry at its
72
+ place, a card without its actions and a hook's notice without its step; its other rows, having no step to sit in, aren't
73
+ listed. Nothing in the entry changes. A kept Continue or reminder turn never has an opening, so its entries are `continued`
74
+ and stay rows of the turn before; a failed one loses the mark at its end. The page draws a failed prompt at the history's
75
+ end, so an entry pushed idle after it lists `after_failed` (the store's saved `failed_turn`) and goes below it, until the
76
+ next turn starts.
77
+ - **A command's card.** `SessionCommands#run` wraps its dispatch in `Engine#running_command`, so a card shown on that thread
78
+ carries `command:` (the event's field, `show_card` adds it). The store keeps the first show's (a replacement keeps it, even
79
+ none) and lists `label: "from /short"`, never `command`. The page draws it after a reload, where the command's line is gone.
55
80
  - **Caps.** `CAPACITY` (20) each for notices, ✂ rows (`LLM_CONTEXT_TYPES`) and cards, so one burst cannot push out
56
81
  another kind. An open question (an approval too) is never pushed out.
57
82
  - **Saved.** With a `path` (`cards.json` in the session's folder) the store rewrites the file after each event in
58
- `SAVED_ON`, and a later worker's store starts from it, so a turn's cards outlive the worker; `CardStore.saved(dir)` is
59
- what chi web reads for a session no worker runs. Load warnings are the worker's own (each start announces them)
60
- and an unanswered question waits for no one: neither is saved or seeded. An entry from an earlier worker lists with
61
- `earlier: true`; one shown during a turn comes back **without its actions** (their turn no longer runs), while a
62
- between-turns card keeps its buttons.
83
+ `SAVED_ON`, and a later worker's store starts from it, so a turn's cards outlive the worker; `CardStore.saved(dir, messages:)`
84
+ is what chi web reads for a session no worker runs. The file is `{version: 2, turn_log:, entries:}`. The log is pruned at
85
+ a turn's start and end and at an idle show (a running turn's rows reuse the start's, as finding it walks the messages) to start at the opening the earliest anchor counts from (all of it while an entry has none, or for a store with no
86
+ `messages:`), so a restart reads an entry as the live store did; an old card therefore keeps it whole. `seed` drops an entry
87
+ whose `turn_id`, `base_since` or `past_idless` isn't an id or a non-negative integer. Load warnings
88
+ are the worker's own (each start announces them) and an unanswered question waits for no one: neither is saved or seeded. An
89
+ entry from an earlier worker lists with `earlier: true`; one shown during a turn comes back **without its actions** (their
90
+ turn no longer runs), while a between-turns card keeps its buttons.
91
+ - **Version 1.** A file from before the log (top-level `turns`, each entry its own, maybe `running`) keeps its places, drift
92
+ included: `seed` turns each count into `base_since`, new openings add to it, and such an entry never reads as having left
93
+ nothing (no id to check). The next save writes version 2 with `base_since` kept. `SessionManager.cards_empty?` reads both.
94
+ - **An older chi on a version 2 file.** Its `seed` wants an integer `turns`, finds none, and starts with no entries. A chi web not
95
+ yet restarted only reads: it lists no cards for that session until restarted, and the file stays whole. An older **writer** does
96
+ damage: at its first saved event, `:turn_started`, it writes `{"version":1,"turns":0,"running":true,"entries":[]}` over the file
97
+ (`{"version":1,"turns":1,"entries":[]}` after that first turn), so that session's cards go, and with them the answered approvals
98
+ `chi guardrails replay` reads. A plain upgrade doesn't reach this: a worker already running seeded before and keeps its own version 1
99
+ file, and a spawn runs the newest chi installed (`SessionManager.worker_command`), unless that activation raises LoadError: the boot
100
+ line then falls back to the spawner's or the home's pinned version, which can be older. Otherwise it takes an older process starting
101
+ on a session a newer one wrote: a downgrade, a chi home whose newest chi is older (`home_older_than_spawner`), or a REPL or `bin/chi`
102
+ from an older gem or checkout on the same state dir. Nothing on this side stops
103
+ it: an older chi can't be changed, and having it read the file would mean writing fake `turns` counters into every file.
104
+ - **A killed turn needs nothing special.** `TurnCheckpoint` keeps a running turn's prompt in the session file, with its id from
105
+ the start (`Worker#run_engine_turn` settles it first), so it counts as any opening. A REPL keeps no running turn in the
106
+ session, so a kill takes the prompt along and there's nothing to count.
63
107
 
64
108
  ## SSEWriter
65
109
 
@@ -13,9 +13,14 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
13
13
  *.md, index.md the bundle's memories, one managed index line each ("· from <bundle>")
14
14
  <system memories>/.bundles/ MemoryPaths.bundles_dir
15
15
  <name>/manifest.json the install record (Provenance#write, read as InstalledBundle)
16
- <name>/bases/ each file as installed: the base of the next upgrade's 3-way merge
16
+ <name>/bases/ each file's base for the next upgrade's 3-way merge: as installed, or the
17
+ bundle's own text after a merge
18
+ <name>/incoming/ the bundle's text for each file marked conflict, until the file settles (Provenance:
19
+ #write, #refresh_marks, gone with #resolve_conflicts); nothing globs it for memories
20
+ (Builder takes the scope dir's top level), and an uninstall's rm_rf sweeps it
17
21
  <name>/hooks/ guardrails/ plugin/ scripts/
18
- .trash/<name>-<stamp>/ files an uninstall or upgrade took away (Trash), never deleted
22
+ .trash/<name>-<stamp>/ files an uninstall or upgrade took away, and copies of what an install
23
+ overwrote (Trash), never deleted
19
24
  ```
20
25
 
21
26
  ## Components
@@ -35,15 +40,20 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
35
40
  | `MemoryFiles` | which top-level `*.md` files of a bundle are its memories (`.select`: the `files:` list, else all but README/LICENSE and the like), `.unshipped` (listed, not shipped) and `.symlinks` (skipped: a symlink is never a memory) |
36
41
  | `Manifest` | parses/validates `manifest.yml` (`name:` is the bundle's name and a directory under `.bundles/`, so a path such as `../x`, `a/b`, `.` or `..` is refused: `name: … is not a bundle name`; YAML that doesn't parse or isn't a mapping is a `ValidationError` too; files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers, session_settings: the keys a session's `chi --set` may give, a list or a mapping of them to types (`SESSION_SETTING_TYPES`), recorded at install in `manifest.json` (`session_settings`, `session_setting_types`) for `SessionConfig` to check; settings: the keys config.yml's `bundles: <name>:` may have, the same shape, recorded as `settings` (nil when the manifest has none) and `setting_types` for `BundleSettingsCheck`, whose warnings the Engine holds for the first turn and `SessionConfig`'s refusals share the words of) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
37
42
  | `Installer` | one install/upgrade/dry run of one bundle (below) |
38
- | `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps) |
39
- | `Merger` | 3-way classification per file: `install`, `fast_forward`, `keep`, `noop`, `conflict` |
40
- | `Provenance` | the `.bundles/<name>/` dir: `#write` the record + bases, `#record` → `InstalledBundle`, `.each_installed`, `.claimants` (which bundles own a file) |
43
+ | `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps). A file it replaces or removes whose bytes are neither the recorded sha nor the incoming file's is copied to the Installer's `Trash` first (`Trash#copy_edit`, `#copy_edits` for a folder at any depth, as `hooks/x`, `guardrails/x`, `plugin/x`, `scripts/sub/x`; no sha recorded counts as edited; a dev link never is) |
44
+ | `Merger` | 3-way classification per file: `.classify` → an `Outcome` (`status`: `install`, `fast_forward`, `keep`, `noop`, `conflict`, `merged`; `base`: where the file's next base comes from, `:on_disk`, `:recorded` or `:incoming`; a new outcome and its base go in its constants, and it needs a branch in `Installer#merge_memory`, a kind in `#next_base` if its base is new, and its status word in `#preview_memory`, `#verify_memory_checksums` and `#summary`). `.classify` never merges and answers `conflict` for two changed sides; `Merger::Upgrade` (provenance, target dir, source dir) is the one way to ask what an upgrade does to a file, nothing written: `#outcome` (`.classify`, then `TextMerge` where both sides changed, git once per call: `merged`, with its `TextMerge::Result` and the digest it read as the Outcome's `merge` and `current_sha`; `ALREADY_MERGED`, a `keep` with base `:incoming`, when the merge adds nothing; or `conflict` with that `merge`, whose `why` says why none was possible), `#classified`, `#forecast` (a `Forecast` of `merged` and `behind` in one pass), `#behind` (a `conflict` outcome as a `FileStanding::Behind`: why, and from `TextMerge.changed_sections` what it missed, nothing for an older mark, whose base may be the user's text), `#paths`, used by the Installer's merge and dry run, `ShippedUpdate.plan` and `SystemBundle.sync(dry_run:)`. `.write_merged` puts a merge in place: the digest checked just before the rename (`AtomicFile.write`'s block), through a symlink, the mode kept, the old file in the `Trash`; false when a live `memory_write` got in. A mark from an older chi (`InstalledBundle.legacy_mark?`: no `incoming_sha`, or `legacy_base`, which keeps that known once the digest is recorded, until the mark goes) may have the user's own text as its base, 0.149's doing, and a merge against it would undo that text with nothing to show, or a fast-forward replace all of it: `#outcome` makes a fast-forward that takes a line out of the file, and `.merge_outcome` a merge that does, a `conflict` (`FileStanding::LEGACY_BASE`), on the Installer's and `Remerge`'s path alike, unless `ShippedHistory` proves the base is a version chi shipped. The price, with no such gem left or for a bundle chi doesn't ship: a real base looks the same, so such a file whose bundle rewords or removes a line it still holds stays behind until it is resolved by hand, with `--agent` or with `--force`; no retry fixes it. `Installer#save_marked_file` serves a fast-forward that goes ahead over a mark: a file put back to its base under this chi's mark, or an older mark with a proved base or an update that only adds. `.markers?` (a file holding a merge's `<<<<<<<`/`>>>>>>>` lines; `=======` alone is a Markdown underline) |
45
+ | `ShippedHistory` | `.shipped?(bundle, file, path)`: whether those bytes are that file of that bundle (by manifest name, since a shipped dir can be renamed) in an installed `samagotchi-<version>` gem. Reads `<gem dir>/gems/` by hand, for `Gem.path` (set by a chi home's boot string) and the home's `gem_dirs` from home.json (a checkout or another Ruby runs on other gems), never `Gem::Specification`. Size, then bytes, then the manifest. Anything it can't read is no match, which keeps the guard. Asked only when `Merger`'s guard would otherwise fire: with 54 gems on the Studio a miss costs about 4 ms, a hit less |
46
+ | `TextMerge` | the text merge: `.merge(base:, ours:, theirs:)` → a `Result` (`status` `:clean` with `text`, `:conflict` with `hunks`, `:failed` with `why`). `git merge-file -p --diff3 --marker-size=20` on temp copies in a temp cwd, with every `GIT_*` that points at a repository or a config unset or at `/dev/null` (it can run inside another tool's hook). git isn't a runtime dependency: none on PATH, or macOS's `/usr/bin/git` with no developer tools behind it (`.apple_stub?`, by `xcode-select -p`; running it pops a dialog), is `:failed`. So is a git past `TIMEOUT` (10 s; its process group is killed: this runs under `SystemBundle`'s lock), and any doubt about the answer: `min(hunks, 127)` against the exit status, a status over 127, a NUL in a side, marker lines that don't pair up (a user's line equal to one), a line no input holds, or an empty answer to non-empty inputs. `Scan` reads the output: a hunk unions (ours, then the entries of theirs that ours lacks) only when the last heading before it, at level 1 or 2 and outside code fences, HTML comments and any hunk, is titled exactly `Changelog` or `Change log`, with nothing but list entries (an item and its indented lines) since that heading and no fence or comment open, and base, ours and theirs each hold only list entries, with every base entry still on both sides: both only added. Any other hunk is a real conflict, and markers never leave the module. An entry is an item and the lines indented deeper under it, nested items too, and the union compares whole entries. `.changed_sections(base, theirs, ours)`: the headings of the sections the bundle changed and the file hasn't got, and whether text under no heading did, which is what names a miss: a conflict's hunks can't, since the whole file stays and the one hunk may be a Changelog line beside a whole new section. `Hunk#heading` counts the places under no heading |
47
+ | `FileStanding` | the one owner of what chi says of an edited bundle file, used by every surface: `Behind` (`#state`, `#line`: `x.md stays behind 0.8.0: no merge possible (why); missed ## A, ## B, ## C and 2 more`, `would stay` in a dry run), `MergeNote`, `.merged_line`, `.no_merge` (for `LEGACY_BASE` it says chi can't tell whether the file holds edits), `.clauses` (a report's clauses for one bundle), `.behind_count`, `.stay_behind`, `.counts_sentence` (the push), `.still_behind`, `.text` (UTF-8, whatever a name or heading was tagged), `FORCE_TAKES`, `FORCE_MOVES`, `IN_THE_TRASH`. Behind means marked and bytes other than the mark's `incoming_sha` (`.behind?`, read by `InstalledBundle#behind_files` and `Remerge` alike): a file brought level by hand isn't, a missing one is a missing file, and one that can't be read is. An older mark no retry has looked at reads `may be behind` (`Behind#unsure`, and the counts say so), and isn't behind at all when level with its recorded source on disk (`InstalledBundle#behind_file?`). A new surface reads these and adds no wording |
48
+ | `Remerge` | the retry at the same version, memory files only, for a file marked `conflict`: `#run(dry_run:)` → a `Result` (`merged`, `settled`, `stamped`, `behind`, `warnings`, `trash_copy`, `trash_dir`). It merges what merges and settles those entries (`Provenance#resolve_conflicts`, the index line refreshed), leaving `version`, `installed_at` and the assets alone: the Installer would `rm_rf` guardrails, plugin and scripts under live sessions. Every file it leaves is in `behind`, with why: one not shipped now (`MemoryFiles.select`) keeps its mark, base and text; one whose `incoming_sha` isn't the shipped text (a checkout at one VERSION) is left alone, no git runs, and it reads as its mark says; a mark with no digest (older chi) is tried as well, and a real conflict then records it (`Provenance#refresh_marks`, which writes only when the mark would read differently, so a conflict at each start writes nothing). A mark whose digest matches is tried on every run, since the file or git may have changed. `noop` and `keep` settle quietly; `fast_forward` waits for the next upgrade, reading as behind until then; `install` is a missing file, which isn't behind, and a warning says so. A file it can't read or replace stays as it is, saying why. Called from `ShippedUpdate.plan_row`/`apply` (`chi update`) and `SystemBundle.sync`'s equal-version branch, under its lock (each chi start and `chi update`); a bundle chi doesn't ship retries through `chi bundle upgrade`. No marks: nothing is read beyond the record. One marked file costs one git run per Engine start, about 12 ms on the Studio (0.1 ms with none) |
49
+ | `Provenance` | the `.bundles/<name>/` dir: `#write` the record + bases (its `files:` is the full list: a key left out is dropped, a nil value keeps the entry with no base; `marks:` are the files left behind, each a `FileStanding::Behind`, and `incoming_dir:` the bundle text each speaks of, recorded as `incoming_sha` and saved at `#incoming_path`; an older mark that stays keeps `legacy_base`), `#resolve_conflicts` (base to the incoming file, mark and its fields (`MARK_FIELDS`) and saved text gone; a file whose incoming copy is gone keeps both), `#refresh_marks` (what a retry found: why, missed, the digest and `legacy_base`, the saved text), `#note_merged` and `#clear_merge_notes(notes)` (`MERGE_NOTE_FIELDS`, kept through `#write`; the saved text is best effort, after the manifest, so it can't leave a plugin under the old record's sha; any other failure writing the record still can, until a retry), `#record` → `InstalledBundle`, `.each_installed`, `.claimants` (which bundles own a file) |
41
50
  | `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty). `BundleHook#events` is the manifest's `event:` or its `events:` list (written back as `event:` for one, as an older chi reads it); `Hooks::BundleLoader#load_hook_file` builds the hook once and registers it under each |
51
+ | `BlockRenderers` (`lib/samagotchi/block_renderers.rb`) | a manifest's `block_renderers:`: `.parse_list`, and what chi web asks without loading plugins: `.for_installed` (first bundle by name wins a language; a record with an error, an unmet `requires_chi` or a list that doesn't parse is skipped) and `.script_path` (`load` membership first, then `Provenance.sha_matches?`). `Manifest` checks each `load` is a `scripts:` key; the files are ordinary scripts everywhere else ([block-renderers.md](block-renderers.md)) |
42
52
  | `Builder` | `chi bundle build`: memories (+ the installed bundle's hooks, rules, plugin, scripts) → zip/tar/dir |
43
53
  | `Profile` | meta bundles (`includes:`): install the members, record which; `InstallResult#kept_own` names a new member's memory files the user already had, with other bytes (`kept_own_line`, printed by `chi bundle`, `chi update` and `chi bootstrap`); a dry run runs each member's Installer as a dry run too, so it names them as well |
44
- | `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true`. `ShippedUpdate.external` is the rest: what `chi update` only checks |
54
+ | `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` and `Remerge` at an equal version (`ShippedUpdate::Row`'s `:would_merge`, `:merged`; `SystemBundle::Result`'s `:merged`). `SystemBundle` also puts back a memory missing at an equal version, that file alone, with no Installer (`verify_files_present`, `Provenance#record_restored`). `ensure!` echoes a merge in one line, in the row's words. That reaches a terminal launch; a worker has only its log (`Log.configure(stderr: false)`), so every merge leaves a note on the file's record (`Installer#note_merges`, a failed run's too, and `Remerge#note_merges`; `Provenance#note_merged`: version, time, trash dir). `chi bundle status` shows it, and `chi update` reads the notes before the run (`SystemBundle.merge_notes`, `ShippedUpdate::Row#earlier`), names them in the row and clears after it those exact ones and the notes of the files that run merged (a worker's meanwhile, on another file, stays), unless it was a dry run, the row failed or has no place for them (a skipped system row), or the run is unattended (`ChildEnv::UNATTENDED_UPDATE`, which `Web::UpdateOps` sets for `update.auto`, an env marker so a hand-over's exec keeps it and an older chi ignores it; `--json` alone says nothing, as a person or script may use it). `chi bundle upgrade` clears the notes of what it printed, and `--force` those of the files it replaced. `--json`'s `merged` is this run's, `earlier` the notes'. `ensure!` says what an upgrade left behind and nothing of a file already behind, which `Result#behind` still has for the row. A plan or sync that can't read a file is that bundle's failed row, in `Trash.failure_message`'s words (`ShippedUpdate.plan_row`, `SystemBundle.sync`). `ShippedUpdate.external` is the rest: what `chi update` only checks |
45
55
  | `InstalledCopy`, `BundleCommand::InstalledCheck` | `chi update`'s re-check of the bundles chi didn't ship ([docs/cli.md](../cli.md#updating)). `InstalledCopy` lays a record and its installed files out as a source folder (manifest.yml from `Manifest.write`, the files' own shas, memories from `bases/`); `InstalledCheck` forks per bundle (a plugin's `exit` or hang is that bundle's row; stdio to /dev/null, so `--json` stays parseable; `TIMEOUT`, then KILL of its group) and runs `Check.new(dir, installed: true)` there, which leaves out what only suits a source tree. Its `boundary` finding is narrower there than in a source tree: `Testing::Boundary.gone?` calls a reference gone only when its constant doesn't resolve or the object has no method the name names, so a bundle reaching `Samagotchi::Log` gets no row. lib loads lazily, so when a name doesn't resolve `gone?` requires every file of this chi's library in that same child and asks again (about 0.2 s, only for a bundle that reaches into chi at all); anything it can't read — a `const_get`, a require of a file that is there — is taken as still there. The fork is the point: `Sandbox` swaps ENV and the plugin's code is arbitrary. `UpdateCommand#external_rows` makes the rows (`level`, never `failed`) and holds the check back while a newer gem is yet to take over (`@not_checked`), since a fork is this chi's code |
46
- | `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
56
+ | `Uninstaller`, `Trash`, `Status`, `Listing` | remove (the bundle's own folder too: an edited file there is copied to the trash first, `Uninstaller#trash_copy_line`); keep removed files (`Trash#move`) and a copy of overwritten ones (`Trash#copy`, its `as:` keeping a subfolder); `chi bundle status` (per file, `behind`, `merge_note`, `unreadable`; `issues=` counts behind, missing and unreadable, not edited); `chi bundle list` (`Installed#behind`) |
47
57
  | `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
48
58
  | `EntrySize` | one memory's size in tokens: `memory.entry_warn_tokens`' crossing note after a memory write, `chi self`'s `big memories` row |
49
59
  | `IndexSize` | one scope's memory index as the prompt carries it (tokens, lines, bytes): `memory.index_warn_tokens`' crossing note after a memory write, `chi self`'s summary, the prompt's figures (`SystemPrompt`) |
@@ -86,25 +96,44 @@ The order matters: warnings print in it, and a plugin/script the manifest names
86
96
  `#source_change` (the summary's first line, `Source changes: old → new`). A source `SourceIdentity` can't place
87
97
  (an older record's relative path) never refuses.
88
98
  3. `install_memories`, per memory (`MemoryFiles.select`: a non-empty `files:` list, else every top-level `*.md` but
89
- README, CHANGELOG, LICENSE and the like; a listed name not shipped warns; a symlink is skipped with a warning): an upgrade 3-way merges the files the bundle owns (`merge_memory`), a dry run only
90
- classifies (`preview_memory`), a plain install writes new files and skips existing ones (`skip_existing`). A
99
+ README, CHANGELOG, LICENSE and the like; a listed name not shipped warns; a symlink is skipped with a warning): an upgrade 3-way merges the files the bundle owns (`merge_memory`: a clean text merge replaces the file through `Merger.write_merged`, and a real conflict, no git or no base leaves it untouched, `note_conflict`, in `#behind`; a fast-forward over an entry this chi marked `conflict`, a file put back by hand, copies it to the trash first: `save_marked_file`), a dry run only
100
+ classifies (`preview_memory`, `would_merge` for a merge, git running on temp copies; both through `Merger::Upgrade`), a plain install writes new files and skips existing ones (`skip_existing`), and `--force` overwrites, a file that
101
+ differs copied to the trash first (`Trash#copy`, said by `#trash_copy_line`; a profile's in `InstallResult#trash_copies`). A
91
102
  same-name file the bundle didn't write is the user's: skipped and never recorded (`@owned`). Model overlays
92
103
  (`<name>.<key>.md`) get no index line (`note_overlay`).
93
104
  4. `AssetInstaller#run`: hooks (manifest-listed, else every `hooks/*.rb`), prune dropped hooks, hook checksums,
94
- guardrails, plugin, scripts; each set replaces the previous version's. requires_chi warnings for hooks/rules. A
105
+ guardrails, plugin, scripts; each set replaces the previous version's, an edited file copied to the trash first. requires_chi warnings for hooks/rules. A
95
106
  file that is a symlink or resolves outside the bundle dir (`AssetInstaller#linked?`, a linked `hooks/` too) is
96
107
  skipped with a warning (a hook or rule) or raises `InstallError` (the plugin, a script).
97
108
  5. An upgrade's `prune_dropped_files`: unchanged → trash; edited → kept (`kept_pruned`); owned by another bundle → kept.
98
109
  6. `verify_memory_checksums` (strict), `warn_missing_needs`.
99
- 7. `write_provenance` (manifest, not a dry run): bases come from the incoming file, except where the user's edit was
100
- kept (kept/noop/conflict/kept_pruned, or a re-install's skipped edit): those keep the old base, so the next
101
- upgrade still sees the edit. A plain install also keeps the record (the old base) of a file an earlier install
110
+ 7. `write_provenance` (manifest, not a dry run): an upgrade's bases come from each file's `Merger::Outcome`
111
+ (`Installer#next_base`): the file on disk, which has the bundle's bytes; the incoming file for a merge, since the merged
112
+ file holds the user's edit and taking it would make current == base, so the next upgrade would fast-forward over the
113
+ edit (the edited signal stays current != base, which `chi bundle status` and uninstall's refusal read); and where the user's edit was
114
+ kept (keep, conflict, whose `FileStanding::Behind` goes in `marks:`; a file the bundle dropped, kept_pruned, and a re-install's skipped edit take the same path by their
115
+ status and reason): those keep the old base, so the next upgrade still sees the edit. One with no base stays without one (a nil in `Provenance#write`'s `files:`, which keeps the
116
+ entry and its recorded checksum): taking the user's file would let the next upgrade fast-forward over it. A plain
117
+ install carries the `conflict` mark of a file it skipped (`held_conflict_marks`), a newer source moving `version`
118
+ included; it goes once the file has the incoming bytes. A plain install also keeps the record (the old base) of a file an earlier install
102
119
  owned and this version doesn't ship, while it is on disk (`dropped_owned_files`), so the next upgrade prunes it.
103
120
  8. `detect_placeholders`.
104
121
 
122
+ A file system error in steps 3 to 5 (`Installer#replace_files`; a `.trash` it can't write, an edited file it can't read) is an
123
+ `InstallError` in `Trash.failure_message`'s words, as it is an `UninstallError` in `Uninstaller#run`. Each save comes before
124
+ its removal, so no edit is lost, and the record isn't written, so a retry is the same upgrade. Not atomic: files the
125
+ failed run already placed stay.
126
+
127
+ Which files a plan asks `Merger::Upgrade` about is the caller's, and the lists differ, each less the files the bundle
128
+ doesn't own: the Installer's `MemoryFiles.select`; `ShippedUpdate.forecast`' every shipped `*.md`, so `chi update`'s plan
129
+ can name a file dropped from `files:` or now a symlink, which the upgrade doesn't merge: edited, it stays (`kept_pruned`, or `kept_shared` when another bundle has it too); `SystemBundle`'s dry
130
+ run the manifest's `files:`, so a manifest with none plans nothing (the shipped one lists its files).
131
+
105
132
  Results are `@results` (file → `{status:, reason:}`), summarised by `#summary`. An upgrade with conflicts keeps an
106
133
  owned source dir alive (`#source_dir`) until `#cleanup_source!`, because the conflict agent reads the incoming
107
- files; `Provenance#resolve_conflicts` then moves the resolved files' bases forward.
134
+ files; `BundleCommand#settle_conflicts` then runs `Provenance#resolve_conflicts`, which moves a base forward and keeps
135
+ the entry's description, only for a file the agent step changed that holds no markers (`Merger.markers?`), and whose incoming
136
+ copy is still there. Any other keeps its old base and mark, and the command exits `CONFLICTS_KEPT`.
108
137
 
109
138
  ## Builder#run, in order
110
139
 
@@ -220,8 +220,13 @@ the record settles is `unclear`, and a file changed since isn't seen. If that tu
220
220
  next step, and the file and view below can stay.
221
221
 
222
222
  **Independence.** The request (`ObjectivesCheck.request`) has its own system text and no tools. Its record is what people and
223
- notes said and what tools returned (`.record`, each entry cut at 1,500 characters, the newest 24,000 kept). It never holds the
224
- agent's prose, the system prompt, the list's notes, a continue's carry note, which holds the agent's last answer
223
+ notes said, what tools returned, and the agent's visible answers labelled `[agent's own words]` (`.record`, each entry cut at 1,500 characters, the
224
+ newest 24,000 kept). Without those answers a claim about what the agent said or asked could only come back `unclear`. So the request says they settle
225
+ what was said or asked and are never proof of work, which needs a tool's output or a person's word. That rule is words to a model, not a mechanism: a
226
+ probe on Qwen3.8 Flash Next held it 3 of 3 (only the agent's "all pass": `unclear`), and a verdict is information only either way.
227
+ The record never holds the agent's thinking (`own_words`: a chat entry's `thinking` and `anthropic_content` aren't read; a native
228
+ entry's content is the raw response, so its inline thoughts, a thought cut off unclosed, and its call markup come out), the system prompt, the list's
229
+ notes, a continue's carry note, an earlier session's answer that would read as outside news
225
230
  (`Objectives.outside_note?`, the rule the audit shares), or the objectives tool's calls, since a move isn't evidence for itself.
226
231
  Note and evidence are capped at 1,000 characters each. Mid-turn it reads the loop's conversation (`Engine#live_conversation`), the running turn's runs too.
227
232
 
@@ -23,7 +23,7 @@ page. A factory dep a new version stops passing is the same hazard the other way
23
23
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
24
24
  until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
25
25
  `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
26
- `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js` and the `objectives_*.js` modules are new modules for that reason.
26
+ `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js`, `block_open.js`, `text_roots.js`, `block_source.js`, `block_frame.js`, `block_host.js` and the `objectives_*.js` modules are new modules for that reason. The last three `block_*` draw a bundle's fenced blocks: [block-renderers.md](block-renderers.md).
27
27
 
28
28
  ## Two pages, two models
29
29
 
@@ -50,7 +50,7 @@ change.
50
50
  | **`store.current`** (`session_state.js`: `emptySessionState` / `sessionStateFrom`; per field: `session_fields.js FIELDS`) | the open session's fields, about 40 (info bar, chips, verdicts, timing, worker, stream cursor) | `store.update` / `setStatus` from app.js, `stream_handlers.js`, `send_turn.js` (`firstPreview`, `owner`), `session_actions.js` (`status`); `forgetSession`'s `replaceCurrent`. Two writers in place, on purpose (D8 in `FIELDS`): live_timing.js's `activeTurn`, verdict_fresh.js's running list | the store runs the field's redraws (`FIELDS[key].redraws`) |
51
51
  | **`store.sessions`** (the hub's projection) | every card | `store.update({sessions})` from app.js: `openSessionEvents` (`applySessionEvent`), `refresh` (the list fallback), `addCreatedSession`, `applyView` (re-sorted), `replaceSelectedCard` (`setLiveStatus` through `setStatus`, `readTurnEnd`, `setUsedMemories`), the stream's close (`withoutWorker`); `session_actions.js` (`setSessions`) | the store: `lists` and `infoBar`. **Always a new array**; an entry is replaced, never changed in place. `followSelectedSession` copies the open one's owner and worker (`fieldsFromCard`) and, when not streaming, its status (`setStatus(…, "hub")`) into `store.current` |
52
52
  | **Factory closures**: the `create*` factories (popovers, panels, views) | each component's own state: open-for, busy, error, its own fetched list | the factory | an `onChange` hook back into app.js, or a getter the factory reads at draw time |
53
- | **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), kept command bubbles, which texts were shown, unnamed badges, folded answers and long prompts (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`); the switcher's version on view is `revise_stack.js`'s own (a copy `.stack-view` before the top answer, the top `.stack-swapped`; never stored) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` and `verify` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
53
+ | **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), what opens a turn (`TURN_PROMPT`, `bubbles.js`: a prompt or a wake's note, the same turns `Steer.turn_opening?` counts for a card's or the recap's `turns_since`), kept command bubbles, which texts were shown, unnamed badges, folded answers and long prompts (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`); the switcher's version on view is `revise_stack.js`'s own (a copy `.stack-view` before the top answer, the top `.stack-swapped`; never stored) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` and `verify` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
54
54
  | **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
55
55
  | **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
56
56
 
@@ -181,9 +181,9 @@ from saying it twice across restarts). The key's `chi:` prefix keeps sw.js from
181
181
 
182
182
  | `kind` | key | title / body |
183
183
  |---|---|---|
184
- | `installed` (`update.restart: never`) | `chi:installed:<X>` | "chi X is installed" / "chi web and its sessions switch over when you restart them." + kept edits |
184
+ | `installed` (`update.restart: never`) | `chi:installed:<X>` | "chi X is installed" / "chi web and its sessions switch over when you restart them." + the edited files (`FileStanding.counts_sentence`) |
185
185
  | `failed` | `chi:update_failed:<X>` | "chi X didn't install" (or "installed with a problem" when the gem row was fine) / the first failed row's status, "Open chi web for details." |
186
- | `updated` | `chi:updated:<X>` | "chi updated to X" / "chi web and 3 sessions restarted; 1 restarts when idle." + kept edits |
186
+ | `updated` | `chi:updated:<X>` | "chi updated to X" / "chi web and 3 sessions restarted; 1 restarts when idle." + the edited files |
187
187
  | `restart_failed` | `chi:restart_failed:<X>` | "chi X is installed; chi web didn't restart" / why (a failed `--version` preflight, no wrapper), "Restart chi web in its terminal." |
188
188
 
189
189
  `updated` is sent by the **restarted** chi web (its AutoUpdate finds the run at `phase: web`): the exec ends the
@@ -242,7 +242,10 @@ session's set (load, resync) and when the list opens (a file changed while the s
242
242
  passes `working_set_updated` to it, and does nothing when the flag is off. A new snapshot redraws:
243
243
  - the session bar's chip and the open list;
244
244
  - the footprints, in place (`refreshFootprints`, the same node; a `data-ws-sig` of the items' states skips an
245
- unchanged one);
245
+ unchanged one). A footprint is a `<span>` in its block's summary line, `.turn-work > summary > .tw-sum`, before the
246
+ steps' `.turn-steps`: drawn in the reload's template (`turnHistoryNodes`), prepended at the hand-off
247
+ (`stage_view.js handOff`, while the stage still hides the summary). A click there must not toggle the block:
248
+ `details_motion.js` skips `.ws-foot`, and `footClick` cancels the click;
246
249
  - the rail (`onServer`, the stage's `scheduleRefresh`).
247
250
 
248
251
  A state is the item's state now, not when its turn ran. A state change never refolds and never moves a line: the
@@ -287,7 +290,7 @@ leaves nothing behind. The rail adds a dashed `+ name · next turn` line per pen
287
290
  (`working_set_rail.js addLabels`, inside the rail's budget), and `read again next turn` on a held line's title.
288
291
 
289
292
  **Looks** (design spec §3, §5): a stale line is faded (`--ws-stale-opacity`: `.55` on the light palette, `.4` on the
290
- dark one, where a lighter grey reads as plain secondary text; a footprint label `.55` on both, its text already faint); a forgotten one is struck in `--text-faint`
293
+ dark one, where a lighter grey reads as plain secondary text; a footprint label `.55` on both); a forgotten one is struck in `--text-faint`
291
294
  at `opacity: .5`. A group shows `✂n` for its forgotten members. It is struck only when all its members are
292
295
  forgotten, and faded when all are stale or forgotten (`groupState`). The list's ops text says `stale` or `forgotten`
293
296
  in words (colour is never alone) and shows `~1.2k` tokens per row (`tokensText`). A footprint adds `n stale` (plain; a file the turn's
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,17 +511,53 @@ 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.
521
559
  - **The rest of the manifest** (`plugin:`, `requires_chi`, `session_settings:`,
522
- `context_providers:`, `requires_chi_next`) is in
560
+ `context_providers:`, `block_renderers:`, `requires_chi_next`) is in
523
561
  [Plugins](plugins.md) and [internals/bundles.md](internals/bundles.md).
524
562
 
525
563
  ## Bundle profiles: core and dev
data/docs/plugins.md CHANGED
@@ -942,6 +942,12 @@ 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 shown before it, and before those
948
+ shown after. After a reload a card a command showed names it (`from /short`),
949
+ its line being gone.
950
+
945
951
  An [anytime command](#anytime-true)'s cards show as it shows them, after its
946
952
  line, in every UI, whether a turn runs or not.
947
953
 
@@ -957,8 +963,8 @@ then a new turn) starts from them. Load warnings and a question still
957
963
  waiting when the worker went are not saved.
958
964
 
959
965
  The event is `{type: :card, id:, source:, title:, body:, level:, actions:,
960
- in_turn:}` (`source` is the bundle), logged as `card` with its source, id and
961
- title.
966
+ in_turn:}` (`source` is the bundle), plus `command:` when a session command
967
+ showed it (`"/short"`), logged as `card` with its source, id and title.
962
968
 
963
969
  ## Side answers: `ctx.ask_model`
964
970
 
@@ -1347,6 +1353,41 @@ context_providers:
1347
1353
  the PATH may be another one, such as macOS's 2.6).
1348
1354
  - The command follows [the contract](context.md#the-command-contract).
1349
1355
 
1356
+ ## Block renderers
1357
+
1358
+ A bundle can draw a fenced code block as a picture in chi web's answers: the model writes ```` ```mermaid ````, the
1359
+ page shows the drawing. No tool, no plugin code in the web server: chi web runs no plugins, and a reloaded page must
1360
+ draw after the worker has gone, so JS draws and Ruby only routes and serves.
1361
+
1362
+ ```yaml
1363
+ # manifest.yml
1364
+ scripts:
1365
+ draw.js: sha256:… # scripts/draw.js in the bundle
1366
+ block_renderers:
1367
+ - lang: mermaid # a fence's first word, downcased: [a-z0-9_+-], at most 32
1368
+ load: [draw.js] # scripts: keys, loaded into the frame in this order
1369
+ ```
1370
+
1371
+ - `load:` names files under `scripts:`, so they install and check as any script does (`chi bundle check`,
1372
+ `rake bundles:sha`), and one changed after the install isn't served until the bundle is reinstalled; a `--dev`
1373
+ install's links aren't checked. A bundle with no memories installs fine, but `chi bundle build` still needs one:
1374
+ it refuses with "No memories to build".
1375
+ - The first installed bundle (by name) with a language draws it. A bundle whose `requires_chi` this chi doesn't meet
1376
+ draws nothing.
1377
+ - **What it needs of the page:** `web.markdown` on (a default-off setting, and the commonmarker gem). With it off a
1378
+ fence has no language in the page and nothing draws, though a hint in the bundle's prompt section still reaches
1379
+ the model: say so in the bundle's README.
1380
+ - **Where it draws:** the owner's page only, in answers and closed steps. Participants' pages, cards, question
1381
+ replies, the asking slot, delegate reports and the doc viewer show the source, and so does the terminal.
1382
+ - **The script's side:** the last file calls `chi.defineRenderer({ init, render })`. `init()` (optional, may be async)
1383
+ runs once; `render(source, theme)` returns the SVG as a string, or a promise of one. Throw to keep the block as
1384
+ source. `theme` is `{name, bg, panel, text, muted, border, accent, font}`, from the page's tokens. The script runs in
1385
+ a sandboxed frame with no cookie and no access to the page, and the CSP closes its network, but for WebRTC (what the
1386
+ frame holds, the diagrams it rendered, could leave that way). The SVG is shown as an `<img>`, so its scripts, links
1387
+ and remote images don't run: it has to stand alone (no `foreignObject`, every namespace prefix bound) and stay under 50,000 px a
1388
+ side and 100M px. Over that, or if the image doesn't decode, the block stays source. The
1389
+ layers and the residual risk are in [internals/block-renderers.md](internals/block-renderers.md).
1390
+
1350
1391
  ## Stable for bundles
1351
1392
 
1352
1393
  Bundles normally use only `chi.*` and `ctx`; the constants and helpers below
@@ -1380,7 +1421,9 @@ that calls it, or for a real external bundle that does. No method on the chance
1380
1421
  5. `Samagotchi::Tools::TaskRuntime::TASKS_DIR` and `EXIT_CODE_FILENAME`.
1381
1422
  6. `Samagotchi::AtomicFile.write`, `Samagotchi::Log.warn` /
1382
1423
  `Samagotchi::Log.debug`, `Samagotchi::Config.get`,
1383
- `Samagotchi::VERSION` and `Samagotchi::CancellationController` (`new`, `cancel!`: what `cancel:` takes).
1424
+ `Samagotchi::VERSION`, `Samagotchi::CancellationController` (`new`, `cancel!`: what `cancel:` takes) and
1425
+ `Samagotchi::GitEnv::UNSET` (a spawn env: a bundle's own git, or gh, asks about the folder it names, not the repo
1426
+ a hook's or `rebase --exec`'s environment names).
1384
1427
 
1385
1428
  `Samagotchi::Testing::Boundary::ALLOWED` is the checked copy of this list,
1386
1429
  and `Boundary::EXCEPTIONS` the per-bundle list of what each shipped bundle
@@ -1390,6 +1433,9 @@ fails on a reference in neither (docs/testing.md). In `chi update`'s re-check of
1390
1433
  an installed bundle the same scan warns only for a name this chi no longer has
1391
1434
  (`Boundary.gone?`): a reference that still resolves gets no row.
1392
1435
 
1436
+ A bundle's `block_renderers:` and the frame's `chi.defineRenderer` ([Block renderers](#block-renderers)) are stable
1437
+ too. They aren't Ruby, so the scan above doesn't see them.
1438
+
1393
1439
  **Used by shipped bundles today, but internal** (may change without notice; a
1394
1440
  bundle that needs one should ask for an API instead):
1395
1441
 
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