samagotchi 0.156.0 → 0.158.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -1
  5. data/docs/architecture.md +18 -12
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +4 -1
  8. data/docs/cli.md +29 -24
  9. data/docs/configuration.md +43 -9
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +12 -7
  12. data/docs/hooks.md +53 -83
  13. data/docs/internals/bridge.md +84 -7
  14. data/docs/internals/bundles.md +69 -46
  15. data/docs/internals/context-telemetry.md +4 -3
  16. data/docs/internals/engine.md +23 -7
  17. data/docs/internals/participants.md +3 -2
  18. data/docs/internals/remote-sessions.md +5 -2
  19. data/docs/internals/web-frontend.md +3 -2
  20. data/docs/internals/working-set.md +1 -1
  21. data/docs/memory.md +51 -10
  22. data/docs/plugins.md +48 -11
  23. data/docs/releasing.md +6 -5
  24. data/docs/sessions.md +13 -13
  25. data/docs/sub-agent.md +17 -2
  26. data/docs/testing.md +56 -5
  27. data/lib/samagotchi/answer_command.rb +2 -1
  28. data/lib/samagotchi/archive_store.rb +4 -3
  29. data/lib/samagotchi/bootstrap_command.rb +3 -2
  30. data/lib/samagotchi/bridge/card_places.rb +5 -7
  31. data/lib/samagotchi/bridge/card_store.rb +3 -0
  32. data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
  33. data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
  34. data/lib/samagotchi/bridge.rb +31 -6
  35. data/lib/samagotchi/broadcast/recipients.rb +8 -8
  36. data/lib/samagotchi/broadcast/triage_model.rb +1 -1
  37. data/lib/samagotchi/bundle_command/check.rb +41 -10
  38. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  39. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  40. data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  42. data/lib/samagotchi/bundle_command.rb +20 -45
  43. data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
  44. data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
  45. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
  46. data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
  47. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  48. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
  50. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  51. data/lib/samagotchi/child_move.rb +6 -6
  52. data/lib/samagotchi/child_reports.rb +2 -2
  53. data/lib/samagotchi/children_status.rb +15 -21
  54. data/lib/samagotchi/cli/parent_wait.rb +8 -22
  55. data/lib/samagotchi/config.rb +3 -0
  56. data/lib/samagotchi/context_command.rb +7 -5
  57. data/lib/samagotchi/context_sources.rb +9 -6
  58. data/lib/samagotchi/context_window.rb +9 -12
  59. data/lib/samagotchi/effective_model.rb +57 -22
  60. data/lib/samagotchi/engine/plugins.rb +1 -1
  61. data/lib/samagotchi/engine/recap.rb +8 -4
  62. data/lib/samagotchi/engine.rb +30 -9
  63. data/lib/samagotchi/events.rb +3 -2
  64. data/lib/samagotchi/extension_load.rb +26 -44
  65. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  66. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  67. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  68. data/lib/samagotchi/guardrails_command.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +2 -2
  70. data/lib/samagotchi/hooks.rb +2 -3
  71. data/lib/samagotchi/host_address.rb +52 -0
  72. data/lib/samagotchi/host_registry.rb +57 -45
  73. data/lib/samagotchi/kernel_loop.rb +7 -3
  74. data/lib/samagotchi/llm/chat_loop.rb +6 -3
  75. data/lib/samagotchi/llm_context_strategy.rb +27 -63
  76. data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
  77. data/lib/samagotchi/memory_bundle/builder.rb +12 -35
  78. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  79. data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
  80. data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
  81. data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
  82. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  83. data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
  84. data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
  85. data/lib/samagotchi/memory_bundle/installer.rb +212 -178
  86. data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
  87. data/lib/samagotchi/memory_bundle/merger.rb +69 -65
  88. data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
  89. data/lib/samagotchi/memory_bundle/profile.rb +5 -5
  90. data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
  91. data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
  92. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  93. data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
  94. data/lib/samagotchi/memory_bundle/status.rb +26 -30
  95. data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
  96. data/lib/samagotchi/memory_bundle/trash.rb +28 -18
  97. data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
  98. data/lib/samagotchi/memory_orphans.rb +4 -4
  99. data/lib/samagotchi/memory_paths.rb +102 -13
  100. data/lib/samagotchi/memory_usage.rb +1 -1
  101. data/lib/samagotchi/model_lookup.rb +75 -0
  102. data/lib/samagotchi/model_profile.rb +1 -0
  103. data/lib/samagotchi/models_command.rb +1 -1
  104. data/lib/samagotchi/note_command.rb +1 -1
  105. data/lib/samagotchi/participant_mode.rb +2 -1
  106. data/lib/samagotchi/plugin/api.rb +42 -10
  107. data/lib/samagotchi/plugin/loader.rb +75 -15
  108. data/lib/samagotchi/plugin/sessions.rb +1 -1
  109. data/lib/samagotchi/project_command.rb +167 -0
  110. data/lib/samagotchi/project_init.rb +69 -0
  111. data/lib/samagotchi/project_scope.rb +6 -4
  112. data/lib/samagotchi/prompt_files.rb +10 -7
  113. data/lib/samagotchi/reasoning_history.rb +14 -20
  114. data/lib/samagotchi/recap_place.rb +26 -0
  115. data/lib/samagotchi/reply_wait/liveness.rb +38 -0
  116. data/lib/samagotchi/reply_wait/poll.rb +44 -15
  117. data/lib/samagotchi/reply_wait/target.rb +113 -0
  118. data/lib/samagotchi/reply_wait.rb +22 -2
  119. data/lib/samagotchi/run_options.rb +1 -0
  120. data/lib/samagotchi/sampling_settings.rb +9 -16
  121. data/lib/samagotchi/self_report.rb +34 -35
  122. data/lib/samagotchi/send_command.rb +17 -23
  123. data/lib/samagotchi/session.rb +349 -441
  124. data/lib/samagotchi/session_access/contract.rb +1 -1
  125. data/lib/samagotchi/session_access/local.rb +5 -6
  126. data/lib/samagotchi/session_access/remote.rb +5 -3
  127. data/lib/samagotchi/session_field.rb +134 -0
  128. data/lib/samagotchi/session_inbox.rb +49 -1
  129. data/lib/samagotchi/session_manager/emptiness.rb +2 -1
  130. data/lib/samagotchi/session_manager/listing.rb +29 -100
  131. data/lib/samagotchi/session_manager.rb +2 -1
  132. data/lib/samagotchi/session_metrics.rb +7 -4
  133. data/lib/samagotchi/session_paths.rb +57 -0
  134. data/lib/samagotchi/session_retention.rb +2 -2
  135. data/lib/samagotchi/session_row.rb +200 -0
  136. data/lib/samagotchi/session_setup.rb +15 -12
  137. data/lib/samagotchi/sessions_command.rb +11 -129
  138. data/lib/samagotchi/steer.rb +10 -0
  139. data/lib/samagotchi/steer_cut_after.rb +7 -14
  140. data/lib/samagotchi/system_prompt.rb +20 -11
  141. data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
  142. data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
  143. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  144. data/lib/samagotchi/terminal_ui.rb +3 -1
  145. data/lib/samagotchi/testing/bundle.rb +5 -12
  146. data/lib/samagotchi/testing/install.rb +5 -4
  147. data/lib/samagotchi/testing/recorder.rb +1 -1
  148. data/lib/samagotchi/testing.rb +1 -1
  149. data/lib/samagotchi/thinking.rb +16 -21
  150. data/lib/samagotchi/tool_declarations.rb +1 -1
  151. data/lib/samagotchi/tool_response.rb +5 -3
  152. data/lib/samagotchi/tool_runner.rb +18 -1
  153. data/lib/samagotchi/toolkit_binding.rb +1 -4
  154. data/lib/samagotchi/toolkit_command.rb +1 -4
  155. data/lib/samagotchi/tools/delegate.rb +60 -51
  156. data/lib/samagotchi/tools/delegate_relay.rb +5 -5
  157. data/lib/samagotchi/tools/delegate_result.rb +1 -1
  158. data/lib/samagotchi/tools/delegate_wait.rb +41 -17
  159. data/lib/samagotchi/tools/list_sessions.rb +7 -7
  160. data/lib/samagotchi/update_command.rb +11 -7
  161. data/lib/samagotchi/verify_fresh.rb +7 -11
  162. data/lib/samagotchi/version.rb +1 -1
  163. data/lib/samagotchi/vision_support.rb +11 -14
  164. data/lib/samagotchi/web/app.rb +66 -21
  165. data/lib/samagotchi/web/message_display.rb +57 -7
  166. data/lib/samagotchi/web/participant_access.rb +1 -1
  167. data/lib/samagotchi/web/project_list.rb +3 -2
  168. data/lib/samagotchi/web/public/activity.js +16 -7
  169. data/lib/samagotchi/web/public/app.js +29 -8
  170. data/lib/samagotchi/web/public/bubbles.js +8 -3
  171. data/lib/samagotchi/web/public/cards_place.js +15 -0
  172. data/lib/samagotchi/web/public/commands.js +5 -1
  173. data/lib/samagotchi/web/public/data.js +4 -2
  174. data/lib/samagotchi/web/public/history_shape.js +220 -0
  175. data/lib/samagotchi/web/public/index.html +6 -1
  176. data/lib/samagotchi/web/public/project_picker.js +86 -4
  177. data/lib/samagotchi/web/public/recap_stamp.js +17 -0
  178. data/lib/samagotchi/web/public/reload_check.js +71 -0
  179. data/lib/samagotchi/web/public/stage_model.js +7 -5
  180. data/lib/samagotchi/web/public/stage_view.js +7 -3
  181. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  182. data/lib/samagotchi/web/public/timing.js +43 -2
  183. data/lib/samagotchi/web/public/turn_block.js +215 -30
  184. data/lib/samagotchi/web/public/turn_drawing.js +43 -0
  185. data/lib/samagotchi/web/public/turn_events.js +10 -1
  186. data/lib/samagotchi/web/public/turn_pieces.js +29 -0
  187. data/lib/samagotchi/web/reload_check_route.rb +95 -0
  188. data/lib/samagotchi/web/server.rb +4 -2
  189. data/lib/samagotchi/web/session_access_routes.rb +3 -3
  190. data/lib/samagotchi/web/session_control.rb +1 -1
  191. data/lib/samagotchi/web/session_hub.rb +9 -17
  192. data/lib/samagotchi/web/session_show.rb +64 -2
  193. data/lib/samagotchi/web/session_summary.rb +36 -24
  194. data/lib/samagotchi/worker.rb +28 -5
  195. data/lib/samagotchi/worker_link.rb +3 -0
  196. metadata +22 -5
  197. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
  198. data/lib/samagotchi/send_command/wait_target.rb +0 -85
@@ -1,7 +1,7 @@
1
1
  # Memory bundles: install, upgrade and build
2
2
 
3
3
  How `lib/samagotchi/memory_bundle/` turns a bundle (a dir, git URL, zip or tar holding `manifest.yml`, `*.md`
4
- memories and optional `hooks/`, `guardrails/`, a plugin file and `scripts/`) into installed state, and back. The
4
+ memories and optional `guardrails/`, a plugin file and `scripts/`) into installed state, and back. The
5
5
  user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memory.md#what-a-bundle-owns) and
6
6
  [plugins.md](../plugins.md) and [bundles.md](../bundles.md).
7
7
 
@@ -21,7 +21,8 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
21
21
  #write, #refresh_marks, gone with #resolve_conflicts; #prune_incoming takes a settled file
22
22
  at any depth and the dirs it leaves empty; read by BundleCommand#print_incoming for `chi bundle diff`);
23
23
  nothing globs it for memories (Builder takes the scope dir's top level), and an uninstall's rm_rf sweeps it
24
- <name>/hooks/ guardrails/ plugin/ scripts/
24
+ <name>/guardrails/ plugin/ scripts/
25
+ <name>/hooks/ an older chi's class-file hooks (none loads; the next install or upgrade removes it)
25
26
  .trash/<name>-<stamp>/ files an uninstall or upgrade took away, and copies of what an install
26
27
  overwrote (Trash), never deleted
27
28
  ```
@@ -36,65 +37,85 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
36
37
  | `BundleCommand::ApiCalls` (bundle_command/api_calls.rb) | what a plugin's source calls of the API, by Prism: `#findings` (a call or keyword the API's classes lack, `check`'s `api`), `#keywords` (the floor check's `call(keyword:)`). Its only list is `MEMBERS`, which object a member gives (`ctx.sessions` → `Plugin::Sessions`); a new such object joins it |
37
38
  | `BundleCommand::Scaffold` (bundle_command/scaffold.rb) | `chi bundle new NAME`: the starting files (block-form `plugin:` entry, `requires_chi` at this chi, a kit test that passes as written); the manifest's shas are filled through `Shas.refresh` |
38
39
  | `BundleCommand::TestRun` (bundle_command/test_run.rb) | `chi bundle test`: `RbConfig.ruby -I <chi's lib>` on `test/**/*_test.rb` (Minitest) or `spec/` (RSpec, as `rake bundles:specs`), under `Bundler.with_unbundled_env` when Bundler's runtime is loaded |
39
- | `Shas` (shas.rb) | a manifest's `sha256:` lines: `.refresh(dir[, text])` rewrites them line by line (the layout kept; a `files:` entry it can't place raises `Shas::Error`), `.stale` → `[Stale(file, old, new)]` (`old` `""` for a placeholder, nil for a plugin / hook / script entry with no sha line), `.parseable` (a placeholder `sha256: sha256:` isn't YAML). Plain Ruby: `rake bundles:sha` / `bundles:check` (`script/release/release_tools.rb`) and `chi bundle check --fix` share it |
40
- | `FileDigest` (file_digest.rb) | the one digest of a file on disk: `.hex(path)` (its bytes, streamed; nil when the path isn't a file, a raise for one that can't be read), `.sha256(path)` = `"sha256:<hex>"`, nil with no file — the shape a record writes for a hook, rule, plugin or script. `Provenance.file_sha`, `Merger.digest`, `Status.checksum`, `InstalledBundle#source_sha`, `FileStanding.differs?` and the Installer's checksum verifiers all go through it; a digest of bytes already in memory (a merge's text, a memory's content that is also its base) stays with the code holding them |
40
+ | `Shas` (shas.rb) | a manifest's `sha256:` lines: `.refresh(dir[, text])` rewrites them line by line (the layout kept; a `files:` entry it can't place raises `Shas::Error`), `.stale` → `[Stale(file, old, new)]` (`old` `""` for a placeholder, nil for a plugin / script entry with no sha line), `.parseable` (a placeholder `sha256: sha256:` isn't YAML). Plain Ruby: `rake bundles:sha` / `bundles:check` (`script/release/release_tools.rb`) and `chi bundle check --fix` share it |
41
+ | `FileDigest` (file_digest.rb) | the one digest of a file on disk: `.hex(path)` (its bytes, streamed; nil when the path isn't a file, a raise for one that can't be read), `.sha256(path)` = `"sha256:<hex>"`, nil with no file — the shape a record writes for a rule, plugin or script. `Provenance.file_sha`, `Merger.digest`, `Status.checksum`, `InstalledBundle#source_sha`, `FileStanding.differs?` and the Installer's checksum verifiers all go through it; a digest of bytes already in memory (a merge's text, a memory's content that is also its base) stays with the code holding them |
41
42
  | `SourceNormalizer` (source.rb) | source → local dir; a git/zip/tar extract is *owned* and removed after the install |
42
43
  | `SourceIdentity` (source.rb) | what a source is, for comparing two installs of one name: `shipped:<dir>` (any gem or checkout), `git:<url>` (no `#ref`, trailing `/` or `.git`), `path:<realpath>`; nil when it can't place one |
43
44
  | `PreparedSource` (source.rb) | one source normalized once (dir, owned, git commit): `BundleCommand` reads the bundle's name from its `manifest.yml` (the name for every kind of source, a git URL too) and hands it to the Installer (`prepared:`), so a source is cloned or extracted once per command |
44
45
  | `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) |
45
- | `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). `KEYS` is every top-level key it reads, so a key outside it is one this chi ignores: `#unknown_keys`, in the order the manifest lists them, said in `Manifest.unknown_key_line`'s words by the Installer and `chi bundle check`. `SINCE` is the first release that read each key or shape (taken from the tags), and `#floor_keys` names the ones this manifest uses — including the shapes only a newer chi reads (a `files:` mapping, a hook's `events:`, a typed `session_settings:`/`settings:`) — for check's `requires-chi-floor` |
46
+ | `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, 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). `KEYS` is every top-level key it reads, so a key outside it is one this chi ignores: `#unknown_keys`, in the order the manifest lists them, said in `Manifest.unknown_key_line`'s words by the Installer and `chi bundle check`. `SINCE` is the first release that read each key or shape (taken from the tags), and `#floor_keys` names the ones this manifest uses — including the shapes only a newer chi reads (a `files:` mapping, a typed `session_settings:`/`settings:`) — for check's `requires-chi-floor`. A `hooks:` entry is `Manifest::HooksRetired` (a `ValidationError`; `WORDS` is the one wording of the retirement, `.for_key`, `.for_files`, `.files_in` a source's `hooks/*.rb`) on a strict read and ignored by `Manifest.tolerant` |
46
47
  | `Installer` | one install/upgrade/dry run of one bundle (below) |
47
- | `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) |
48
- | `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) |
48
+ | `AssetInstaller` | the parts that live in `.bundles/<name>/`: guardrail rules, plugin, scripts (and `#retire_hooks`: an older chi's `hooks/` removed, below); 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_edits`, 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). The rule alone, read-only, is `Trash.edit?` / `Trash.edits`. A dry run reports each asset as the real run would (`would_install`, `would_update`, up to date): one result, `#result_for`, recorded as is or through `#planned` |
49
+ | `FileState` (file_state.rb) | one bundle memory as a command meets it, derived, never stored: `FileState.of(key, record:, provenance:, target_dir:, source_dir:, claimants:, shipped:)` reads the file (D), its base (B), the bundle's text (I) and the record's entry (R) once into `Digests` (`disk`, `base`, `incoming`, `recorded`, `marked_incoming`; `:unreadable` for a file it can't read) and names its `kind` and `mark` (`:none`, `:current`, `:legacy` = `InstalledBundle.legacy_mark?`). With a source (install, upgrade, remerge, restore, a plan): `:new`, `:user_same`, `:user_file` (not the bundle's), `:missing`, `:level` (D == I), `:pristine`, `:edited`, `:diverged`, `:pristine_no_base`, `:edited_no_base`, `:dropped_pristine`, `:dropped_edited`, `:dropped_missing` (`shipped: false`: the bundle no longer ships the key), `:unreadable`; with `source_dir: nil` (status, uninstall, diff) only `:pristine`, `:edited`, `:pristine_no_base`, `:edited_no_base`, `:missing`, `:unreadable`. Edited or not is always `#edited?` = `!Provenance.unchanged?` (the one edited rule: the checksum first, then the base); the base then says what changed, and a base the checksum contradicts (equal to an edited file) is no base to merge from (`:edited_no_base`). `#source_moved?` (a mark that speaks of other text than the bundle ships: Remerge's first question), `#shared_with` (the other bundles listing it, `Provenance.claimants` asked lazily, at most once: only a dropped file and an uninstall ask) |
50
+ | `FileActions` (file_actions.rb) | what each command that acts does in each state: one table per command (`INSTALL`, `FORCE` = install and upgrade `--force`, `UPGRADE`, `REMERGE`, `RESTORE`, `UNINSTALL`), a cell keyed `[kind, mark]`, `[kind, :any]` or `[kind, :shared]`, its value an `Action` (`do`: `:write_incoming`, `:merge`, `:decide`, `:keep`, `:skip`, `:move_to_trash`, `:refuse`, `:restore`; `save_first`; `next_base`: `:on_disk`, `:incoming`, `:recorded`, `:none`; `word`, the report bucket; `record`: `:own`, `:keep_entry`, `:mark`, `:hold_mark`, `:clear_mark`, `:drop`, `:not_owned`). `.for(command, state)`. A `:decide` cell (`[:diverged, :any]`, the legacy guard's `[:pristine, :legacy]`) isn't a function of kind and mark: `Merger::Upgrade#decision` answers it with an `Outcome`, which `.decided(outcome, command)` turns into the action. A dry run reads its command's row and doesn't do it (`Installer::DRY_WORDS` gives its word). A new kind or a new acting command adds a row in each table it reaches; `file_actions_spec` checks every cell against the bundle-state corpus |
51
+ | `Merger` | the upgrade's merge. `Merger::Upgrade` (provenance, target dir, source dir) is the one way to ask what an upgrade does to a file, nothing written: `#decision(key[, state])` → a `Decision` (the `FileState`, the UPGRADE action with a `:decide` cell answered, and the `Outcome`), `#outcome` its `Outcome` (`status`: `install`, `fast_forward`, `keep`, `noop`, `conflict`, `merged`; `base`: the next base, `Action#next_base`'s words), `#state`, `#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, `Remerge`, `ShippedUpdate.plan` and `SystemBundle.sync(dry_run:)`. A `:decide` cell runs `.merge_outcome` (`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; no base: `TextMerge::NO_BASE`, a base the checksum contradicts: `FileStanding::LEGACY_BASE`). `.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: the upgrade's `[:pristine, :legacy]` cell decides: `#decision` 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. A fast-forward that goes ahead over a mark is a `save_first` cell (`Installer#save_first`): 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) |
49
52
  | `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 |
50
53
  | `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 |
51
54
  | `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 |
52
- | `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`). 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) |
53
- | `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). `#write(manifest_from: DIR)` keeps `DIR/manifest.yml` at `#kept_manifest_path` (written aside and renamed; for `dev:` a link to its realpath) and records `manifest_sha` (`FileDigest.sha256`, `DEV_SHA` for dev) and `installed_by` (this chi's VERSION); the Installer and `Profile.install` pass their source dir, and a write without it carries the existing record's two fields, the kept file left alone. The in-place edits (`#resolve_conflicts`, `#record_restored`, `#refresh_marks`, `#note_merged`, `#clear_merge_notes`) keep them. The record's mirror of the manifest keys (`requires_chi` … `setting_types`) is still written from the kwargs, exactly as before: an older chi sharing the memories dir (an old worker, chi web, gem or worktree) reads only the record, and without the mirror's `requires_chi` it would load a too-new bundle's plugin and hooks. A key a later manifest gains is never mirrored. Any chi without the kept manifest that rewrites a record drops `manifest_sha` and `installed_by` (it rebuilds the record from its own kwargs). `#adopt_manifest(dir, dry_run:)` heals such a record (`InstalledBundle#manifest_unkept?`: no `manifest_sha`, or its file gone): when `dir` is the source of what's installed (its manifest names the bundle at the recorded version; each mirrored key, normalised as `.mirror_fields` records it (the one shape `#write` uses too), equals the record's, a key the record lacks being fine only when `Manifest::SINCE` has it, since an older chi couldn't have recorded it; each recorded hook's events, on_error and priority are the manifest's; and every recorded plugin, hook, script, rule and unmarked memory sha is that of `dir`'s file; else the why names the key or file) it keeps `dir/manifest.yml` and records `manifest_sha`, nothing else (not `installed_at`, the mirror or `installed_by`); otherwise it returns why and writes nothing. Never for a dev record (its next `install --dev` links the file) |
54
- | `InstalledManifest` (installed_manifest.rb) | the one answer to "this installed bundle's manifest" (`InstalledBundle#manifest`): requires_chi, trust_level, hook metadata, needs, the scripts' names, context providers, block renderers, session settings and settings with their types, file descriptions, a profile's includes. Read from the kept `manifest.yml` (`origin: :kept`) when the record's `manifest_sha` is set and the file still has it (`Provenance.sha_matches?`, so a dev link is never checked), else from the record's `mirror` (`origin: :record`), in these cases: no kept file and no `manifest_sha` (a record from before chi kept it; no problem); no kept file, or something other than a file in its place, while `manifest_sha` is set (`:missing`); a kept file and no `manifest_sha` (a chi without the kept manifest rewrote the record: `problems["manifest.yml"] = :stale`); a kept file with another sha, or one that isn't a YAML mapping (`:edited`); the sha matches (`:kept`). `#standing` is that symbol, `#key_problems` the rest. The kept file is read by `Manifest.tolerant` key by key in a fixed order (its comment): a key that doesn't parse under this chi takes the mirror's value if that parses, else empty, with its message in `problems` (a reader that logged it logs that); `block_renderers` is checked against the record's scripts when the kept `scripts:` fails, and one bad hook entry keeps the record's hooks whole. Hooks: each *recorded* hook with the kept manifest's events, on_error and priority and the record's sha (a found `hooks/*.rb` with no entry as recorded; a manifest hook the record lacks isn't installed, so isn't listed). `description` and `unknown_keys` come only from a kept file; a profile's `includes` is always the record's (its members, identity). The value is deep-frozen, since callers share it. One process-wide cache (a Hash under a Mutex, cleared at `CACHE_LIMIT`) keyed by the record value and the kept file's stat (mtime, size, inode, device; a dev link's target): chi web asks per request, a worker at start and per `ContextProviders.resolve`. `#requires_chi_failure(version = VERSION)` is the one requires_chi gate; `#experimental?`, `#needs?`, `#context_providers?`, `#block_renderers?` |
55
+ | `Remerge` | the retry at the same version, memory files only, for a file marked `conflict` (its `FileState` and `REMERGE` row; a `:decide` cell through `Merger::Upgrade#decision`): `#run(dry_run:)` → a `Result` (`merged`, `settled`, `stamped`, `behind`, `warnings`, `trash_copy`). It merges what merges and settles those entries (`Provenance#resolve_conflicts`, the index line refreshed), checking a merged incoming file against the manifest's sha256, in a dry run too. It leaves `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) |
56
+ | `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). `#write(manifest_from: DIR)` keeps `DIR/manifest.yml` at `#kept_manifest_path` (written aside and renamed; for `dev:` a link to its realpath) and records `manifest_sha` (`FileDigest.sha256`, `DEV_SHA` for dev) and `installed_by` (this chi's VERSION); the Installer and `Profile.install` pass their source dir, and a write without it carries the existing record's two fields, the kept file left alone. The in-place edits (`#resolve_conflicts`, `#record_restored`, `#refresh_marks`, `#note_merged`, `#clear_merge_notes`) keep them. The record's mirror of the manifest keys (`requires_chi` … `setting_types`) is still written from the kwargs, exactly as before: an older chi sharing the memories dir (an old worker, chi web, gem or worktree) reads only the record, and without the mirror's `requires_chi` it would load a too-new bundle's plugin. A key a later manifest gains is never mirrored. Any chi without the kept manifest that rewrites a record drops `manifest_sha` and `installed_by` (it rebuilds the record from its own kwargs). `#adopt_manifest(dir, dry_run:)` heals such a record (`InstalledBundle#manifest_unkept?`: no `manifest_sha`, or its file gone): when `dir` is the source of what's installed (its manifest names the bundle at the recorded version; each mirrored key, normalised as `.mirror_fields` records it (the one shape `#write` uses too), equals the record's, a key the record lacks being fine only when `Manifest::SINCE` has it, since an older chi couldn't have recorded it (a recorded hook doesn't count: retired); and every recorded plugin, hook, script, rule and unmarked memory sha is that of `dir`'s file; else the why names the key or file) it keeps `dir/manifest.yml` and records `manifest_sha`, nothing else (not `installed_at`, the mirror or `installed_by`); otherwise it returns why and writes nothing. Never for a dev record (its next `install --dev` links the file) |
57
+ | `InstalledManifest` (installed_manifest.rb) | the one answer to "this installed bundle's manifest" (`InstalledBundle#manifest`): requires_chi, trust_level, needs, the scripts' names, context providers, block renderers, session settings and settings with their types, file descriptions, a profile's includes. Read from the kept `manifest.yml` (`origin: :kept`) when the record's `manifest_sha` is set and the file still has it (`Provenance.sha_matches?`, so a dev link is never checked), else from the record's `mirror` (`origin: :record`), in these cases: no kept file and no `manifest_sha` (a record from before chi kept it; no problem); no kept file, or something other than a file in its place, while `manifest_sha` is set (`:missing`); a kept file and no `manifest_sha` (a chi without the kept manifest rewrote the record: `problems["manifest.yml"] = :stale`); a kept file with another sha, or one that isn't a YAML mapping (`:edited`); the sha matches (`:kept`). `#standing` is that symbol, `#key_problems` the rest. The kept file is read by `Manifest.tolerant` key by key in a fixed order (its comment): a key that doesn't parse under this chi takes the mirror's value if that parses, else empty, with its message in `problems` (a reader that logged it logs that); `block_renderers` is checked against the record's scripts when the kept `scripts:` fails, and an older install's `hooks:` is ignored (no problem row). `description` and `unknown_keys` come only from a kept file; a profile's `includes` is always the record's (its members, identity). The value is deep-frozen, since callers share it. One process-wide cache (a Hash under a Mutex, cleared at `CACHE_LIMIT`) keyed by the record value and the kept file's stat (mtime, size, inode, device; a dev link's target): chi web asks per request, a worker at start and per `ContextProviders.resolve`. `#requires_chi_failure(version = VERSION)` is the one requires_chi gate; `#experimental?`, `#needs?`, `#context_providers?`, `#block_renderers?` |
55
58
  | `ManifestStanding` (manifest_standing.rb) | the one owner of the words for an installed bundle's `manifest.yml` as `InstalledManifest` read it: `.state` (`:kept`, `:legacy`, `:stale`, `:missing`, `:edited`), `.label` (`[kept]` / `[from the install record]`), `.line(bundle, refused:)` (the case and the command: `chi bundle upgrade NAME` (reads it again from a git URL, or a source that didn't match, with why), `chi update keeps it` while untried, the source is gone (`Installer.source_gone_line`), `chi bundle install --dev SOURCE` for a dev record; for a kept one, only the keys whose `Manifest::SINCE` floor is above the record's `installed_by`: newer than its installer), `.keep(bundle, source_dir:, dry_run:)` → `Outcome` (the record after, the line, `kept`): `Provenance#adopt_manifest` from `source_dir`, else from the recorded source (a dir; a zip/tar through `PreparedSource`, cleaned up; never a git clone). Read by `ShippedUpdate` (`Row#manifest`, the plan's dry `keep` and apply's), `UpdateCommand#external_rows` (a `kept its manifest` row, else a warn row, before the bundle's re-check, which then sees the kept file) and `chi bundle status NAME` (`BundleCommand#manifest_line`: `Manifest:`, the label, `.status_line`: for a record with a folder source (a bundle chi ships: this chi's shipped copy at the recorded version, which `chi update` reads), a dry `adopt_manifest` first, so the line says whether `chi update` keeps it or why not; an archive isn't extracted there, its line says `chi update tries to keep it`; a profile chi doesn't ship is never kept by `chi update`, so its line names `chi bundle upgrade NAME`) |
56
- | `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty). `InstalledBundle`'s members are identity and state (name, version, scope, source, `installed_at`, `source_commit`, files and their marks, hooks, guardrails, scripts and the plugin with their shas, a profile's `includes`, `dev`, `dir`, the kept manifest's `manifest_sha` and `installed_by`, nil on a record from before it was kept); the manifest keys it records sit in one `mirror` Hash (`MIRROR_KEYS`, frozen: a key a later manifest gains is never mirrored) that only `InstalledManifest` reads, so manifest data is read through `#manifest`. `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 |
59
+ | `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty). `InstalledBundle`'s members are identity and state (name, version, scope, source, `installed_at`, `source_commit`, files and their marks, hooks, guardrails, scripts and the plugin with their shas, a profile's `includes`, `dev`, `dir`, the kept manifest's `manifest_sha` and `installed_by`, nil on a record from before it was kept); the manifest keys it records sit in one `mirror` Hash (`MIRROR_KEYS`, frozen: a key a later manifest gains is never mirrored) that only `InstalledManifest` reads, so manifest data is read through `#manifest`. `BundleHook` is an older record's hook entry: `.parse` and `#sha256` (what the retire, uninstall and the trash selection read) |
57
60
  | `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)) |
58
- | `Builder` | `chi bundle build`: memories (+ the installed bundle's hooks, rules, plugin, scripts) → zip/tar/dir |
61
+ | `Builder` | `chi bundle build`: memories (+ the installed bundle's rules, plugin, scripts; never an older record's hooks) → zip/tar/dir |
59
62
  | `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 |
60
- | `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`). At an equal version both keep the shipped `manifest.yml` for a record an older chi wrote (`Provenance#adopt_manifest` from the shipped dir): `SystemBundle.sync` under its lock, not in a dry run (so at the first Engine start or `chi update` after the upgrade, once), and `ShippedUpdate.apply` for a row that ends `:up_to_date` or `:merged` (`#keep_manifest`, through `ManifestStanding.keep`: `Row#manifest` is its line, `would keep its manifest` from the plan's dry run, appended to the row's status); a bumped bundle gets it from its Installer. `ShippedUpdate.external` is the rest: what `chi update` only checks |
61
- | `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/`; its `files:`, `hooks:`, `plugin:` and `scripts:` are always what the record and the copy hold, so a memory the install left out, one with no base or a manifest hook the record lacks gives no `missing-file`, and every other key is the kept `manifest.yml`'s when `InstalledManifest` read it, so the check sees its unknown keys, else the mirror's; a kept key that didn't parse keeps the value `InstalledManifest` fell back to, since `InstalledCheck` already names its problem); `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 |
62
- | `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`) |
63
+ | `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`), checked against the manifest's `files:` sha as an installed one is (`FileDigest.mismatch`). A sync's `Result#warnings` reach `chi update`'s row (`UpdateCommand#system_bundle_row`) and `chi bootstrap`'s line (`BootstrapCommand#system_bundle_line`); `ShippedUpdate.apply` puts an update's `Checksum mismatch` warnings in `Row#note`. `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`). At an equal version both keep the shipped `manifest.yml` for a record an older chi wrote (`Provenance#adopt_manifest` from the shipped dir): `SystemBundle.sync` under its lock, not in a dry run (so at the first Engine start or `chi update` after the upgrade, once), and `ShippedUpdate.apply` for a row that ends `:up_to_date` or `:merged` (`#keep_manifest`, through `ManifestStanding.keep`: `Row#manifest` is its line, `would keep its manifest` from the plan's dry run, appended to the row's status); a bumped bundle gets it from its Installer. `ShippedUpdate::Row#replaced` is what the upgrade copies to the trash (`ShippedUpdate.trash_bound`: `Trash.edits` / `Trash.edit?` against the shipped source over the rules, plugin and scripts folders and an older record's `hooks/` (which `#retire_hooks` removes whole), no recorded sha counting as edited), the same rule and files as `AssetInstaller`'s copies. `ShippedUpdate.external` is the rest: what `chi update` only checks |
64
+ | `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/`; its `files:`, `plugin:` and `scripts:` are always what the record and the copy hold (no `hooks:`), so a memory the install left out or one with no base gives no `missing-file`, and every other key is the kept `manifest.yml`'s when `InstalledManifest` read it, so the check sees its unknown keys, else the mirror's; a kept key that didn't parse keeps the value `InstalledManifest` fell back to, since `InstalledCheck` already names its problem); `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 |
65
+ | `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); `Provenance.unchanged?` (the record's checksum, else the base; neither counts as edited) is the one edited rule, read by `prune_dropped_files`, the uninstall's refusal and `chi bundle status`'s `modified`; `chi bundle status` (per file, `behind`, `merge_note`, `unreadable`; `issues=` counts behind, missing and unreadable, not edited); `chi bundle list` (`Installed#behind`) |
63
66
  | `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
64
67
  | `EntrySize` | one memory's size in tokens: `memory.entry_warn_tokens`' crossing note after a memory write, `chi self`'s `big memories` row |
65
68
  | `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`) |
66
69
  | `Placeholder` | `{{placeholder}}` patterns left in an installed memory, one line per file from `Installer#detect_placeholders`; a hint, never a gate |
67
70
 
68
71
  A dev install (`chi bundle install --dev PATH`, `Installer#initialize(dev: true)`) links instead of copying:
69
- `AssetInstaller#place` symlinks each hook, rule file, plugin and script into `.bundles/<name>/` to its realpath in
72
+ `AssetInstaller#place` symlinks each rule file, plugin and script into `.bundles/<name>/` to its realpath in
70
73
  the source (memories are still copied: their bases and the index are the install's), and the record gets `"dev": true`
71
74
  (`InstalledBundle#dev`, `source` the realpath) with `Provenance::DEV_SHA` (`sha256:dev`, never a hex digest) in
72
- place of every hook, rule, plugin and script sha256. `Provenance.sha_matches?` / `.unchecked?` read it as "not
75
+ place of every rule, plugin and script sha256. `Provenance.sha_matches?` / `.unchecked?` read it as "not
73
76
  checked", which is how the readers below skip it for that bundle only. `Installer#refuse_dev_mismatch` keeps a dev
74
77
  and a normal install from replacing each other (this is also why `chi bundle upgrade` refuses a dev bundle);
75
78
  `Uninstaller` removes links with `rm_rf`/`rm_f`, which never follow them into the source.
76
79
 
77
- Readers of the installed state: `Hooks::BundleLoader` (Engine), `GuardrailWiring#bundle_rules`, `Plugin::Loader`,
80
+ Readers of the installed state: `GuardrailWiring#bundle_rules`, `Plugin::Loader`,
78
81
  `ContextProviders` (scripts), `BundleNeeds` (index markers). Each goes through `Provenance.each_installed` / `#record`
79
82
  and checks the recorded sha256 (and `requires_chi`) before loading. `ContextProviders.resolve`/`.any?` skip a bundle
80
83
  whose `requires_chi` this chi doesn't meet, logging `providers_too_new` once per process per bundle and version (a
81
84
  Set under a Mutex, like `GuardrailWiring`'s kept-rules notice), and `.command_for` raises `Invalid` for a source
82
85
  attached to one. They read shas and state off the record, and
83
- manifest data (requires_chi, hook metadata, needs, providers, renderers, settings) through `InstalledBundle#manifest`
86
+ manifest data (requires_chi, needs, providers, renderers, settings) through `InstalledBundle#manifest`
84
87
  (`InstalledManifest`: the kept `manifest.yml` when its sha holds, else the record's mirror), as do `SessionConfig`, `BundleSettingsCheck`, `BlockRenderers`, `Status`, `BundleCommand`'s
85
- status and diff, `Builder` and `InstalledCopy`. Their requires_chi checks are `InstalledManifest#requires_chi_failure`;
86
- `Hooks::BundleLoader.load` is given the installed bundle's manifest and uses the same gate. `GuardrailWiring#rules_stamp` stats each bundle's
88
+ status and diff, `Builder` and `InstalledCopy`. Their requires_chi checks are `InstalledManifest#requires_chi_failure`. `GuardrailWiring#rules_stamp` stats each bundle's
87
89
  `manifest.yml` with its `manifest.json` and `guardrails/*`, so a changed requires_chi re-reads the rules. No reader
88
90
  calls `Manifest.new` on a kept file: `Manifest.tolerant` through `InstalledManifest` is the one read.
89
- The checks live in `Hooks::BundleLoader.load` and `Plugin::Loader.load_bundle`; the loading itself is
90
- `BundleLoader.load_hook_file` (one hook file, into a given `namespace:`) and `Plugin::Loader.register_file` (one
91
- plugin file: build, `register`, `commit!`), which raise instead of logging: the bundle test kit
91
+ A guardrail plugin (`plugin:` `required: true`) is known from `InstalledManifest#plugin_required` before
92
+ `Plugin::Loader.load_bundle` builds it: the install records the flag in the record's `plugin:` (`PluginRef#required`,
93
+ `Provenance#write`'s `plugin_required:`), and the kept manifest's flag is ORed with it, so a missing, edited or
94
+ unparsable kept manifest.yml never drops it (an older chi's `PluginRef` ignores the key):
95
+ its failure goes to the hook failures as required (`ExtensionLoad#load_plugins` passes them as `guardrail_failures:`),
96
+ where the gate denies every tool call; a `chi.on(on_error: :fail_closed)` hook is wrapped `Hooks.wrap(policy: :deny)`.
97
+ The checks live in `Plugin::Loader.load_bundle` (its `.sha_mismatch`; `.namespace_for` is the bundle's
98
+ `Samagotchi::Bundles::<bundle>` module); the loading itself is `Plugin::Loader.register_file` (one
99
+ plugin file: build, `register`, `commit!`), which raises instead of logging: the bundle test kit
92
100
  (`Samagotchi::Testing::Bundle`) loads a bundle's source through them, unchecked. `Samagotchi::Testing::Install` is the
93
- checked path in a sandbox: the Installer, then `ExtensionLoad` (bundle hooks, then `load_plugins`), returning the
101
+ checked path in a sandbox: the Installer, then `ExtensionLoad` (config hooks, then `load_plugins`), returning the
94
102
  Installer's warnings, this bundle's load failures and what it registered (`Install.registered`, an `Install::Registered` value, also the parity
95
103
  spec's shape). A load failure carries `LoadFailures::Failure#location` (the first backtrace frame inside the
96
- bundle, or a SyntaxError's own line), which `Install` maps from the installed copy (`.bundles/<name>/plugin/`,
97
- `hooks/`) back to the source dir.
104
+ bundle, or a SyntaxError's own line), which `Install` maps from the installed copy (`.bundles/<name>/plugin/`)
105
+ back to the source dir.
106
+
107
+ Class-file hooks (retired). A record an older chi installed may still have a `hooks` map and `hooks/*.rb`
108
+ (`InstalledBundle#hooks`). Nothing loads them: `ExtensionLoad#hooks` adds one not-required failure per such record
109
+ (`hooks (bundle X)`, `Manifest::HooksRetired::WORDS`), which the Engine announces at the first turn; `Status`'s
110
+ `retired_hooks` gives `chi bundle status` its `Hooks (N): not loaded` lines (and the list an issue), and
111
+ `BundleCommand::InstalledCheck` a `hooks-retired` row for `chi update` (an `InstalledCopy` has no hooks). A source with
112
+ hooks is refused before its manifest is read (`Installer#refuse_hooks`, then `Manifest.read`), strict or not, a dry
113
+ run too; `ShippedUpdate`'s plan skips one the same way. Over such a record, `AssetInstaller#retire_hooks` (after the
114
+ plugin and scripts, so a refused install leaves them) copies every edited or unknown file under `hooks/` to the trash
115
+ (`Trash#copy_edits` with the recorded shas; a dev link never), removes the dir and records each as `:removed` (a dry
116
+ run's `:would_remove`, through `#planned`) with the `AssetInstaller::RETIRED` note, which `#summary` lists on a line of
117
+ its own; `ShippedUpdate#trash_bound` names the same files (`Trash.edits` over `hooks/`). `Provenance#write` records
118
+ `"hooks" => {}` and deletes the old hook bases. An older chi reading such a record sees a bundle with no hooks.
98
119
 
99
120
  Records from older chi. A record written before the kept manifest (**legacy**: no `manifest.yml`, no
100
121
  `manifest_sha`), or rewritten since by such a chi (**stale**: a kept file, no `manifest_sha`; its file may be an
@@ -122,25 +143,25 @@ The order matters: warnings print in it, and a plugin/script the manifest names
122
143
  `#source_change` (the summary's first line, `Source changes: old → new`). A source `SourceIdentity` can't place
123
144
  (an older record's relative path) never refuses.
124
145
  3. `install_memories`, per memory (`MemoryFiles.select`: a non-empty `files:` list, else every top-level `*.md` but
125
- 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
126
- 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
146
+ README, CHANGELOG, LICENSE and the like; a listed name not shipped warns; a symlink is skipped with a warning): each file's `FileState` and its row in `FileActions`' `FORCE`, `UPGRADE` or `INSTALL` table (`#memory_command`) say what happens, done by `#write_memory`: an upgrade 3-way merges the files the bundle owns (a `:decide` cell through `Merger::Upgrade#decision`: 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, is a `save_first` cell: a copy in the trash first, `#save_first`), a dry run only
147
+ reads the row (`preview_memory`, `would_merge` for a merge, git running on temp copies), a plain install writes new files, takes the new text of an owned file never edited (`#edited?` false, unmarked: D4, an `Updated` as an upgrade's fast-forward) and skips the others, and `--force` overwrites, a file that
127
148
  differs copied to the trash first (`Trash#copy`, said by `#trash_copy_line`; a profile's in `InstallResult#trash_copies`). A
128
149
  same-name file the bundle didn't write is the user's: skipped and never recorded (`@owned`). Model overlays
129
150
  (`<name>.<key>.md`) get no index line (`note_overlay`).
130
- 4. `AssetInstaller#run`: hooks (manifest-listed, else every `hooks/*.rb`), prune dropped hooks, hook checksums,
131
- 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
132
- file that is a symlink or resolves outside the bundle dir (`AssetInstaller#linked?`, a linked `hooks/` too) is
133
- skipped with a warning (a hook or rule) or raises `InstallError` (the plugin, a script).
134
- 5. An upgrade's `prune_dropped_files`: unchanged → trash; edited → kept (`kept_pruned`); owned by another bundle → kept.
135
- 6. `verify_memory_checksums` (strict), `warn_missing_needs`.
136
- 7. `write_provenance` (manifest, not a dry run): an upgrade's bases come from each file's `Merger::Outcome`
151
+ 4. `AssetInstaller#run`: guardrails, plugin, scripts, then `#retire_hooks` (an older record's `hooks/`); each set
152
+ replaces the previous version's, an edited file copied to the trash first. requires_chi warnings for the plugin and
153
+ rules. A file that is a symlink or resolves outside the bundle dir (`AssetInstaller#linked?`) is skipped with a
154
+ warning (a rule) or raises `InstallError` (the plugin, a script).
155
+ 5. An upgrade's `prune_dropped_files`: each dropped file's `FileState` (`shipped: false`) and its `UPGRADE` / `FORCE` row: unchanged (`#edited?` false) → trash; edited → kept (`kept_pruned`); owned by another bundle → kept.
156
+ 6. `verify_memory_checksums` (strict except a dev install, whose own tree may have stale shas; `chi bundle check` flags them: a memory whose action takes the bundle's text, `Action#takes_source?`: installed, forced, an upgrade's or a re-install's fast-forward, or merged into a local edit; it checks the source file the write or merge takes its bytes from, so a dry run checks what it would bring in; a `Remerge` merge checks its incoming file too), `warn_missing_needs`.
157
+ 7. `write_provenance` (manifest, not a dry run): each owned file's base comes from its action's `next_base`
137
158
  (`Installer#next_base`): the file on disk, which has the bundle's bytes; the incoming file for a merge, since the merged
138
159
  file holds the user's edit and taking it would make current == base, so the next upgrade would fast-forward over the
139
- edit (the edited signal stays current != base, which `chi bundle status` and uninstall's refusal read); and where the user's edit was
160
+ edit (the edited signal stays `Provenance.unchanged?` false, the checksum else the base, which `chi bundle status` and uninstall's refusal read); and where the user's edit was
140
161
  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
141
- 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
162
+ action's `:recorded`): 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
142
163
  entry and its recorded checksum): taking the user's file would let the next upgrade fast-forward over it. A plain
143
- install carries the `conflict` mark of a file it skipped (`held_conflict_marks`), a newer source moving `version`
164
+ install carries the `conflict` mark of a file it skipped (`held_conflict_marks`: the `:hold_mark` cells), a newer source moving `version`
144
165
  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
145
166
  owned and this version doesn't ship, while it is on disk (`dropped_owned_files`), so the next upgrade prunes it.
146
167
  8. `detect_placeholders`.
@@ -155,13 +176,15 @@ doesn't own: the Installer's `MemoryFiles.select`; `ShippedUpdate.forecast`' eve
155
176
  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
156
177
  run the manifest's `files:`, so a manifest with none plans nothing (the shipped one lists its files).
157
178
 
158
- Results are `@results` (file → `{status:, reason:}`), summarised by `#summary`: a file that already holds the
159
- bundle's bytes (`reason: "already up to date"`, a memory or a hook/rule/plugin/script) is listed as `Up to date:`,
160
- and only the files really left out (`already exists`, a dry run's `would_skip`, a dry run upgrade's `noop`) as
161
- `Skipped:`. A plain install over a bundle already installed from the same source ends with `NAME was already
162
- installed from SOURCE: its hooks, rules, plugin and scripts now match it and its record was rewritten`, plus
179
+ Results are `@results` (file → `Installer::FileResult`: its `word`, a memory's `FileState` and action, a `note`),
180
+ summarised by `#summary` by word: a file that already holds the bundle's bytes (`:up_to_date`, a memory or a
181
+ rule/plugin/script, in a dry run as in the real run) is listed as `Up to date:`, and only the files really left
182
+ out (`:kept_own`, a memory's dry-run `:would_skip`) as `Skipped:`. A memory's dry-run word is its action's through
183
+ `Installer::DRY_WORDS`. A dry run's `would_update` (a plugin or script an upgrade would change) is `Would update:`; an asset
184
+ row is never `would_skip`. A plain install over a bundle already installed from the same source ends with `NAME was already
185
+ installed from SOURCE: its rules, plugin and scripts now match it and its record was rewritten`, plus
163
186
  `; your own copies of its memories were kept (chi bundle upgrade NAME merges the bundle's changes into them)` when
164
- a memory was left out as `already exists`. An upgrade with conflicts keeps an
187
+ a memory was left out as the user's (`:kept_own`: an edited one, or one the bundle doesn't own; a never-edited one takes the new text and isn't named there). An upgrade with conflicts keeps an
165
188
  owned source dir alive (`#source_dir`) until `#cleanup_source!`, because the conflict agent reads the incoming
166
189
  files; `BundleCommand#settle_conflicts` then runs `Provenance#resolve_conflicts`, which moves a base forward and keeps
167
190
  the entry's description, only for a file the agent step changed that holds no markers (`Merger.markers?`), and whose incoming
@@ -171,7 +194,7 @@ copy is still there. Any other keeps its old base and mark, and the command exit
171
194
 
172
195
  `select_memories` (all but `index.md`/hidden; with `--files` the named ones + their overlays, else leaving out files
173
196
  another installed bundle owns) → `checksum_memories` → `installed_parts` (`Builder::InstalledParts`: the same-name
174
- installed bundle's hooks, rules, plugin, scripts, context providers, else the scope dir's `hooks/`/`guardrails/`) →
197
+ installed bundle's rules, plugin, scripts, context providers, else the scope dir's `guardrails/`) →
175
198
  `stage_files` + `write_manifest` (requires_chi, needs, settings… from the installed bundle's `InstalledManifest`;
176
199
  `trust_level` and `description` the CLI's, else that manifest's; a kept manifest's unknown keys carried, each with a
177
200
  `unknown_key_line` warning) in a temp dir → `write_out` (zip, tar, tgz, or a new/empty dir). An installed
@@ -114,9 +114,10 @@ Context window lookup (`ContextWindow.resolve`, the first that answers wins; the
114
114
  chat host has no `/props`, so the chat loop and the engine skip this step for it.
115
115
  2. The host's model list (the chat adapter's `#context_window`, e.g. a provider's `context_length`; `:model_list`).
116
116
  3. The config file's `models.<key>.window_tokens` (by the model's lookup names, as sampling and vision look a model
117
- up; `:model_setting`), else `hosts.<name>.window_tokens` (`:host_setting`). `ContextWindow.setting` reads it per
118
- turn for the effective model, after the hooks that may switch it.
119
- 4. `context.window_tokens`: CLI, `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` or the config file (`:config` or `:env`).
117
+ up; `:model_setting`), else `hosts.<name>.window_tokens` (`:host_setting`). `ContextWindow.setting(lookup)` (the
118
+ effective model's `ModelLookup`) reads it per turn for the effective model, after the hooks that may switch it.
119
+ 4. `context.window_tokens`: `--context-window-tokens`, `SAMAGOTCHI_CONTEXT_WINDOW_TOKENS` or the config file (`:cli`,
120
+ `:env` or `:config`).
120
121
  5. `ContextWindow::DEFAULT_TOKENS`, 256000 (`:default`).
121
122
 
122
123
  Settings (3 and 4) only fill in when the server and its list report nothing (a server that is down, a chat host whose list has no window). A
@@ -51,7 +51,9 @@ goes IDLE, records `last_turn` (its `enqueued_id` from the origin; a continue tu
51
51
  turn's, `ContinueOffer#run_continue_turn`), the working set and the objectives' view (`announce_objectives`) go out, and the end
52
52
  event is emitted (`:turn_completed` / `:turn_canceled`, every one carrying `turn_id`). Before the replace,
53
53
  `@verify_anchors.stamp_opening` puts `verify` / `revises` on the opening message (metadata, never sent) and
54
- `record_verdict` adds a Verify turn's verdict. `complete_turn` then runs `@objectives_stage.outgoing` (not for a
54
+ `record_verdict` adds a Verify turn's verdict. When the turn keeps messages, the end event names their range,
55
+ `kept_from` (`turn.history_size`, noted in `turn_messages` right after `ContextNote.with_system_head`) and `kept_to`
56
+ (`kept.size`): display-side only, no message gains a key. `complete_turn` then runs `@objectives_stage.outgoing` (not for a
55
57
  canceled or exhausted turn), the `:after_turn` hook (with `present:` and `refs:`, see "Hooks"),
56
58
  `store_answer_display` (one replace; `:answer_display` to the observers when a hook changed the answer or attached
57
59
  refs) and the `:session_end` hook. `TurnEndings` (`turn_endings.rb`) holds the pure builders (`kept_messages`,
@@ -377,7 +379,18 @@ a bare id to another host, and another process's saved model lists change what a
377
379
  command thread, the recap thread, plugin threads.
378
380
  - `ModelChoice` (`ModelBinding#choice`) holds the name and the typed name as **one value**, so a reader never pairs
379
381
  one model's name with another's typed name.
380
- - `EffectiveModel.of_session` builds a saved session's (no worker): `Web::App`, `LLMContextStrategy.for_session`.
382
+ - Every reader passes its `ModelLookup` (`model_lookup.rb`) to the setting module: one input for every layer (the
383
+ target's host entry, the lookup names, the `models:` map, and the env the CLI/env/file layers are read with, the
384
+ registry's own: `HostRegistry#env`). Each module (`Thinking`, `ReasoningHistory`, `SteerCutAfter`,
385
+ `SamplingSettings`, `VisionSupport`, `ContextWindow.setting`, `LLMContextStrategy`) keeps its own ordered
386
+ `resolve` over it and never names `ModelLookup` or `ModelSettings` (the class map's rule: the modules' parsers are
387
+ what `ModelSettings` calls). `ModelProfile.resolve` takes its `models` map. `.of(registry, choice, models:)` takes
388
+ one map for many models (the start page's picker).
389
+ - `EffectiveModel.of_session` builds a saved session's (no worker): `SessionSetting#saved_summary`, and
390
+ `EffectiveModel.session_llm_context` / `.session_budget` (a session page, a session list's ctx %).
391
+ - The registry: an Engine (and the TerminalUI) takes `HostRegistry.current` once at its start, so a running worker
392
+ keeps the hosts it started with; commands and chi web read `.current` (one per process, built again when
393
+ `hosts:` changes; it refuses a client override, so a spec's `client:` goes on a registry of its own).
381
394
 
382
395
  `SessionSetting` (`session_setting.rb`) describes the four per-session settings (llm_context, thinking,
383
396
  reasoning_history, toolkit): own value, commands, chips, a saved session's chip, the web's fallback. The session
@@ -577,7 +590,7 @@ cache is dropped at the turn's end, not its start, so one turn doesn't ask twice
577
590
 
578
591
  ## Hooks
579
592
 
580
- Plugins and hook files register hooks on hook points (docs/hooks.md). Every fire puts the hook runtime on the
593
+ Plugins (`chi.on`) and config hook files register hooks on hook points (docs/hooks.md). Every fire puts the hook runtime on the
581
594
  event: `event[:hook]` (the label) and five helpers, which call the Engine's `Hooks::Runtime` (built by `HookRoutes`,
582
595
  `hook_routes.rb`). A plugin's `ctx` has the same ones. A question takes keywords only at that plugin-facing edge;
583
596
  from there it is one `HookQuestion` value through `Engine#hook_ask_user` and `HookRoutes#ask_user` to
@@ -626,10 +639,12 @@ re-validates them ([architecture.md](../architecture.md), the `display_refs` par
626
639
  `clear_all` leaves them.
627
640
  - **Turn-scoped hooks** (`Engine#register_hook`) are cleared by `clear_hooks` in `release_turn`.
628
641
  - Fire order: bundle hooks (by priority, bundle, hook name), then config hooks, then turn-scoped hooks.
629
- - **A hook that raises is logged; it never breaks the turn.** A plugin's `chi.on` handler and a bundle's
630
- `hooks/*.rb` also tell the user once per label (`Hooks.wrap(on_failure:)` → `HookRoutes#plugin_hook_failed`: a
631
- `:guardrail_warning` labelled `plugins`); the callback reaches the hook files through
632
- `ExtensionLoad.new(hook_failed:)`. A config hook has none, nor the test kit's sandbox.
642
+ - **A hook that raises is logged; it never breaks the turn.** A plugin's `chi.on` handler also tells the user once
643
+ per label (`Hooks.wrap(on_failure:)` → `HookRoutes#plugin_hook_failed`: a `:guardrail_warning` labelled
644
+ `plugins`, through `Plugin::Registries#hook_failed`). A config hook has none, nor the test kit's sandbox.
645
+ - `ExtensionLoad#hooks` loads config.yml's hooks only; an installed record that still has class-file bundle hooks
646
+ (an older chi's install) adds a not-required load failure instead, announced at the first turn
647
+ ([hooks.md](../hooks.md#class-file-hooks-are-retired)).
633
648
  - A notice goes to the running turn's sink and the observers; outside a turn it is announced with
634
649
  `between_turns: true`; while the plugins load it is held. `EventRoutes#route` decides it, the same routes as a card's
635
650
  (`Engine#show_card`); a guardrails line after load takes only the turn / between-turns routes (`#route_by_turn`).
@@ -750,6 +765,7 @@ are in [architecture.md](../architecture.md)'s file table.
750
765
  | `event_fanout.rb`, `events.rb`, `event_routes.rb` | `EventFanout` (sink + observers, the stream handler, the working set's emit); the shared event sets; where a card, notice or guardrails line goes |
751
766
  | `engine_snapshots.rb` | `session_state_snapshot` (cheap, never raises), `stats_snapshot` (may probe `/props`) |
752
767
  | `model_binding.rb`, `effective_model.rb`, `session_model_settings.rb`, `session_setting.rb` | The model choice and profile; `EffectiveModel` / `ModelChoice`; the session's settings on its model; the per-session setting descriptors |
768
+ | `model_lookup.rb`, `host_registry.rb`, `host_address.rb` | `ModelLookup`, every setting layer's input; `HostRegistry` (`.current`, the shared one); `HostAddress`, whether an address is remote |
753
769
  | `thinking_notices.rb`, `level_setting_command.rb`, `thinking_command.rb`, `reasoning_history_command.rb`, `reasoning_history.rb` | Thinking notices; `/thinking`, `/reasoning-history`; `ReasoningHistory` (`.resolve`, `.run_start`) |
754
770
  | `steer.rb`, `steer_cut.rb`, `generation_phase.rb`, `waiting_steer.rb`, `steer_cut_after.rb`, `cut_policy.rb` | Steers at boundaries; the cut seam and its parts; the cut wait; the loops' cut path |
755
771
  | `kernel_loop.rb` | The native loop |
@@ -269,7 +269,7 @@ into it, from any client (the web, an attached TUI, `chi send` without `--queue`
269
269
  lock), and its ack and `:turn_enqueued` say `queue`; the worker's drain takes no input file during a participant's
270
270
  turn (`Worker#pending_input_drain`, for a file the Bridge never saw), so each runs as the owner's own turn after it.
271
271
  The web's delivery line says so (`participantTurn`: Enter queues, no cut), the TUI prints its queued note, `chi send`
272
- says `(queued: runs after the current turn)` and its `--wait` waits for that turn (`WaitTarget.message`). The owner's
272
+ says `(queued: runs after the current turn)` and its `--wait` waits for that turn (`ReplyWait::Target.message`). The owner's
273
273
  own turns steer as before. A step-limit Continue of a participant's turn (the owner answers it) continues as theirs: its origin (a `TurnOrigin`,
274
274
  `#participant?`; see engine.md) keeps their author (`ContinueOffer#run_continue_turn`), so it runs inside the boundary and on their spend; once their link ended or their cap is reached, the owner's Continue
275
275
  ends the offer as a Stop instead (`ContinueOffer#refuse_continue`: decision `refused`, a notice, `not_continued`), so
@@ -372,7 +372,8 @@ fields: a field added to an event later reaches nobody until it is named there.
372
372
  and never after a key word whose value is still to come, `Redact::OPEN_VALUE`; past 1600 with no such space,
373
373
  the line's last unbroken run and the rest of that line go out as one `[redacted]`). The owner's stream never
374
374
  goes through it (a byte pipe).
375
- - **The conversation** (`.messages`, over `App#messages_for_display(parts: true)`, `Web::MessageDisplay`): a user message only when it is a
375
+ - **The conversation** (`.messages`, over `App#messages_for_display(parts: true)`, `Web::MessageDisplay`; no turn drawings: the
376
+ participant page rebuilds every turn from its messages, [bridge.md](bridge.md#turndrawings)): a user message only when it is a
376
377
  turn's prompt (it has the turn's id) or a person's merged line, and never one from a source that isn't a person's;
377
378
  so the owner's `!` line and its output, a delegate's report (a wake turn's too), and the notes chi adds (a declined
378
379
  Continue) never show. A turn no person sent (its record's `automatic`, `SessionMetrics`: a task's or a plugin's,
@@ -32,7 +32,8 @@ The value objects (`Data.define`, no hashes across files) are `NewSession` (what
32
32
  `Session` itself, which has the same fields but `owner`: both backends answer `#owner(id)`), `Created` (`Remote#create`; Local returns the `Session`), `WaitStart` (the
33
33
  pre-send state for a wait), `ContextEntry` and `ContextPush`. `Remote` also returns the existing callee types
34
34
  (`SessionManager::DeliverResult`, `NoteDelivery::Result`, `ReplyWait::Result`, `BridgeClient::Response`,
35
- `SessionManager::Restarted`) so that the commands can't tell the backends apart. A command checks `@access.remote?` where the backends must
35
+ `SessionManager::Restarted`, and `SessionRow` from `#summaries`: chi web's `GET /api/sessions/summaries` rows read back
36
+ with `SessionRow.from_summary_h`, so `chi sessions list` prints the same `#cli_line` on both) so that the commands can't tell the backends apart. A command checks `@access.remote?` where the backends must
36
37
  differ.
37
38
 
38
39
  ### Dispatch: choosing the backend
@@ -103,7 +104,9 @@ registry flag (`--recap-base-url` …) is this machine's config: `LOCAL_ONLY` wi
103
104
  there, so a `timeout` answer carries the slice's `running_look` (`ReplyWait::RunningLook`: the running turn it saw
104
105
  with its checkpoint mark, `{last_end, claimed}`, else null) and the client sends it back on the next slice, as is:
105
106
  a dead worker's turn settled between two slices ends the wait `canceled` (`worker_stopped`) as it does locally,
106
- not at the timeout. A 503 `server_stopping` (chi web
107
+ not at the timeout. `running_look` is needed only for a 0.156 worker: a newer one names a wake turn (`wake-…`)
108
+ and records a steer's merge (`input/merged/`), so a wait binds the turn's id and its settle ends it; it goes
109
+ two releases on (the wire keeps accepting it and ignores it then). A 503 `server_stopping` (chi web
107
110
  shutting down mid-wait: `ReplyWait`'s `cancelled:` returns `:canceled`) is retried after `RETRY_AFTER` (1 s).
108
111
  `HTTPTransport::READ_TIMEOUT` (75 s) is set above the longest answer, a wait slice or a create's wait for its worker.
109
112
  6. **The report.** `ParentReport.report` prints it as it does locally. It uses `Remote#parent_strategy`, which
@@ -24,7 +24,7 @@ page. A factory dep a new version stops passing is the same hazard the other way
24
24
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
25
25
  until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
26
26
  `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
27
- `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`, `version_notices.js`, `owner_claim.js`, `owner_verify.js`, `composer_mode.js`, `strip_hidden.js`, `block_open.js`, `text_roots.js`, `block_source.js`, `block_frame.js`, `block_host.js`, `block_watch.js`, `card_stamps.js`, `inline_widgets.js`, `widget_walk.js`, `widget_picks.js` and the `objectives_*.js` modules are new modules for that reason. The last four `block_*` draw fenced blocks (`block_watch.js` finds and watches them, `block_host.js` is its frame drawer for a bundle's renderers): [block-renderers.md](block-renderers.md). `inline_widgets.js` is its core drawer for `chi-ui` blocks, drawing `widget_walk.js`'s and `widget_picks.js`'s widgets in a shadow root styled by `widgets.css`: [inline-widgets.md](inline-widgets.md).
27
+ `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`, `version_notices.js`, `owner_claim.js`, `owner_verify.js`, `composer_mode.js`, `strip_hidden.js`, `block_open.js`, `text_roots.js`, `block_source.js`, `block_frame.js`, `block_host.js`, `block_watch.js`, `card_stamps.js`, `recap_stamp.js` (a saved recap's place by its `opening_id` stamp, else its count), `inline_widgets.js`, `widget_walk.js`, `widget_picks.js`, `history_shape.js` (the history's shape, live against a reload: the e2e reload checks and the opt-in reload check share its normalizer and its `ACCEPTED` allowlist), `turn_pieces.js` (a finished turn's one order and its answer rule: the live hand-off's `handOffOrder` and `turnEnded`, and a reload's `turnHistoryNodes`), `reload_check.js` (the opt-in `web.reload_check`: after the stage's hand-off, the turn against a reload of it, the counts posted), `turn_drawing.js` (a finished turn's drawing, `turn_drawings` in the show: its fold into steps and its end kind; `turn_block.js` draws it) and the `objectives_*.js` modules are new modules for that reason. The last four `block_*` draw fenced blocks (`block_watch.js` finds and watches them, `block_host.js` is its frame drawer for a bundle's renderers): [block-renderers.md](block-renderers.md). `inline_widgets.js` is its core drawer for `chi-ui` blocks, drawing `widget_walk.js`'s and `widget_picks.js`'s widgets in a shadow root styled by `widgets.css`: [inline-widgets.md](inline-widgets.md).
28
28
 
29
29
  ## Two pages, two models
30
30
 
@@ -51,7 +51,8 @@ change.
51
51
  | **`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`), `owner_verify.js` (`verdicts`, `verifyFresh`); `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`) |
52
52
  | **`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` |
53
53
  | **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 |
54
- | **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`), an opening's id for a card's stamp (`data-turn-id` on a prompt, `data-opening-id` on a wake's note or `/continue no`'s reason line; `card_stamps.js` places a snapshot's card after the stamped opening, by its count only without one), 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
+ | **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 `turns_since`), an opening's id for a card's and the saved recap's stamp (`data-turn-id` on a prompt, `data-opening-id` on a wake's note or `/continue no`'s reason line; `card_stamps.js` places a snapshot's card after the stamped opening, `recap_stamp.js` the saved recap after the last turn it covers, each by its count (`turns_since`) only without one), 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) |
55
+ | **A drawn turn** (`turn_drawings` of the full read, `?parts=1&drawings=1`) | a finished turn as live drew it: `turnGroups` makes its stamped items (`drawn: <turn_id>`) one group, and `turnHistoryNodes` draws it by feeding its drawing's events to a live turn block offscreen (`turn_block.js drawnTurnNodes`: the block's own handlers, as the stage's), the stage's extras (a reminder, merged lines, a card that asks or warns) and end as live, then the answer, the merged lines and the steps' texts from their saved items (markdown, refs). Each of its top-level elements carries `data-drawn`: `cards_place.js` leaves that turn's in-turn CardStore entries (`of_turn`) to it, and a kept reminder bubble of that turn (`data-turn-id`) stays out after a resync. A turn without a drawing is rebuilt from its messages as before | chi web (the show) | `renderHistory`, the reload check's render |
55
56
  | **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
56
57
  | **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
57
58
 
@@ -244,7 +244,7 @@ passes `working_set_updated` to it, and does nothing when the flag is off. A new
244
244
  - the session bar's chip and the open list;
245
245
  - the footprints, in place (`refreshFootprints`, the same node; a `data-ws-sig` of the items' states skips an
246
246
  unchanged one). A footprint is a `<span>` in its block's summary line, `.turn-work > summary > .tw-sum`, before the
247
- steps' `.turn-steps`: drawn in the reload's template (`turnHistoryNodes`), prepended at the hand-off
247
+ steps' `.turn-steps`: drawn in the reload's template (`turnHistoryNodes`; a drawn turn's from its drawing's rows, `turnGroups`' steps), prepended at the hand-off
248
248
  (`stage_view.js handOff`, while the stage still hides the summary). A click there must not toggle the block:
249
249
  `details_motion.js` skips `.ws-foot`, and `footClick` cancels the click;
250
250
  - the rail (`onServer`, the stage's `scheduleRefresh`).