samagotchi 0.148.0 → 0.150.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +4 -2
  4. data/docs/architecture.md +3 -3
  5. data/docs/bundles.md +38 -1
  6. data/docs/cli.md +76 -24
  7. data/docs/configuration.md +8 -6
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +2 -1
  10. data/docs/internals/background-tasks.md +1 -1
  11. data/docs/internals/bridge.md +8 -0
  12. data/docs/internals/bundles.md +42 -14
  13. data/docs/internals/objectives.md +269 -0
  14. data/docs/internals/web-frontend.md +4 -1
  15. data/docs/internals/web-push.md +2 -2
  16. data/docs/internals/working-set.md +5 -2
  17. data/docs/memory.md +47 -9
  18. data/docs/plugins.md +10 -2
  19. data/docs/releasing.md +1 -1
  20. data/docs/sessions.md +45 -2
  21. data/docs/system-one.md +17 -1
  22. data/docs/testing.md +5 -0
  23. data/lib/samagotchi/atomic_file.rb +3 -0
  24. data/lib/samagotchi/bootstrap_command.rb +5 -2
  25. data/lib/samagotchi/bridge/card_store.rb +52 -7
  26. data/lib/samagotchi/bridge.rb +1 -1
  27. data/lib/samagotchi/bundle_command.rb +91 -68
  28. data/lib/samagotchi/bundler_env.rb +24 -0
  29. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
  30. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  31. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
  32. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  33. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  34. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  35. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  36. data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -1
  37. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +3 -1
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +3 -2
  39. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +2 -2
  40. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  41. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  42. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  43. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  44. data/lib/samagotchi/chi_home.rb +4 -2
  45. data/lib/samagotchi/chi_shim.rb +2 -1
  46. data/lib/samagotchi/child_env.rb +10 -3
  47. data/lib/samagotchi/config.rb +7 -1
  48. data/lib/samagotchi/context_note.rb +17 -5
  49. data/lib/samagotchi/engine.rb +113 -6
  50. data/lib/samagotchi/engine_peers.rb +11 -0
  51. data/lib/samagotchi/git_env.rb +43 -0
  52. data/lib/samagotchi/guardrails/context.rb +2 -0
  53. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  54. data/lib/samagotchi/guardrails/targets.rb +4 -1
  55. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  56. data/lib/samagotchi/memory_bundle/asset_installer.rb +16 -2
  57. data/lib/samagotchi/memory_bundle/file_standing.rb +167 -0
  58. data/lib/samagotchi/memory_bundle/installed_bundle.rb +62 -1
  59. data/lib/samagotchi/memory_bundle/installer.rb +161 -52
  60. data/lib/samagotchi/memory_bundle/listing.rb +4 -3
  61. data/lib/samagotchi/memory_bundle/merger.rb +209 -8
  62. data/lib/samagotchi/memory_bundle/placeholder.rb +2 -1
  63. data/lib/samagotchi/memory_bundle/profile.rb +7 -3
  64. data/lib/samagotchi/memory_bundle/provenance.rb +178 -20
  65. data/lib/samagotchi/memory_bundle/remerge.rb +167 -0
  66. data/lib/samagotchi/memory_bundle/shipped_history.rb +79 -0
  67. data/lib/samagotchi/memory_bundle/shipped_update.rb +89 -24
  68. data/lib/samagotchi/memory_bundle/source.rb +3 -3
  69. data/lib/samagotchi/memory_bundle/status.rb +21 -3
  70. data/lib/samagotchi/memory_bundle/system_bundle.rb +98 -33
  71. data/lib/samagotchi/memory_bundle/text_merge.rb +399 -0
  72. data/lib/samagotchi/memory_bundle/trash.rb +58 -2
  73. data/lib/samagotchi/memory_bundle/uninstaller.rb +26 -6
  74. data/lib/samagotchi/note_command.rb +1 -1
  75. data/lib/samagotchi/note_delivery.rb +6 -2
  76. data/lib/samagotchi/objectives.rb +395 -0
  77. data/lib/samagotchi/objectives_audit.rb +115 -0
  78. data/lib/samagotchi/objectives_check.rb +365 -0
  79. data/lib/samagotchi/objectives_command.rb +192 -0
  80. data/lib/samagotchi/plugin/api.rb +2 -1
  81. data/lib/samagotchi/plugin/context.rb +21 -1
  82. data/lib/samagotchi/project_env.rb +3 -2
  83. data/lib/samagotchi/self_report.rb +2 -0
  84. data/lib/samagotchi/session_commands.rb +15 -0
  85. data/lib/samagotchi/session_continue.rb +3 -0
  86. data/lib/samagotchi/session_inbox.rb +14 -6
  87. data/lib/samagotchi/session_manager.rb +1 -1
  88. data/lib/samagotchi/steer.rb +24 -4
  89. data/lib/samagotchi/testing/boundary.rb +2 -0
  90. data/lib/samagotchi/testing/host.rb +1 -0
  91. data/lib/samagotchi/testing/minitest.rb +3 -0
  92. data/lib/samagotchi/testing/rspec.rb +3 -0
  93. data/lib/samagotchi/testing/sandbox.rb +3 -1
  94. data/lib/samagotchi/tool_activity.rb +17 -0
  95. data/lib/samagotchi/tool_declarations.rb +34 -0
  96. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  97. data/lib/samagotchi/tools/builtins.rb +4 -1
  98. data/lib/samagotchi/tools/delegate.rb +2 -1
  99. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  100. data/lib/samagotchi/tools/peers.rb +3 -0
  101. data/lib/samagotchi/tools/task_runtime.rb +13 -10
  102. data/lib/samagotchi/turn_flow.rb +3 -1
  103. data/lib/samagotchi/update_command.rb +76 -28
  104. data/lib/samagotchi/update_hint.rb +4 -1
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/web/auto_update.rb +8 -8
  107. data/lib/samagotchi/web/auto_update_record.rb +6 -4
  108. data/lib/samagotchi/web/message_display.rb +9 -0
  109. data/lib/samagotchi/web/participant_view.rb +2 -2
  110. data/lib/samagotchi/web/public/app.js +15 -3
  111. data/lib/samagotchi/web/public/block_open.js +13 -0
  112. data/lib/samagotchi/web/public/bubbles.js +4 -2
  113. data/lib/samagotchi/web/public/cards_place.js +30 -9
  114. data/lib/samagotchi/web/public/commands.js +4 -1
  115. data/lib/samagotchi/web/public/details_motion.js +2 -1
  116. data/lib/samagotchi/web/public/index.html +20 -14
  117. data/lib/samagotchi/web/public/objectives.css +80 -0
  118. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  119. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  120. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  121. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  122. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  123. data/lib/samagotchi/web/public/stage_model.js +5 -6
  124. data/lib/samagotchi/web/public/stage_view.js +8 -7
  125. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  126. data/lib/samagotchi/web/public/turn_block.js +34 -19
  127. data/lib/samagotchi/web/public/turn_html.js +4 -1
  128. data/lib/samagotchi/web/public/turn_model.js +1 -1
  129. data/lib/samagotchi/web/public/update_sheet.js +8 -3
  130. data/lib/samagotchi/web/public/working_set_view.js +10 -7
  131. data/lib/samagotchi/web/push/notifier.rb +7 -9
  132. data/lib/samagotchi/web/session_hub.rb +2 -1
  133. data/lib/samagotchi/web/session_show.rb +12 -1
  134. data/lib/samagotchi/web/update_ops.rb +2 -1
  135. data/lib/samagotchi/worker.rb +3 -0
  136. data/lib/samagotchi/worker_inbound.rb +16 -8
  137. metadata +22 -1
@@ -13,9 +13,14 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
13
13
  *.md, index.md the bundle's memories, one managed index line each ("· from <bundle>")
14
14
  <system memories>/.bundles/ MemoryPaths.bundles_dir
15
15
  <name>/manifest.json the install record (Provenance#write, read as InstalledBundle)
16
- <name>/bases/ each file as installed: the base of the next upgrade's 3-way merge
16
+ <name>/bases/ each file's base for the next upgrade's 3-way merge: as installed, or the
17
+ bundle's own text after a merge
18
+ <name>/incoming/ the bundle's text for each file marked conflict, until the file settles (Provenance:
19
+ #write, #refresh_marks, gone with #resolve_conflicts); nothing globs it for memories
20
+ (Builder takes the scope dir's top level), and an uninstall's rm_rf sweeps it
17
21
  <name>/hooks/ guardrails/ plugin/ scripts/
18
- .trash/<name>-<stamp>/ files an uninstall or upgrade took away (Trash), never deleted
22
+ .trash/<name>-<stamp>/ files an uninstall or upgrade took away, and copies of what an install
23
+ overwrote (Trash), never deleted
19
24
  ```
20
25
 
21
26
  ## Components
@@ -35,15 +40,19 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
35
40
  | `MemoryFiles` | which top-level `*.md` files of a bundle are its memories (`.select`: the `files:` list, else all but README/LICENSE and the like), `.unshipped` (listed, not shipped) and `.symlinks` (skipped: a symlink is never a memory) |
36
41
  | `Manifest` | parses/validates `manifest.yml` (`name:` is the bundle's name and a directory under `.bundles/`, so a path such as `../x`, `a/b`, `.` or `..` is refused: `name: … is not a bundle name`; YAML that doesn't parse or isn't a mapping is a `ValidationError` too; files, hooks as `BundleHook`, plugin, scripts, needs, requires_chi, includes, context_providers, session_settings: the keys a session's `chi --set` may give, a list or a mapping of them to types (`SESSION_SETTING_TYPES`), recorded at install in `manifest.json` (`session_settings`, `session_setting_types`) for `SessionConfig` to check; settings: the keys config.yml's `bundles: <name>:` may have, the same shape, recorded as `settings` (nil when the manifest has none) and `setting_types` for `BundleSettingsCheck`, whose warnings the Engine holds for the first turn and `SessionConfig`'s refusals share the words of) and writes it (`Manifest.write`, for Builder). It ignores `requires_chi_next`, a shipped bundle's release-time marker (`script/release/release_tools.rb`, docs/releasing.md) |
37
42
  | `Installer` | one install/upgrade/dry run of one bundle (below) |
38
- | `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps) |
39
- | `Merger` | 3-way classification per file: `install`, `fast_forward`, `keep`, `noop`, `conflict` |
40
- | `Provenance` | the `.bundles/<name>/` dir: `#write` the record + bases, `#record` → `InstalledBundle`, `.each_installed`, `.claimants` (which bundles own a file) |
43
+ | `AssetInstaller` | the parts that live in `.bundles/<name>/`: hooks, guardrail rules, plugin, scripts; returns `AssetInstaller::Installed` (the paths the record keeps). A file it replaces or removes whose bytes are neither the recorded sha nor the incoming file's is copied to the Installer's `Trash` first (`Trash#copy_edit`, `#copy_edits` for a folder at any depth, as `hooks/x`, `guardrails/x`, `plugin/x`, `scripts/sub/x`; no sha recorded counts as edited; a dev link never is) |
44
+ | `Merger` | 3-way classification per file: `.classify` → an `Outcome` (`status`: `install`, `fast_forward`, `keep`, `noop`, `conflict`, `merged`; `base`: where the file's next base comes from, `:on_disk`, `:recorded` or `:incoming`; a new outcome and its base go in its constants, and it needs a branch in `Installer#merge_memory`, a kind in `#next_base` if its base is new, and its status word in `#preview_memory`, `#verify_memory_checksums` and `#summary`). `.classify` never merges and answers `conflict` for two changed sides; `Merger::Upgrade` (provenance, target dir, source dir) is the one way to ask what an upgrade does to a file, nothing written: `#outcome` (`.classify`, then `TextMerge` where both sides changed, git once per call: `merged`, with its `TextMerge::Result` and the digest it read as the Outcome's `merge` and `current_sha`; `ALREADY_MERGED`, a `keep` with base `:incoming`, when the merge adds nothing; or `conflict` with that `merge`, whose `why` says why none was possible), `#classified`, `#forecast` (a `Forecast` of `merged` and `behind` in one pass), `#behind` (a `conflict` outcome as a `FileStanding::Behind`: why, and from `TextMerge.changed_sections` what it missed, nothing for an older mark, whose base may be the user's text), `#paths`, used by the Installer's merge and dry run, `ShippedUpdate.plan` and `SystemBundle.sync(dry_run:)`. `.write_merged` puts a merge in place: the digest checked just before the rename (`AtomicFile.write`'s block), through a symlink, the mode kept, the old file in the `Trash`; false when a live `memory_write` got in. A mark from an older chi (`InstalledBundle.legacy_mark?`: no `incoming_sha`, or `legacy_base`, which keeps that known once the digest is recorded, until the mark goes) may have the user's own text as its base, 0.149's doing, and a merge against it would undo that text with nothing to show, or a fast-forward replace all of it: `#outcome` makes a fast-forward that takes a line out of the file, and `.merge_outcome` a merge that does, a `conflict` (`FileStanding::LEGACY_BASE`), on the Installer's and `Remerge`'s path alike, unless `ShippedHistory` proves the base is a version chi shipped. The price, with no such gem left or for a bundle chi doesn't ship: a real base looks the same, so such a file whose bundle rewords or removes a line it still holds stays behind until it is resolved by hand, with `--agent` or with `--force`; no retry fixes it. `Installer#save_marked_file` serves a fast-forward that goes ahead over a mark: a file put back to its base under this chi's mark, or an older mark with a proved base or an update that only adds. `.markers?` (a file holding a merge's `<<<<<<<`/`>>>>>>>` lines; `=======` alone is a Markdown underline) |
45
+ | `ShippedHistory` | `.shipped?(bundle, file, path)`: whether those bytes are that file of that bundle (by manifest name, since a shipped dir can be renamed) in an installed `samagotchi-<version>` gem. Reads `<gem dir>/gems/` by hand, for `Gem.path` (set by a chi home's boot string) and the home's `gem_dirs` from home.json (a checkout or another Ruby runs on other gems), never `Gem::Specification`. Size, then bytes, then the manifest. Anything it can't read is no match, which keeps the guard. Asked only when `Merger`'s guard would otherwise fire: with 54 gems on the Studio a miss costs about 4 ms, a hit less |
46
+ | `TextMerge` | the text merge: `.merge(base:, ours:, theirs:)` → a `Result` (`status` `:clean` with `text`, `:conflict` with `hunks`, `:failed` with `why`). `git merge-file -p --diff3 --marker-size=20` on temp copies in a temp cwd, with every `GIT_*` that points at a repository or a config unset or at `/dev/null` (it can run inside another tool's hook). git isn't a runtime dependency: none on PATH, or macOS's `/usr/bin/git` with no developer tools behind it (`.apple_stub?`, by `xcode-select -p`; running it pops a dialog), is `:failed`. So is a git past `TIMEOUT` (10 s; its process group is killed: this runs under `SystemBundle`'s lock), and any doubt about the answer: `min(hunks, 127)` against the exit status, a status over 127, a NUL in a side, marker lines that don't pair up (a user's line equal to one), a line no input holds, or an empty answer to non-empty inputs. `Scan` reads the output: a hunk unions (ours, then the entries of theirs that ours lacks) only when the last heading before it, at level 1 or 2 and outside code fences, HTML comments and any hunk, is titled exactly `Changelog` or `Change log`, with nothing but list entries (an item and its indented lines) since that heading and no fence or comment open, and base, ours and theirs each hold only list entries, with every base entry still on both sides: both only added. Any other hunk is a real conflict, and markers never leave the module. An entry is an item and the lines indented deeper under it, nested items too, and the union compares whole entries. `.changed_sections(base, theirs, ours)`: the headings of the sections the bundle changed and the file hasn't got, and whether text under no heading did, which is what names a miss: a conflict's hunks can't, since the whole file stays and the one hunk may be a Changelog line beside a whole new section. `Hunk#heading` counts the places under no heading |
47
+ | `FileStanding` | the one owner of what chi says of an edited bundle file, used by every surface: `Behind` (`#state`, `#line`: `x.md stays behind 0.8.0: no merge possible (why); missed ## A, ## B, ## C and 2 more`, `would stay` in a dry run), `MergeNote`, `.merged_line`, `.no_merge` (for `LEGACY_BASE` it says chi can't tell whether the file holds edits), `.clauses` (a report's clauses for one bundle), `.behind_count`, `.stay_behind`, `.counts_sentence` (the push), `.still_behind`, `.text` (UTF-8, whatever a name or heading was tagged), `FORCE_TAKES`, `FORCE_MOVES`, `IN_THE_TRASH`. Behind means marked and bytes other than the mark's `incoming_sha` (`.behind?`, read by `InstalledBundle#behind_files` and `Remerge` alike): a file brought level by hand isn't, a missing one is a missing file, and one that can't be read is. An older mark no retry has looked at reads `may be behind` (`Behind#unsure`, and the counts say so), and isn't behind at all when level with its recorded source on disk (`InstalledBundle#behind_file?`). A new surface reads these and adds no wording |
48
+ | `Remerge` | the retry at the same version, memory files only, for a file marked `conflict`: `#run(dry_run:)` → a `Result` (`merged`, `settled`, `stamped`, `behind`, `warnings`, `trash_copy`, `trash_dir`). It merges what merges and settles those entries (`Provenance#resolve_conflicts`, the index line refreshed), leaving `version`, `installed_at` and the assets alone: the Installer would `rm_rf` guardrails, plugin and scripts under live sessions. Every file it leaves is in `behind`, with why: one not shipped now (`MemoryFiles.select`) keeps its mark, base and text; one whose `incoming_sha` isn't the shipped text (a checkout at one VERSION) is left alone, no git runs, and it reads as its mark says; a mark with no digest (older chi) is tried as well, and a real conflict then records it (`Provenance#refresh_marks`, which writes only when the mark would read differently, so a conflict at each start writes nothing). A mark whose digest matches is tried on every run, since the file or git may have changed. `noop` and `keep` settle quietly; `fast_forward` waits for the next upgrade, reading as behind until then; `install` is a missing file, which isn't behind, and a warning says so. A file it can't read or replace stays as it is, saying why. Called from `ShippedUpdate.plan_row`/`apply` (`chi update`) and `SystemBundle.sync`'s equal-version branch, under its lock (each chi start and `chi update`); a bundle chi doesn't ship retries through `chi bundle upgrade`. No marks: nothing is read beyond the record. One marked file costs one git run per Engine start, about 12 ms on the Studio (0.1 ms with none) |
49
+ | `Provenance` | the `.bundles/<name>/` dir: `#write` the record + bases (its `files:` is the full list: a key left out is dropped, a nil value keeps the entry with no base; `marks:` are the files left behind, each a `FileStanding::Behind`, and `incoming_dir:` the bundle text each speaks of, recorded as `incoming_sha` and saved at `#incoming_path`; an older mark that stays keeps `legacy_base`), `#resolve_conflicts` (base to the incoming file, mark and its fields (`MARK_FIELDS`) and saved text gone; a file whose incoming copy is gone keeps both), `#refresh_marks` (what a retry found: why, missed, the digest and `legacy_base`, the saved text), `#note_merged` and `#clear_merge_notes(notes)` (`MERGE_NOTE_FIELDS`, kept through `#write`; the saved text is best effort, after the manifest, so it can't leave a plugin under the old record's sha; any other failure writing the record still can, until a retry), `#record` → `InstalledBundle`, `.each_installed`, `.claimants` (which bundles own a file) |
41
50
  | `InstalledBundle`, `BundleHook`, `PluginRef` | value objects for the record; tolerant of older records (missing keys read as empty). `BundleHook#events` is the manifest's `event:` or its `events:` list (written back as `event:` for one, as an older chi reads it); `Hooks::BundleLoader#load_hook_file` builds the hook once and registers it under each |
42
51
  | `Builder` | `chi bundle build`: memories (+ the installed bundle's hooks, rules, plugin, scripts) → zip/tar/dir |
43
52
  | `Profile` | meta bundles (`includes:`): install the members, record which; `InstallResult#kept_own` names a new member's memory files the user already had, with other bytes (`kept_own_line`, printed by `chi bundle`, `chi update` and `chi bootstrap`); a dry run runs each member's Installer as a dry run too, so it names them as well |
44
- | `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true`. `ShippedUpdate.external` is the rest: what `chi update` only checks |
53
+ | `SystemBundle`, `ShippedUpdate` | the built-in system bundle (`ensure!` at Engine start, `sync` in `chi update`) and `chi update`'s upgrade of shipped bundles; both drive the Installer with `upgrade: true` and `Remerge` at an equal version (`ShippedUpdate::Row`'s `:would_merge`, `:merged`; `SystemBundle::Result`'s `:merged`). `SystemBundle` also puts back a memory missing at an equal version, that file alone, with no Installer (`verify_files_present`, `Provenance#record_restored`). `ensure!` echoes a merge in one line, in the row's words. That reaches a terminal launch; a worker has only its log (`Log.configure(stderr: false)`), so every merge leaves a note on the file's record (`Installer#note_merges`, a failed run's too, and `Remerge#note_merges`; `Provenance#note_merged`: version, time, trash dir). `chi bundle status` shows it, and `chi update` reads the notes before the run (`SystemBundle.merge_notes`, `ShippedUpdate::Row#earlier`), names them in the row and clears after it those exact ones and the notes of the files that run merged (a worker's meanwhile, on another file, stays), unless it was a dry run, the row failed or has no place for them (a skipped system row), or the run is unattended (`ChildEnv::UNATTENDED_UPDATE`, which `Web::UpdateOps` sets for `update.auto`, an env marker so a hand-over's exec keeps it and an older chi ignores it; `--json` alone says nothing, as a person or script may use it). `chi bundle upgrade` clears the notes of what it printed, and `--force` those of the files it replaced. `--json`'s `merged` is this run's, `earlier` the notes'. `ensure!` says what an upgrade left behind and nothing of a file already behind, which `Result#behind` still has for the row. A plan or sync that can't read a file is that bundle's failed row, in `Trash.failure_message`'s words (`ShippedUpdate.plan_row`, `SystemBundle.sync`). `ShippedUpdate.external` is the rest: what `chi update` only checks |
45
54
  | `InstalledCopy`, `BundleCommand::InstalledCheck` | `chi update`'s re-check of the bundles chi didn't ship ([docs/cli.md](../cli.md#updating)). `InstalledCopy` lays a record and its installed files out as a source folder (manifest.yml from `Manifest.write`, the files' own shas, memories from `bases/`); `InstalledCheck` forks per bundle (a plugin's `exit` or hang is that bundle's row; stdio to /dev/null, so `--json` stays parseable; `TIMEOUT`, then KILL of its group) and runs `Check.new(dir, installed: true)` there, which leaves out what only suits a source tree. Its `boundary` finding is narrower there than in a source tree: `Testing::Boundary.gone?` calls a reference gone only when its constant doesn't resolve or the object has no method the name names, so a bundle reaching `Samagotchi::Log` gets no row. lib loads lazily, so when a name doesn't resolve `gone?` requires every file of this chi's library in that same child and asks again (about 0.2 s, only for a bundle that reaches into chi at all); anything it can't read — a `const_get`, a require of a file that is there — is taken as still there. The fork is the point: `Sandbox` swaps ENV and the plugin's code is arbitrary. `UpdateCommand#external_rows` makes the rows (`level`, never `failed`) and holds the check back while a newer gem is yet to take over (`@not_checked`), since a fork is this chi's code |
46
- | `Uninstaller`, `Trash`, `Status`, `Listing` | remove, keep removed files, `chi bundle status`, `chi bundle list` |
55
+ | `Uninstaller`, `Trash`, `Status`, `Listing` | remove (the bundle's own folder too: an edited file there is copied to the trash first, `Uninstaller#trash_copy_line`); keep removed files (`Trash#move`) and a copy of overwritten ones (`Trash#copy`, its `as:` keeping a subfolder); `chi bundle status` (per file, `behind`, `merge_note`, `unreadable`; `issues=` counts behind, missing and unreadable, not edited); `chi bundle list` (`Installed#behind`) |
47
56
  | `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
48
57
  | `EntrySize` | one memory's size in tokens: `memory.entry_warn_tokens`' crossing note after a memory write, `chi self`'s `big memories` row |
49
58
  | `IndexSize` | one scope's memory index as the prompt carries it (tokens, lines, bytes): `memory.index_warn_tokens`' crossing note after a memory write, `chi self`'s summary, the prompt's figures (`SystemPrompt`) |
@@ -86,25 +95,44 @@ The order matters: warnings print in it, and a plugin/script the manifest names
86
95
  `#source_change` (the summary's first line, `Source changes: old → new`). A source `SourceIdentity` can't place
87
96
  (an older record's relative path) never refuses.
88
97
  3. `install_memories`, per memory (`MemoryFiles.select`: a non-empty `files:` list, else every top-level `*.md` but
89
- README, CHANGELOG, LICENSE and the like; a listed name not shipped warns; a symlink is skipped with a warning): an upgrade 3-way merges the files the bundle owns (`merge_memory`), a dry run only
90
- classifies (`preview_memory`), a plain install writes new files and skips existing ones (`skip_existing`). A
98
+ 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
99
+ 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
100
+ differs copied to the trash first (`Trash#copy`, said by `#trash_copy_line`; a profile's in `InstallResult#trash_copies`). A
91
101
  same-name file the bundle didn't write is the user's: skipped and never recorded (`@owned`). Model overlays
92
102
  (`<name>.<key>.md`) get no index line (`note_overlay`).
93
103
  4. `AssetInstaller#run`: hooks (manifest-listed, else every `hooks/*.rb`), prune dropped hooks, hook checksums,
94
- guardrails, plugin, scripts; each set replaces the previous version's. requires_chi warnings for hooks/rules. A
104
+ guardrails, plugin, scripts; each set replaces the previous version's, an edited file copied to the trash first. requires_chi warnings for hooks/rules. A
95
105
  file that is a symlink or resolves outside the bundle dir (`AssetInstaller#linked?`, a linked `hooks/` too) is
96
106
  skipped with a warning (a hook or rule) or raises `InstallError` (the plugin, a script).
97
107
  5. An upgrade's `prune_dropped_files`: unchanged → trash; edited → kept (`kept_pruned`); owned by another bundle → kept.
98
108
  6. `verify_memory_checksums` (strict), `warn_missing_needs`.
99
- 7. `write_provenance` (manifest, not a dry run): bases come from the incoming file, except where the user's edit was
100
- kept (kept/noop/conflict/kept_pruned, or a re-install's skipped edit): those keep the old base, so the next
101
- upgrade still sees the edit. A plain install also keeps the record (the old base) of a file an earlier install
109
+ 7. `write_provenance` (manifest, not a dry run): an upgrade's bases come from each file's `Merger::Outcome`
110
+ (`Installer#next_base`): the file on disk, which has the bundle's bytes; the incoming file for a merge, since the merged
111
+ file holds the user's edit and taking it would make current == base, so the next upgrade would fast-forward over the
112
+ edit (the edited signal stays current != base, which `chi bundle status` and uninstall's refusal read); and where the user's edit was
113
+ 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
114
+ 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
115
+ entry and its recorded checksum): taking the user's file would let the next upgrade fast-forward over it. A plain
116
+ install carries the `conflict` mark of a file it skipped (`held_conflict_marks`), a newer source moving `version`
117
+ included; it goes once the file has the incoming bytes. A plain install also keeps the record (the old base) of a file an earlier install
102
118
  owned and this version doesn't ship, while it is on disk (`dropped_owned_files`), so the next upgrade prunes it.
103
119
  8. `detect_placeholders`.
104
120
 
121
+ A file system error in steps 3 to 5 (`Installer#replace_files`; a `.trash` it can't write, an edited file it can't read) is an
122
+ `InstallError` in `Trash.failure_message`'s words, as it is an `UninstallError` in `Uninstaller#run`. Each save comes before
123
+ its removal, so no edit is lost, and the record isn't written, so a retry is the same upgrade. Not atomic: files the
124
+ failed run already placed stay.
125
+
126
+ Which files a plan asks `Merger::Upgrade` about is the caller's, and the lists differ, each less the files the bundle
127
+ doesn't own: the Installer's `MemoryFiles.select`; `ShippedUpdate.forecast`' every shipped `*.md`, so `chi update`'s plan
128
+ 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
129
+ run the manifest's `files:`, so a manifest with none plans nothing (the shipped one lists its files).
130
+
105
131
  Results are `@results` (file → `{status:, reason:}`), summarised by `#summary`. An upgrade with conflicts keeps an
106
132
  owned source dir alive (`#source_dir`) until `#cleanup_source!`, because the conflict agent reads the incoming
107
- files; `Provenance#resolve_conflicts` then moves the resolved files' bases forward.
133
+ files; `BundleCommand#settle_conflicts` then runs `Provenance#resolve_conflicts`, which moves a base forward and keeps
134
+ the entry's description, only for a file the agent step changed that holds no markers (`Merger.markers?`), and whose incoming
135
+ copy is still there. Any other keeps its old base and mark, and the command exits `CONFLICTS_KEPT`.
108
136
 
109
137
  ## Builder#run, in order
110
138
 
@@ -0,0 +1,269 @@
1
+ # Session objectives
2
+
3
+ A session's objectives are a short list the user types: what this session is to achieve. The first version is a
4
+ try-out, so each piece can come out alone. There is no new message kind or store: the list is a note in the
5
+ conversation and the command writes a note file. Terminals show the default note bubble; the web draws it
6
+ ([The page](#the-page)). For the user's side, see
7
+ [sessions.md](../sessions.md#objectives) and [cli.md](../cli.md).
8
+
9
+ ## The object and the entry
10
+
11
+ `Objectives::List` holds `Objectives::Item`s (`id`, `text`, `status` active|done|dropped, optional `done_when`) and the
12
+ number the next id takes. That number only grows, so an id is never reused, not after a drop or a `set`. `done_when`
13
+ is a hint, never enforced.
14
+
15
+ Each edit is a whole new list in one note entry:
16
+
17
+ ```ruby
18
+ ContextNote.message(note_id:, text: list.text, source: "objectives", header: ContextNote::OBJECTIVES_HEADER,
19
+ objectives: list.to_payload)
20
+ ```
21
+
22
+ - `objectives` is one key in `ContextNote::KEYS`, so everything that copies a note field by field keeps it
23
+ (`ChatLoop#plain`). A note is also what `take_initial_prompt` and `TurnFlow#restore` already handle, which is why
24
+ this isn't a new kind.
25
+ - The payload is `{"next" => 3, "items" => [{"id", "text", "status", "done_when"}]}`, in string keys before a save and
26
+ after one (`List#to_payload`). `Objectives.from_payload` reads either style.
27
+ - It is never sent. The wire gets role and content only, and the frame instructs by itself:
28
+
29
+ ```
30
+ [OBJECTIVES, set by the user. What the user wants this session to achieve; replaces any earlier list. Keep work on it. Say when something falls outside it. Don't change it yourself.]
31
+ O1 [done] Two coffees, open the cart
32
+ O2 Stops before checkout (done when: no order is placed)
33
+ [END NOTE]
34
+ ```
35
+
36
+ A dropped item is left out of the text, and a list with nothing left says `(no objectives now)` so that it still
37
+ replaces the one before it. The frame has no clock, and there are no system prompt lines about it, so a session with
38
+ no list adds no note and no prompt line. It still carries the tool's bytes ([Moves](#moves)).
39
+
40
+ A note body isn't escaped when it is saved, and a system-role note reaches the model as it stands, so outside text
41
+ could carry a line that looks exactly like this frame. On the wire, `Steer.wire_text` leads such a line with `(quoted) `
42
+ (`Steer::FORGED_OBJECTIVES`). That covers every entry with a kind, on both loops: a context note, a peer's `send_note`,
43
+ `chi note`, a delegate report, a steer. A participant's message gets the wider `FORGED_OPENINGS`. The real note is
44
+ known by its `objectives` payload key, never by its text. The saved content stays raw, the rewrite is deterministic,
45
+ and text with no such line goes out byte for byte, so the prompt cache holds. The owner's typed prompt has no kind and
46
+ isn't touched. Tool output isn't covered: it goes out as a tool result, not as a system message.
47
+
48
+ A payload whose `next` isn't an Integer or whose `items` isn't an Array is not a list (`from_payload` answers nil), so
49
+ it can't wipe the fold or restart the ids.
50
+
51
+ ## The current list
52
+
53
+ `Objectives.build(conversation, pending:)` is a pure fold over the entries, shaped like `WorkingSet.build`: the newest
54
+ list note wins, and a pending payload wins over that, because a file not yet absorbed is newer than anything in the
55
+ conversation. `Objectives.pending_payload(session_dir)` finds the newest objectives file among the notes;
56
+ `Objectives.current` is the two together.
57
+
58
+ `build` also fills `List#moves`, the latest `Objectives::Move` per id ([Moves](#moves)). Moves are never in the frame or
59
+ the payload, so an edit and a continue carry none: a new link starts with no last move.
60
+
61
+ ## Editing
62
+
63
+ `/objectives` (`ObjectivesCommand`, registered `anytime`) reads `Objectives.current`. An edit goes out through
64
+ `NoteDelivery.deliver(objectives:)`, which means:
65
+
66
+ - One edit's read and write are one step (`Objectives.with_lock`, a file lock in the session's folder, across threads
67
+ and processes), and the worker's absorb holds it from its claim to the files' delete, so no edit reads between them.
68
+ Each note holds a whole list: without this, two edits that read the same list lost one.
69
+ - `done` refuses a dropped item, and `undo` puts an item back to active.
70
+ - A part with no text of its own refuses the line. An empty done-when is none, and of two done-when groups the last
71
+ counts. A leading `[done]`, `[dropped]` or `[active]` is stripped, so a text can't pass for a status. Any whitespace
72
+ collapses, Unicode line separators too, so each item stays one line of the frame.
73
+ - It never writes session.json and runs on any thread: it only adds a file to `notes/`.
74
+ - It is refused in a session a chi REPL owns (`--no-shared`), with the reason `chi note` gives
75
+ (`NoteDelivery::REPL_REFUSAL`). A show still answers there.
76
+ - A second edit stacks on an unabsorbed first, since the current list reads the pending file.
77
+ - `WorkerInbound#absorb_notes` adds only the newest list in a batch and deletes the others, so edits made during a
78
+ turn cost one entry. It runs between turns: an idle edit lands at the worker's next pass (the fallback tick, or just
79
+ before the next turn), and a mid-turn edit waits for the turn's end. A long coordinator turn is the known gap.
80
+
81
+ The attached TUI and the web composer send the line like any session command (the registry's listing tells them it
82
+ runs mid-turn too). So does `chi send -m "/objectives …"`. A participant's command is refused as any other is.
83
+
84
+ ## Moves
85
+
86
+ The `objectives` tool (`Tools::ObjectivesTool`, schema last in `TOOL_SCHEMAS`) has one action, `move`: `id`, `note`,
87
+ optional `evidence`. A call with no `action` is a move too. It is information only: nothing is written, and the call in
88
+ history is the record. A handler can't append an entry mid-turn safely, and a system entry would split a call from its
89
+ response, so there is no new entry kind. `Objectives.moves_in` reads the calls back with
90
+ `LLMContextStale.paired_runs`, as `WorkingSet` does: both hosts' forms, a `call_tool` of it as it ran. A run whose output
91
+ counts only if its output is the tool's answer that it recorded that id (`ObjectivesTool.recorded?`): a call with no list,
92
+ a refusal, a denial or an interrupt recorded nothing. A rollback that drops the turn drops its moves.
93
+
94
+ Mid-turn, `session.messages` doesn't hold the running turn. `/objectives` adds the loop's own conversation
95
+ (`Engine#live_conversation`, a `dup` read on the command's thread), because `Engine#plugin_messages`' running part (the
96
+ Bridge's `TurnAccumulator#current_messages`) leaves tool calls out. A move shows once its result is in.
97
+
98
+ - **The answer** says it recorded a move, that the list is unchanged and that the user edits it with `/objectives`; so
99
+ does the schema's description. Other actions get an `Error:` with the same sentence. A small model told to edit the
100
+ list has nothing to call, and this is the only place it is told so: no stable-prompt line.
101
+ - **No list**: `There are no objectives here…`, not an error, for a delegate (its fold is empty), a session with no list
102
+ and a kernel without a session (`Tools::Peers#objectives` is nil; `Engine::PeerView#objectives` reads the session).
103
+ - **Refused**: an unknown id, a dropped objective, an empty note. A `done` objective takes a move.
104
+ - **Declared**: on `full` (`all_core`) and on the coordinator kit (its bundle adds it); named in the not-loaded line
105
+ elsewhere, and so counted in `find_tools`' chi line, and among the deferred tools on a host that loads tools.
106
+ - **Cost**: one extra generation when the move is the step's only call, none when it comes in a batch with another call.
107
+ - **Guardrails**: no rule names it, so it passes with no ask, in `strict` too. `ReviewBoundary` denies it as anything
108
+ outside its kit. `PairBoundary` denies it as owner-voiced: a move stays in the owner's history and reads as the
109
+ session's own.
110
+ - **The row**: `recording a move` in the TUI's line with `id=`, `note=`, `evidence=`; the web's row is `objectives O2 note · evidence`
111
+ (`ToolActivity#tool_title`). No UI of its own.
112
+
113
+ `serves` on `ask_user_question` waits until moves read well. The model can't edit the list.
114
+
115
+ ## Proposals
116
+
117
+ `action: propose` (`change` add, reword, done or drop; `id`; `text`; `note`, why) checks the change against the list
118
+ (`Objectives::Proposal#refusal`) and hands it to `Engine#propose_objective`, which keeps it until the turn is over,
119
+ as `#context_choice`'s cards are kept: shown in `run_turn`'s ensure, it is a between-turns card (no `in_turn`), so it
120
+ writes no `pending_card.json`, the stage doesn't say it waits, and it keeps its buttons over a restart (`CardStore` takes
121
+ them only from a card shown in a turn). The same change twice in a turn is one card. A P3 finding stands: a card action
122
+ can't carry a plain reply, so an action here is a real edit.
123
+
124
+ Apply is `/objectives --card ID done O2`: the proposal's own line (`Proposal#line`) behind a prefix the command reads
125
+ (`ObjectivesCommand::CARD`), so it goes through the one route, with its refusals. On success the card with that id is
126
+ replaced by a one-line notice. Dismiss (`--card ID dismiss`) only folds it. Either is recorded under the lock
127
+ (`objectives_cards.json` in the session's folder), so a repeat, from a stale tab or a second tap, applies nothing: it
128
+ says the proposal was already applied and folds the card again. A reword carries what it was proposed against
129
+ (`--was`, `Objectives.fingerprint`) and is refused when the item has changed since. It keeps the item's done-when unless its text
130
+ brings its own. The page keeps a proposal card's buttons off until the folded card comes or the action fails
131
+ (`cards_place.js actionFailed`). A turn leaves at most `PROPOSALS_PER_TURN` (3) cards; past that the tool says so. A refused edit leaves the card, and its
132
+ `command_ran` shows the refusal. Nothing is recorded as a move (`recorded?` is positive); the call stays in history as
133
+ any call. An add's text can't split on ` | `. The body names the line, for a terminal, which shows no button.
134
+
135
+ ## The page
136
+
137
+ `Objectives.view` (`List#to_view`) is what the page draws: the items, each with its last move, and `pending`, whether
138
+ the newest list is a file still waiting. The session read (`objectives`, no key with no list) and the live
139
+ `:objectives_updated` carry it. `Engine#announce_objectives` emits when the view differs from the last: after an edit,
140
+ once a list note is in (`add_context_note`), at a turn's end (its moves) and after a rollback between turns. A session
141
+ with no list emits nothing. Between two tabs that is all it takes. A participant gets neither: the event is in
142
+ `ParticipantView::DROPPED`.
143
+
144
+ Idle, an edit lands at once: the worker's anytime path wakes the loop (`@waker.wake`, one extra pass for any anytime
145
+ command), which absorbs the file. It no longer waits for the fallback tick.
146
+
147
+ - **The wiring** (`objectives_wiring.js`): the routes the strip and the moves post on, each following a woken worker
148
+ only while the page still shows that session.
149
+ - **The strip** (`objectives_strip.js`, `#objectivesStrip`; pure parts in `objectives_model.js`, CSS in
150
+ `objectives.css`). A factory with its own closure (the list; never `store.current`), seeded by `renderSessionView` and reset
151
+ by `forgetSession`. Its edit list is a `chip_popover.js` shell, so a phone gets the bottom sheet and the focus rules.
152
+ Every edit is a line on the command route with `card: true`: no echo, and `commandView` hides a good `/objectives`
153
+ answer that came with that mark, so a tap leaves no bubble, a refusal does. The strip redraws from the event, never
154
+ optimistically.
155
+ - **The entry** (`objectives_entry.js`, from `turn_html.js noteBubbleTemplate`): `Objectives.entry` puts the note's items
156
+ and, against the list before it, `changes` (`Objectives.changes`, display only) on the history item and on
157
+ `context_added`. A first list is pills; a later one a line, `You marked O1 done`. The raw text stays the tooltip.
158
+ - **Moves** (`objectives_moves.js`): a tool row's id is a chip (`moveRowText`), and a row that recorded a move carries
159
+ `data-move-id`. One observer on `#history` reads those back into a line after each finished `.turn-work`, so a reload,
160
+ a live end and a hand-off all draw it. The newest line's Not yet is `objectivesStrip.notYet`. A decision card's
161
+ `Concerns O2` gets the chip there too.
162
+ - **Not yet** is `POST /turn` with a client id of its own (`<tab>:objectives`), so the stream draws its bubble in every
163
+ tab, this one too: the page's own echo needs the composer. It steers a running turn or starts a turn, as a typed line.
164
+ - **Short version**: the same observer offers `/short` (decision-card) under the newest long answer (`foldable`), only with a list
165
+ and with the command in the session's listing, and not during a turn: `/short` itself refuses then, since a running
166
+ turn's "last answer" is interim text. A request that fails, or a worker that leaves with one still out (the bundle's service
167
+ stops after chi's 3 s wait for a command), ends the card saying so; a worker killed outright leaves its one-line `…` row.
168
+
169
+ ## Continue, rollback, fork
170
+
171
+ - **Continue**: `Objectives.carry`, in `SessionContinue`'s `before_spawn`, writes a second note file in the new link,
172
+ after the carry note and any moved context's note, so the list reads as the latest word. It is not in
173
+ `SessionChain.note_text` (capped; the last answer gets what the recap leaves). It reads the previous link fresh,
174
+ with its unabsorbed newest file. A failure is only logged (`continue_objectives_failed`), because it runs inside the
175
+ continue's rollback. A review link's list stays behind, as its last answer does: the next link's participants could
176
+ differ.
177
+ - **Rollback**: a note survives `TurnFlow#restore`, and an unabsorbed edit is on disk, untouched.
178
+ - **Fork, delegate**: a plugin fork copies what it is passed. A delegate starts with an empty fold, so the tool answers
179
+ that there are none.
180
+
181
+ ## The long-answer card
182
+
183
+ A bundle, not core: [decision-card](../bundles.md#the-decision-card-bundle), removed by uninstalling. It reads the list with
184
+ `ctx.objectives`, the plugin API over `Objectives.current`; `items` and `text` are the bundle's only view of the object.
185
+
186
+ `:after_turn` reads the answer's length and tail from `event[:answer]`, not from the stored message, which on the native
187
+ loop still holds its thought block, and starts a thread only when both pass, so the turn's end
188
+ is held for no request and the web's answer doesn't wait (no `event[:present]`). The thread reads the list and, with an active
189
+ objective, makes one `ctx.ask_model`. A Transcript drops system entries, so the list goes in `prompt:`. The objective a reply
190
+ names is display only: an id the list doesn't have, or has dropped, is left out.
191
+
192
+ Stale is a new turn: `:before_turn` takes the running `Ask` and cancels its request, so a one-slot server is free for that
193
+ turn, and `show` holds the same mutex, so a card shows before that start or not at all. The hook runs inside its turn, so the
194
+ card waits for `ctx.turn_running?` to go false: shown earlier, it gets `in_turn: true` and lands in that turn's collapsed block.
195
+ The request doesn't wait: it goes ahead of the turn-end warm-up, so on a one-slot server the warm-up queues behind it and is the
196
+ last thing in the slot, and the next turn keeps its prefilled prompt. A shown card is replaced by a one-line one with
197
+ the same id only at a turn of the owner's (a prompt, no author): a wake has no prompt, and a child's report isn't an answer. A
198
+ worker that restarts forgets a shown card, which then stays as it is: it has no buttons.
199
+
200
+ A card with no actions writes no `pending_card.json`, so it never reaches `Web::Push::Attention`.
201
+
202
+ ## Check this
203
+
204
+ `/objectives check O2` and the list's **Check this** (an objective with a move, the one route) run `ObjectivesCheck.run` on the
205
+ anytime command's own thread: one side request (`Engine#ask_objective_check`, on `ask_side_model`) reads the objective's latest
206
+ move against the session's record, and the answer is a verdict with reasons. Information only: no status changes, it is
207
+ never an approval, no coordinator path reads it, and nothing enters the conversation or the next request.
208
+
209
+ **Why not a `VerifyFresh` child.** The plan said "VerifyFresh-like, fit unchecked". It fits poorly:
210
+
211
+ - `VerifyFresh.start` starts from a saved unclear verdict and its `VerifyAnchor`, a quote of at most 200 characters that
212
+ `verify_anchor_source_session` holds against the parent's text. A move has neither, and a faked one would land in `session.verdicts`.
213
+ - A child is an agent on the session's model with the owner's tools: an unknown number of requests, approvals waiting
214
+ on the child, a place in `session.max_children` and in the session lists, and its spend outside the parent's line.
215
+ Keeping it read-only and out of the delegate paths (`ChildRing`, `ChildrenStatus`) needs a new session field.
216
+
217
+ The side request costs exactly one request, shows in the session's side line (kind `objectives`) and comes out with one
218
+ file. What it gives up: **it can't open a file or run anything.** It reads what the session saw, so a claim that nothing in
219
+ the record settles is `unclear`, and a file changed since isn't seen. If that turns out too weak, a read-only child is the
220
+ next step, and the file and view below can stay.
221
+
222
+ **Independence.** The request (`ObjectivesCheck.request`) has its own system text and no tools. Its record is what people and
223
+ notes said, what tools returned, and the agent's visible answers labelled `[agent's own words]` (`.record`, each entry cut at 1,500 characters, the
224
+ newest 24,000 kept). Without those answers a claim about what the agent said or asked could only come back `unclear`. So the request says they settle
225
+ what was said or asked and are never proof of work, which needs a tool's output or a person's word. That rule is words to a model, not a mechanism: a
226
+ probe on Qwen3.8 Flash Next held it 3 of 3 (only the agent's "all pass": `unclear`), and a verdict is information only either way.
227
+ The record never holds the agent's thinking (`own_words`: a chat entry's `thinking` and `anthropic_content` aren't read; a native
228
+ entry's content is the raw response, so its inline thoughts, a thought cut off unclosed, and its call markup come out), the system prompt, the list's
229
+ notes, a continue's carry note, an earlier session's answer that would read as outside news
230
+ (`Objectives.outside_note?`, the rule the audit shares), or the objectives tool's calls, since a move isn't evidence for itself.
231
+ Note and evidence are capped at 1,000 characters each. Mid-turn it reads the loop's conversation (`Engine#live_conversation`), the running turn's runs too.
232
+
233
+ **Its frame.** The claim and the record sit between `<record-RANDOM>` tags made per request, so tool output printing a closing tag
234
+ stays inside; the request says that what's inside is data and its instructions aren't to be followed. No model is needed for
235
+ the frame to hold; a model may still be swayed by text inside it, which is why a verdict is information only.
236
+
237
+ **Its host.** The model is the session's, or `objectives.check_model`, whose host may be another one. The whole user text goes through
238
+ `Web::ParticipantView::Redact.text`, chi's participant redaction: known key shapes and `token=`-style values become `[redacted]`, and
239
+ the home folder `~`. That is by pattern, so a secret of another shape still goes.
240
+
241
+ **State.** `objective_checks.jsonl` beside the session: a `running` line, then one with `verdict` (confirmed, refuted, unclear,
242
+ `no_answer`), `reason`, `text`, `model` and `mark`: a digest of the item's line as sent and the move itself (`Move#ref`, its run's
243
+ id and its model entry's time, with its words). The start is under `Objectives.with_lock`, so a double tap or a second tab answers "already
244
+ being checked" and asks nothing. A running line stops counting once its worker's pid is gone, so a kill, a shutdown or a restart frees
245
+ the next check at once, and past `RUNNING_SECONDS` in any case. `Objectives.view` puts the last check on its item (`check`), only while `mark`
246
+ matches: a reword, a status change or a newer move, in the same words too, offers the check again. A running check carries `until`, because no event says
247
+ its worker died: the strip lets the row go at that time (`armExpiry`), and a reconnect's session read clears it sooner. `announce_objectives` carries the start and the end. A continue doesn't carry it.
248
+
249
+ **Shown as** a card with no actions (`source: objectives`, on the command's thread, so `anytime` and not in a turn's block, and it
250
+ writes no `pending_card.json`, so no push), the list's row, and `check:` under the move in `/objectives`. A model that fails is
251
+ `no_answer`: logged (`objectives_check_failed`), an info card saying so, and the next tap may try again.
252
+
253
+ **Limits.** Refused during a participant's turn, whose text stays out of a side request; one ending during their turn is still the owner's (`Engine#side_author`). A participant can't reach it: the
254
+ command route is the owner's, and `card` and `objectives_updated` are in `ParticipantView::DROPPED`. A move made in the running
255
+ turn isn't on the page until the turn's end (`announce_objectives`), so its tap waits for that; the typed line works at once.
256
+
257
+ ## System 1 in audit
258
+
259
+ `ObjectivesAudit`, off unless `objectives.system_one: audit`. See [system-one.md](../system-one.md#objectives-in-audit).
260
+
261
+ ## Not in this version
262
+
263
+ Stable-prompt lines aren't here, and terminals have no picture: they keep `/objectives`.
264
+
265
+ Taking it out: old notes stay readable framed bubbles and keep being sent, and the key is ignored. Old `objectives` calls
266
+ stay as history; with a kit no longer declaring the tool, they are ordinary saved calls. The card's bundle uninstalls. The
267
+ page's pieces are the `objectives_*.js` modules and `objectives.css`, wired in a few lines of `app.js`; without them a list note
268
+ falls back to the default bubble once `noteBubbleTemplate`'s first line goes. Check this is `objectives_check.rb`, its line in the command and
269
+ the `check` key; the audit is `objectives_audit.rb` and three calls in `Engine`. Old `objective_checks.jsonl` files and saved runs are then read by nothing.
@@ -23,7 +23,7 @@ page. A factory dep a new version stops passing is the same hazard the other way
23
23
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
24
24
  until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
25
25
  `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
26
- `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep) and `worker_features.js` are new modules for that reason.
26
+ `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js`, `block_open.js` and the `objectives_*.js` modules are new modules for that reason.
27
27
 
28
28
  ## Two pages, two models
29
29
 
@@ -54,6 +54,9 @@ change.
54
54
  | **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
55
55
  | **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
56
56
 
57
+ The objectives' strip keeps the list in its own closure (`objectives_strip.js`, seeded by `renderSessionView`, reset by
58
+ `forgetSession`, updated by `objectives_updated`): [objectives.md](objectives.md#the-page).
59
+
57
60
  The start page's own choices (the model, the ⚙ panel, the 🧰 list) live in `start_page.js`'s closure, never in
58
61
  `store.current`. A fresh chain link's picker reads both.
59
62
 
@@ -181,9 +181,9 @@ from saying it twice across restarts). The key's `chi:` prefix keeps sw.js from
181
181
 
182
182
  | `kind` | key | title / body |
183
183
  |---|---|---|
184
- | `installed` (`update.restart: never`) | `chi:installed:<X>` | "chi X is installed" / "chi web and its sessions switch over when you restart them." + kept edits |
184
+ | `installed` (`update.restart: never`) | `chi:installed:<X>` | "chi X is installed" / "chi web and its sessions switch over when you restart them." + the edited files (`FileStanding.counts_sentence`) |
185
185
  | `failed` | `chi:update_failed:<X>` | "chi X didn't install" (or "installed with a problem" when the gem row was fine) / the first failed row's status, "Open chi web for details." |
186
- | `updated` | `chi:updated:<X>` | "chi updated to X" / "chi web and 3 sessions restarted; 1 restarts when idle." + kept edits |
186
+ | `updated` | `chi:updated:<X>` | "chi updated to X" / "chi web and 3 sessions restarted; 1 restarts when idle." + the edited files |
187
187
  | `restart_failed` | `chi:restart_failed:<X>` | "chi X is installed; chi web didn't restart" / why (a failed `--version` preflight, no wrapper), "Restart chi web in its terminal." |
188
188
 
189
189
  `updated` is sent by the **restarted** chi web (its AutoUpdate finds the run at `phase: web`): the exec ends the
@@ -242,7 +242,10 @@ session's set (load, resync) and when the list opens (a file changed while the s
242
242
  passes `working_set_updated` to it, and does nothing when the flag is off. A new snapshot redraws:
243
243
  - the session bar's chip and the open list;
244
244
  - the footprints, in place (`refreshFootprints`, the same node; a `data-ws-sig` of the items' states skips an
245
- unchanged one);
245
+ unchanged one). A footprint is a `<span>` in its block's summary line, `.turn-work > summary > .tw-sum`, before the
246
+ steps' `.turn-steps`: drawn in the reload's template (`turnHistoryNodes`), prepended at the hand-off
247
+ (`stage_view.js handOff`, while the stage still hides the summary). A click there must not toggle the block:
248
+ `details_motion.js` skips `.ws-foot`, and `footClick` cancels the click;
246
249
  - the rail (`onServer`, the stage's `scheduleRefresh`).
247
250
 
248
251
  A state is the item's state now, not when its turn ran. A state change never refolds and never moves a line: the
@@ -287,7 +290,7 @@ leaves nothing behind. The rail adds a dashed `+ name · next turn` line per pen
287
290
  (`working_set_rail.js addLabels`, inside the rail's budget), and `read again next turn` on a held line's title.
288
291
 
289
292
  **Looks** (design spec §3, §5): a stale line is faded (`--ws-stale-opacity`: `.55` on the light palette, `.4` on the
290
- dark one, where a lighter grey reads as plain secondary text; a footprint label `.55` on both, its text already faint); a forgotten one is struck in `--text-faint`
293
+ dark one, where a lighter grey reads as plain secondary text; a footprint label `.55` on both); a forgotten one is struck in `--text-faint`
291
294
  at `opacity: .5`. A group shows `✂n` for its forgotten members. It is struck only when all its members are
292
295
  forgotten, and faded when all are stale or forgotten (`groupState`). The list's ops text says `stale` or `forgotten`
293
296
  in words (colour is never alone) and shows `~1.2k` tokens per row (`tokensText`). A footprint adds `n stale` (plain; a file the turn's
data/docs/memory.md CHANGED
@@ -187,7 +187,7 @@ Working habits for this model:
187
187
  `model_notes_<name>.md` you already have and never owns it, so your note
188
188
  is what loads, the two never stack, and upgrades leave it alone.
189
189
  `chi bundle install model-notes` says `Skipped model_notes_deepseek.md
190
- (already exists; use --force to overwrite)`; through the `dev` profile
190
+ (already exists; --force takes the bundle's version, your file copied to the trash first)`; through the `dev` profile
191
191
  (`chi bundle install dev`, `chi bundle upgrade dev`, `chi update`) it is
192
192
  `model-notes: kept your own model_notes_deepseek.md (not the bundle's; to
193
193
  take the bundle's, remove yours with chi memory rm model_notes_deepseek
@@ -197,14 +197,14 @@ Working habits for this model:
197
197
  (`chi memory rm model_notes_deepseek --scope system`, or `memory_write`
198
198
  with `remove: true`, moves it to the trash) and run
199
199
  `chi bundle install model-notes` again: the install writes the missing
200
- file and owns it from then on. Don't use `--force`: it overwrites every
201
- file of the bundle with no trash copy.
200
+ file and owns it from then on. `--force` overwrites every file of the
201
+ bundle instead, each one that differs copied to the bundle trash first.
202
202
  - **Other notes stack.** A note of another name for the same models
203
203
  (`model_notes_my_deepseek`), or a project-scope `model_notes_deepseek`,
204
204
  loads beside the bundle's (notes are told apart by their file, not their
205
205
  name), and the total may pass the 3,000-character warning.
206
- - **Your edits to an installed note** are kept by an upgrade (a 3-way
207
- merge), or come back as a conflict.
206
+ - **Your edits to an installed note** survive an upgrade: it merges them
207
+ with the new version, or leaves your file as it is on a real conflict.
208
208
  - Without a model note the system prompt is what it was.
209
209
 
210
210
 
@@ -487,7 +487,9 @@ for a file another bundle installed.
487
487
  and the output names them and the dir:
488
488
  `Moved to the trash: notes.md (…/.trash/team-notes-20261003-120000)`. So
489
489
  does an upgrade's removal of a file the new version no longer ships. A
490
- file you edited still needs `--force`. `chi bundle trash` lists the trash
490
+ file you edited still needs `--force`. A hook, rule, plugin or script you
491
+ edited isn't asked about: it is copied to the same dir (`Copied to the trash
492
+ first: scripts/x (…)`) and the bundle's own copy goes. `chi bundle trash` lists the trash
491
493
  (oldest first, with file count, size, and age); `--empty` deletes all
492
494
  folders, `--older-than DAYS` keeps only recent ones, and `--dry-run`
493
495
  previews. Hooks, rules and the plugin live in the bundle's own dir and
@@ -509,12 +511,48 @@ for a file another bundle installed.
509
511
  `.tgz` or `.tar` gives that archive, any other path a directory; default
510
512
  `<name>.zip`).
511
513
  - **An upgrade that meets your edits.** `chi bundle upgrade` updates a file
512
- you didn't edit and keeps one you did (a conflict), then asks on a
514
+ you didn't edit. One you edited that the bundle also changed takes a text
515
+ merge (with `git`, which chi doesn't otherwise need): when it comes out clean
516
+ the file holds both, the old one is in the bundle trash, and the output says
517
+ `merged 0.8.0 into your X` (a dry run, `would merge 0.8.0 into your X`). Entries both sides added under a
518
+ `Changelog` heading are kept, yours first. A clean merge is text, not
519
+ meaning: two sentences can still disagree, so read what you care about (`chi
520
+ bundle trash` lists the old copy).
521
+ - **A real conflict** leaves your file untouched, never with markers in it, and
522
+ marks it. So does no `git`, a file with no recorded base, or a file marked by
523
+ an older chi (below). The output says `X stays behind 0.8.0:` with why and what
524
+ it missed, as `chi update`, `chi bundle status` and `chi bundle list` (`1 file
525
+ behind`) do from then on, until the file has the bundle's bytes; the bundle's text
526
+ waits in `.bundles/<name>/incoming/` until it settles. The upgrade then asks on a
513
527
  terminal whether to launch a chi session that merges the two (`/exit`
514
528
  when done). `--agent` launches it without asking, `--no-agent` never does;
515
- with neither and no terminal it keeps your edits and says so. Kept edits
529
+ with neither and no terminal the file just stays behind. Only a file
530
+ the session changed, with no conflict markers left, counts as resolved: any
531
+ other stays a conflict (`Nothing changed in X: still behind.`). A file marked
532
+ this way is retried at the same version: the system bundle's at each chi start
533
+ and on `chi update`, another shipped bundle's on `chi update`, and any other
534
+ bundle's on `chi bundle upgrade`.
535
+ - **A file marked by an older chi** merges normally when its recorded base is a
536
+ version chi shipped, which chi checks against the samagotchi gems still
537
+ installed. Otherwise that base may be your own text, so the file merges only if
538
+ the bundle takes out or rewords no line it still holds. If it does, chi can't
539
+ tell whether the file holds edits of yours and says that; the file
540
+ stays behind until you resolve it by hand, with `--agent` or with `--force`, and
541
+ the retry can't fix that. A file left behind makes the upgrade
516
542
  exit 2; `chi bundle diff NAME FILE` shows the base, and `--force` takes
517
- the bundle's version.
543
+ the bundle's version, copying a file that differs to the bundle trash first
544
+ (the output says `Copied to the trash first: …`).
545
+ - **Every merge is noted on the file's record.** The system bundle's upgrade and
546
+ retry run at each start, a worker's only output is its log, and no one may read
547
+ an automatic update. So `chi bundle status` shows the merge with the old copy's
548
+ folder while the note exists. A `chi update` row or `chi bundle upgrade` output
549
+ printed to you ends it for what it printed (an automatic update leaves it), and
550
+ `--force` replacing the file ends it too. No session is told at start.
551
+ - **`chi bundle status`** says per file what is `modified`, `missing` or
552
+ `unreadable`, and that a file stays behind and why. Its `issues=` counts files
553
+ behind, missing or unreadable (and a plugin or hooks that won't load), not edits. A
554
+ file an older chi marked, which no retry has looked at, says `may be behind`, and so
555
+ do the counts in `chi bundle list`, `chi self` and `chi update`'s last line.
518
556
  - **A dev install** (`chi bundle install --dev PATH`) links the hooks, rules,
519
557
  plugin and scripts to `PATH` but still copies the memories at install, so
520
558
  an edit to one of them in `PATH` isn't seen until the next install.
data/docs/plugins.md CHANGED
@@ -850,10 +850,12 @@ full label, `plugin.rb (bundle my-bundle)`.
850
850
  | `ctx.messages` | the conversation, as a frozen copy, without the system prompt. While a turn runs, a session worker's (attached, web) adds that turn so far: its prompt, the model's text and the lines merged into it (no tool calls or thinking); the REPL's is the conversation before that turn. An entry may carry `at`, when it happened (docs/sessions.md, Message times) |
851
851
  | `ctx.messages_partial?` | whether `ctx.messages` leaves out a running turn (the REPL mid-turn), so a plugin can say what its answer is about |
852
852
  | `ctx.prompt_files` | the files whose text is in the session's system prompt now: the AGENTS.md chain (the work tree top down to the cwd) and the appended files (`append_system_prompt:`, `--append-system-prompt`); absolute paths, in prompt order, `[]` before the first build |
853
+ | `ctx.objectives` | the session's [objectives](internals/objectives.md), read now, as a frozen value: `empty?`, `items` (each `id`, `text`, `status` `"active"`, `"done"` or `"dropped"`, `done_when`), `find(id)`, `text` (the lines the model is shown) and `moves` (the latest move per id; mid-turn, a worker's leaves the running turn's out). An edit not yet absorbed counts. Empty when the session has none. Use its methods, not its class. Needs chi newer than 0.148.0 |
853
854
  | `ctx.notify(text, level: :info, fallback_for: nil)` | one line to the user, labelled by the bundle (`my-bundle> …`). Every UI shows it, during a turn (a tool, a hook) or between turns (a command). `fallback_for: :display` marks a line that repeats the answer's display (`event[:present]`), which a UI that renders the display leaves out (`fallback_for: :editor`: a file the viewer could open from a ref in its editor): see [Hooks](hooks.md#what-a-hook-can-do-the-runtime). The keyword needs chi 0.35.0 (it raises on an older chi) |
854
855
  | `ctx.card(title:, body: "", actions: [], level: :info, id: nil)` | a card in every UI, returning its id: see [Cards](#cards) |
855
856
  | `ctx.ask_user(question:, options:, header: nil, allow_freeform: false, effects: nil)` | a single-select question through the question flow: `{selected:, freeform:, selected_indices:}`, or nil (no one to ask, cancelled, bad options or effects). `effects:` tags the options a parent agent's answer is held to (label => `:spend`, `:permit`, `:permit_wide` or `:deny`; untagged = `:answer`): tag every option that spends or permits, and never let text alone or a dismissal do either ([hooks.md](hooks.md#what-a-hook-can-do-the-runtime)). It needs a chi newer than 0.85.0 |
856
857
  | `ctx.cancelled?` | whether the running turn was cancelled (a long tool should stop) |
858
+ | `ctx.turn_running?` | whether a turn runs now. An `:after_turn` hook runs inside its turn, so a card a thread shows before the turn is over belongs to that turn's block, which the web collapses: wait for this to go false. Needs chi newer than 0.148.0 |
857
859
  | `ctx.steer(text)` | put text into the running turn: its own user message at the loop's next boundary, shown as `my-bundle> nudged: …`. The model reads it with a header naming your bundle (`[Steer from the my-bundle plugin, mid-task. …]`); the session keeps the raw text. Returns true when queued, false with no turn running (it never starts one; that is `ctx.sessions`' send). Dropped (logged) if the model answers or the turn ends first. Safe from any thread: an anytime command, a hook (false from `:after_turn`), your own |
858
860
  | `ctx.stop_turn(reason)` | stop the running turn after a warn notice with the reason; true when it stopped one now. In a `:before_tool_call` block it also denies that call |
859
861
  | `ctx.stop_generation(reason)` | cut the generation that is streaming: the turn goes on and the model is asked again ([hooks.md](hooks.md#watching-the-stream)). Shows nothing: post your own notice. True when it cut one now |
@@ -940,6 +942,10 @@ worker gone), then moves out of the block, after the turn's end line, so it
940
942
  stays in sight; a reload puts it in the same place. An `:info` card of a
941
943
  completed turn stays in its step.
942
944
 
945
+ Between turns, a card goes above the first turn prompt after it, live and
946
+ after a reload. A failed turn whose prompt went back to its sender isn't a
947
+ turn: its prompt line comes after the cards.
948
+
943
949
  An [anytime command](#anytime-true)'s cards show as it shows them, after its
944
950
  line, in every UI, whether a turn runs or not.
945
951
 
@@ -1377,8 +1383,10 @@ that calls it, or for a real external bundle that does. No method on the chance
1377
1383
  `Samagotchi::Tools::Execute.timeout_seconds`.
1378
1384
  5. `Samagotchi::Tools::TaskRuntime::TASKS_DIR` and `EXIT_CODE_FILENAME`.
1379
1385
  6. `Samagotchi::AtomicFile.write`, `Samagotchi::Log.warn` /
1380
- `Samagotchi::Log.debug`, `Samagotchi::Config.get` and
1381
- `Samagotchi::VERSION`.
1386
+ `Samagotchi::Log.debug`, `Samagotchi::Config.get`,
1387
+ `Samagotchi::VERSION`, `Samagotchi::CancellationController` (`new`, `cancel!`: what `cancel:` takes) and
1388
+ `Samagotchi::GitEnv::UNSET` (a spawn env: a bundle's own git, or gh, asks about the folder it names, not the repo
1389
+ a hook's or `rebase --exec`'s environment names).
1382
1390
 
1383
1391
  `Samagotchi::Testing::Boundary::ALLOWED` is the checked copy of this list,
1384
1392
  and `Boundary::EXCEPTIONS` the per-bundle list of what each shipped bundle
data/docs/releasing.md CHANGED
@@ -13,7 +13,7 @@ can run the whole release; the user approves the notes before the tag and the
13
13
  and `rake release:check` enforce it). The system bundle upgrades itself when
14
14
  chi starts.
15
15
  - **Every other shipped bundle** (btw, check-in, guardrails, known-names,
16
- loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, work-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, model-notes, and the profiles core and dev) has its own semver in its
16
+ loop-guard, mcp, skills, slow-commands, agents-md, cost-cap, work-cap, source-links, github-pr, coordinator, claude-peer, codex-peer, ruby, model-notes, decision-card, and the profiles core and dev) has its own semver in its
17
17
  `manifest.yml` and, when it needs a newer chi, a `requires_chi:` line. They never upgrade by themselves: users run
18
18
  `chi update` (all of them, with the gem) or `chi bundle upgrade NAME`. So:
19
19
  - a change to a bundle's files bumps that bundle's `version:`