samagotchi 0.158.0 → 0.159.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/bin/chi +3 -1
  4. data/docs/architecture.md +7 -6
  5. data/docs/cli.md +56 -1
  6. data/docs/configuration.md +9 -0
  7. data/docs/context.md +7 -1
  8. data/docs/guardrails.md +3 -1
  9. data/docs/hooks.md +3 -0
  10. data/docs/internals/bridge.md +1 -0
  11. data/docs/internals/bundles.md +9 -7
  12. data/docs/memory.md +5 -1
  13. data/docs/scheduling.md +228 -0
  14. data/docs/sessions.md +25 -1
  15. data/lib/samagotchi/bridge.rb +26 -2
  16. data/lib/samagotchi/bundle_command.rb +38 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/client_id.rb +13 -3
  19. data/lib/samagotchi/config.rb +12 -0
  20. data/lib/samagotchi/context_move.rb +74 -0
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +1 -1
  22. data/lib/samagotchi/memory_bundle/asset_installer.rb +84 -103
  23. data/lib/samagotchi/memory_bundle/asset_state.rb +81 -0
  24. data/lib/samagotchi/memory_bundle/file_actions.rb +51 -0
  25. data/lib/samagotchi/memory_bundle/shipped_update.rb +17 -12
  26. data/lib/samagotchi/memory_bundle/status.rb +20 -1
  27. data/lib/samagotchi/memory_bundle/uninstaller.rb +10 -4
  28. data/lib/samagotchi/run_options.rb +1 -0
  29. data/lib/samagotchi/schedule_command.rb +252 -0
  30. data/lib/samagotchi/schedules.rb +948 -0
  31. data/lib/samagotchi/session_access/contract.rb +1 -1
  32. data/lib/samagotchi/session_access/local.rb +20 -0
  33. data/lib/samagotchi/session_manager/spawn.rb +25 -11
  34. data/lib/samagotchi/session_manager/worker_command.rb +13 -13
  35. data/lib/samagotchi/terminal_ui/formatting.rb +2 -0
  36. data/lib/samagotchi/tool_declarations.rb +43 -0
  37. data/lib/samagotchi/tools/builtin_calls.rb +2 -0
  38. data/lib/samagotchi/tools/builtins.rb +17 -0
  39. data/lib/samagotchi/tools/cancel_schedule.rb +43 -0
  40. data/lib/samagotchi/tools/list_schedules.rb +43 -0
  41. data/lib/samagotchi/tools/schedule_task.rb +39 -0
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/public/chains.js +32 -8
  44. data/lib/samagotchi/web/public/index.html +4 -1
  45. data/lib/samagotchi/web/public/info_bar.js +4 -3
  46. data/lib/samagotchi/web/public/turn_events.js +4 -0
  47. data/lib/samagotchi/web/session_hub.rb +20 -0
  48. data/lib/samagotchi/worker.rb +24 -6
  49. data/lib/samagotchi/worker_boot.rb +3 -4
  50. data/lib/samagotchi/worker_inbound.rb +23 -0
  51. data/lib/samagotchi/worker_link.rb +5 -0
  52. data/lib/samagotchi/worker_wakes.rb +9 -1
  53. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7be08e98d08c8d5fae428eee5ca7a1439c44cda4354693cc542e712cd4aa9ce2
4
- data.tar.gz: '09a1d00545460e98a25a4076314b1202822ad7ab71377e17d5917d8a05634fa0'
3
+ metadata.gz: ab720ab347312f152fc99a03ca8cd423a640de2c9b4221ccc70803396a2d8c09
4
+ data.tar.gz: 313bbe5c7cce008df50c73de233557824723566db8449a8e3a04ff01e85343f9
5
5
  SHA512:
6
- metadata.gz: '0149b416ea8e87e57e95c1cbe32a313a18a9735c50655f74d59531c41ab3b7785c2d8a3a2a41b458fab6308e802496e381d9b2f2ad062105b683608c603c9b67'
7
- data.tar.gz: d01cee2cdcfafdc1bb0f6f88e9b99ddbd85a72bda7c096b2e23ab67eeac68857836ab4bd557b4dc98397634be7b0495f17230e473ccd4695d1f189fd4da714df
6
+ metadata.gz: 4c02cc469245adaa130c74b1e10978c11b51e85f7ff8c2fe9c27d0de68682d0452f07d9dec7f9100c032dac9cf7357bff81fc70586c9c6cb1e99ab0327c68d56
7
+ data.tar.gz: 7ffaacdca84dc1396e5f6edd1160b54e063daa38b71d9aa7c36d7619495d26271606efd277024f344e18043ed2112f3343ea127560438bea0f5480b98df617f3
data/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.159.0] - 2026-11-09
12
+
13
+ Update with `chi update`.
14
+
15
+ ### Added
16
+
17
+ - `chi schedule <add|list|show|tick|run-now|enable|disable|cancel>`: durable schedules that outlive every worker. Each entry is a prompt with a clock in `~/.local/state/samagotchi/schedules.json`, fired by a tick run by cron, systemd or (with `schedule.auto_tick: true`) chi web's hub, and each firing is either the next link of the session's chain (the default, with `--keep-links` pruning the older links and their delegates) or a turn queued in the session itself (`--mode same`). Recurrence is `--every 45m`, `--weekday 1 --time 09:00` or `--date 2026-11-20T09:00`; a busy target is skipped and retried at the next tick, and 12 consecutive skips or 3 consecutive failures disable it (any success resets both). The model registers its own with `schedule_task`, `list_schedules` and `cancel_schedule`. `register_reminder` is unchanged: a reminder lives in a live worker and holds it up; a schedule wakes one. See docs/scheduling.md.
18
+
19
+ ### Changed
20
+
21
+ - `chi bundle status NAME` lists the bundle's guardrail rules and scripts, and says which you edited (an edited one is
22
+ replaced by the next install, its copy kept in the trash).
23
+
24
+ ### Fixed
25
+
26
+ - A peer's wake push now starts a session that went idle (never one you stopped).
27
+ - Continuing a session carries a peer's pushed text the old session hadn't absorbed or read yet, as a separate
28
+ third-party context note.
29
+
11
30
  ## [0.158.0] - 2026-11-08
12
31
 
13
32
  ### Added
@@ -5865,7 +5884,8 @@ and long-lived sessions.
5865
5884
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5866
5885
  hotkey panel that send selected text or the clipboard to your sessions.
5867
5886
 
5868
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.158.0...HEAD
5887
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.159.0...HEAD
5888
+ [0.159.0]: https://github.com/dm1try/samagotchi/compare/v0.158.0...v0.159.0
5869
5889
  [0.158.0]: https://github.com/dm1try/samagotchi/compare/v0.157.0...v0.158.0
5870
5890
  [0.157.0]: https://github.com/dm1try/samagotchi/compare/v0.156.0...v0.157.0
5871
5891
  [0.156.0]: https://github.com/dm1try/samagotchi/compare/v0.155.0...v0.156.0
data/bin/chi CHANGED
@@ -104,7 +104,9 @@ SUBCOMMANDS = {
104
104
  # the chi home: the Ruby every chi launch boots (set, doctor)
105
105
  "home" => ["samagotchi/home_command", "HomeCommand"],
106
106
  # this folder's project; init marks a folder in no git repository (.chi/project.yml)
107
- "project" => ["samagotchi/project_command", "ProjectCommand"]
107
+ "project" => ["samagotchi/project_command", "ProjectCommand"],
108
+ # durable schedules that outlive every worker (add, list, show, tick, run-now, …)
109
+ "schedule" => ["samagotchi/schedule_command", "ScheduleCommand"]
108
110
  }.freeze
109
111
  # chi --remote NAME <command> (or CHI_REMOTE=NAME): the session commands
110
112
  # against another machine's chi web; what runs here only is refused (exit 2).
data/docs/architecture.md CHANGED
@@ -105,7 +105,7 @@ terminal renders the events it gets over the Bridge.
105
105
  | Adapters | `Samagotchi::Client`, `LLM::OpenAIChat`, `LLM::AnthropicMessages`, `LLM::HTTP` | Raw-prompt servers, the OpenAI chat API, Anthropic's Messages API, and the HTTP they share. |
106
106
  | Tools | `lib/samagotchi/tools/*` | Execute, read, edit, write, memory, task_*, web_fetch, delegate/delegate_result, ask_user_question, reminders, send_note, list_sessions, context_read, objectives, forget_outputs, find_tools/call_tool (the tool index), plus runtime/output-guardrails. |
107
107
  | Bundles | `lib/samagotchi/memory_bundle/*`, `BundleCommand` | `chi bundle`: install/upgrade (3-way, with a text merge of edited memories), build, uninstall; the install record under `.bundles/<name>/` that the guardrail and plugin loaders read ([internals/bundles.md](internals/bundles.md)). |
108
- | Background | `Samagotchi::Worker`, `Samagotchi::SessionManager` | `SessionManager` (`session_manager.rb`, its parts in `session_manager/`: spawn · worker_command · listing · delivery · lifecycle · removal · emptiness) forks/spawns one worker process per session. The worker process's main (owner lock, detach, signals, project env, what it does as it leaves) is `WorkerBoot` (`worker_boot.rb`), which SessionManager loads only as a worker boots (its `run_session_loop` forwarder); its `Worker` builds the `Engine` and `Bridge` (no terminal rendering) and runs queued turns. The spawned process (own process group) forks the worker, which `setsid`s and is reparented to init once its starter exits (it waits until the worker holds `owner.lock`, so the spawner's `Process.detach` waiter tells a starting worker): nothing that walks the spawner's process tree reaches it (`SAMAGOTCHI_WORKER_DETACH=1`, `detach_from_spawner`). The lock's pid is the worker's; `spawn`'s `child_pid` is its starter's. |
108
+ | Background | `Samagotchi::Worker`, `Samagotchi::SessionManager` | `SessionManager` (`session_manager.rb`, its parts in `session_manager/`: spawn · worker_command · listing · delivery · lifecycle · removal · emptiness) forks/spawns one worker process per session. The worker process's main (owner lock, detach, signals, project env, what it does as it leaves) is `WorkerBoot` (`worker_boot.rb`), which the boot string loads directly; SessionManager's `run_session_loop` forwarder stays for older spawners' strings. Its `Worker` builds the `Engine` and `Bridge` (no terminal rendering) and runs queued turns. The spawned process (own process group) forks the worker, which `setsid`s and is reparented to init once its starter exits (it waits until the worker holds `owner.lock`, so the spawner's `Process.detach` waiter tells a starting worker): nothing that walks the spawner's process tree reaches it (`SAMAGOTCHI_WORKER_DETACH=1`, `detach_from_spawner`). The lock's pid is the worker's; `spawn`'s `child_pid` is its starter's. |
109
109
  | Web | `Samagotchi::Web::App`, `Samagotchi::Web::Server`, `Samagotchi::Web::SessionHub` | Rack+WEBrick on one port, `127.0.0.1:4567` (index.html + `/api/*` + SSE); with `web.host: lan` also on this machine's LAN address, behind an access token (`Web::Lan`, `Web::Token`). The hub is chi web's projection of the session list, pushed to every tab over `GET /api/events`. |
110
110
  | Sessions | `Samagotchi::Session`, `SessionManager` | File `sessions/<uuid>.json` + sidecar `input/`/`output/`; retention 14d/500, `updated_at desc`, lazy sweep. |
111
111
 
@@ -162,9 +162,9 @@ Unchanged: `ContextProviders`' `{ruby}` (`RbConfig.ruby`: the home's ruby inside
162
162
  project env below, so a `mise env` that sets GEM_HOME reaches it), `chi bundle
163
163
  try/test/check` (`-I LIB_DIR`), hooks (in-process). The worker's boot string, `run_session_loop(id, state_dir:)` and
164
164
  `SAMAGOTCHI_BOOT_FALLBACK` stay the cross-version contract (`spec/worker_contract_spec.rb`): the home only adds the
165
- prelude in front. The string names `SessionManager.run_session_loop`, which only forwards to
166
- `WorkerBoot.run_session_loop`, the worker's main; the string switches to `WorkerBoot` one release later, and the
167
- forwarder goes the release after that. An older chi that wakes a session doesn't read home.json and starts its worker as before.
165
+ prelude in front. The string names `WorkerBoot.run_session_loop`, the worker's main; the SessionManager forwarder
166
+ remains one more release for boot strings written by older spawners. An older chi that wakes a session doesn't read
167
+ home.json and starts its worker as before.
168
168
 
169
169
  **The tools a worker runs** follow the project, not the boot, and only they do: a spawned worker
170
170
  (`WORKER_PROCESS_ENV`; never an in-process loop) calls `ProjectEnv.compute` in `WorkerBoot.run_session_loop`, after the owner
@@ -194,7 +194,7 @@ log's last 4 MB). Rows: [configuration.md](configuration.md#checking-it-chi-home
194
194
 
195
195
  ## Session retention & ordering
196
196
 
197
- - **Files:** `~/.local/state/samagotchi/sessions/<uuid>.json` + `<uuid>/input|output|owner.lock|bridge.json` (XDG-aware), and the `<uuid>/stopped` and `<uuid>/archived` marker files.
197
+ - **Files:** `~/.local/state/samagotchi/sessions/<uuid>.json` + `<uuid>/input|output|owner.lock|bridge.json` (XDG-aware), and the `<uuid>/stopped` and `<uuid>/archived` marker files. Durable schedules belong to the machine, not a session: `~/.local/state/samagotchi/schedules.json` and its `schedules.lock`, next to `sessions/` ([Scheduling](scheduling.md)).
198
198
  - **Single owner:** the process running a session's Engine (its worker, or a `--non-interactive` one-shot) holds a flock on `owner.lock` (`OwnerLock`); a second owner backs off, and the web answers 409 for a session a one-shot owns.
199
199
  - **Status:** `status` is turn state (`idle`/`running`); liveness is the lock. A stop is the `<uuid>/stopped` marker (`Session.mark_stopped`; a resume removes it), which `Session.load` and `.summary_from_file` read as status `stopped`, so no other process rewrites a worker's session file.
200
200
  - **Retention:** 14 days / 500 cap (env `SAMAGOTCHI_SESSION_RETENTION_DAYS`/`MAX_COUNT`, optional `KEEP_STATUS`), live-owner guard, only when `*.json` present; lazy sweep at most once per `session.sweep_interval_hours` (default 24) from the session hub's full-probe tick (and on `GET /api/sessions`, which the page no longer calls), manual via `bin/chi sessions prune --dry-run`.
@@ -432,7 +432,7 @@ shared with `/api/sessions` and the session view; built on the session's `Sessio
432
432
  reads, so the page, `chi sessions list` and `/children` agree on status, liveness and a waiting question) carries `owner`, `project_root` and
433
433
  `bridge_up` (`WorkerLink` answers Live: a worker holds the lock and its sidecar names its pid; the page attaches its stream on it), and
434
434
  from that sidecar `worker_version` (the chi the worker runs) and `worker_features`
435
- (`Bridge::FEATURES`, what a client may ask of it beyond the routes: `restart`, `task_stop`, `reparent`, `unqueue`,
435
+ (`Bridge::FEATURES`, what a client may ask of it beyond the routes: `restart`, `task_stop`, `reparent`, `unqueue`, `activity` (`GET /activity`, the busy check a schedule tick asks a live worker),
436
436
  `delivery`, a worker that queues a message for after the turn; `SessionManager.deliver_turn` and
437
437
  the attached TUI refuse a queue to a worker without it, which would steer; `turn_ids`, a worker whose running
438
438
  turn's mark, continue turn and Stop carry the turn's `enqueued_id`, which `chi answer`, `chi send --wait` with no
@@ -655,6 +655,7 @@ A map of the parts the overview above doesn't name (paths under `lib/samagotchi/
655
655
  | Dialects | `llm/dialect.rb` (`LLM::Dialect`) | What an `api: anthropic` provider's `/v1/messages` does differently (`dialect: fireworks`) ([internals/anthropic.md](internals/anthropic.md)). |
656
656
  | Logging | `log.rb` (`Log`), `log_subscriber.rb` (`LogSubscriber`), `log_path.rb` (`LogPath`), `log_line.rb` | The debug log: tagged records, a session's event trail, where the file goes ([configuration.md](configuration.md#debug-log-file)). |
657
657
  | Stores | `reminder_store.rb` (`ReminderStore`), `recap_store.rb` (`RecapStore`) | A session's reminders, and its idle recap (`recap.json`). |
658
+ | Schedules | `schedules.rb` (`Schedules`, its `Entry`, `Tick`, `Index`), `schedule_command.rb` (`ScheduleCommand`) | Durable schedules that outlive every worker: `<state dir>/schedules.json` and its `schedules.lock`, the tick's busy check (a live worker asked over the Bridge's `GET /activity`, the files when there is none), the chain prune. `chi schedule` is this machine only. See [Scheduling](scheduling.md). |
658
659
  | Reply waits | `reply_wait.rb` (`ReplyWait`, `GONE_AFTER`: the one dead-worker rule), `reply_wait/poll.rb` (`ReplyWait::Poll`, one look per poll), `reply_wait/target.rb` (`ReplyWait::Target`: which turn a wait binds, cursor, baseline, `owner_grace`; `first_message`, `message`, `wait_only`, `answer`, `participant`, `from_cursor`), `reply_wait/liveness.rb` (`ReplyWait::Liveness`: one owner and link read per look; `#owner?` for gone, `#worker_owned?` for an open question) | `chi send --wait`, `chi answer`, a participant's wait, the delegate tools and delegate reports wait through `ReplyWait.call(id, **target.to_h, …)` ([sub-agent.md](sub-agent.md)). |
659
660
  | Session rows | `session_row.rb` (`SessionRow`) | A session as the lists see it: scalar facts only, built by `SessionManager.session_rows` (one owner, link, recap and analytics read each), `#to_summary_h` the summaries route's body and `.from_summary_h` a remote's rows read back, `#cli_line` the one `chi sessions list` line, `.running_or_starting?` the delegates' and Verify fresh's "running" (a 15 s grace for a worker on its way). A leaf: never session_manager or web. |
660
661
  | Setup and update | `bootstrap_command.rb`, `bootstrap/`, `update_command.rb`, `gem_update.rb` | `chi bootstrap` (first config.yml, system bundle, core profile) and `chi update` (the gem, shipped bundles, the macOS helper). |
data/docs/cli.md CHANGED
@@ -24,6 +24,7 @@
24
24
  - `chi context report [--days N]` — the candidate judge's counts from `context_candidates.jsonl`: candidates proposed, mentioned, attached or declined by you; the judge's asks; what it would attach per threshold, and how that agrees with what you did (this machine only; see [The log and the report](context.md#the-log-and-the-report))
25
25
  - `chi send [-m TEXT] [--image PATH]... [--cut | --queue] (ID|PREFIX)...` — send a message to sessions as if typed there: a turn starts (or a running one takes it at its next step; `--cut` asks it to cut long thinking for it; `--queue` runs it as a turn of its own after the running one, and `--queue --wait` waits for that turn's own answer); piped stdin goes above `-m` as quoted context, and `--image` attaches images (see [Sessions: Sending a message](sessions.md#sending-a-message)); `--new` starts a session with it instead (`--new --continues ID|last:ID` the next link of that session's chain, see [Session chains](sessions.md#session-chains)), and `--wait` prints the answer (`--wait ID` with no message waits for the next reply without sending — to an idle session whose last turn already ended, that ended turn's reply or end comes back; `--wait --turn T ID` with no message waits for exactly turn T, whether running, queued or last ended; `--format json` includes its `turn_id` when known, for waiting again after exit 3 or 4; exit 3 a question waits, its options on stderr; exit 4 `--timeout` passed with the turn still running; see [Starting a session](sessions.md#starting-a-session))
26
26
  - `chi answer ID --question QID (--option N|LABEL)... [--text T] [--timeout S] [--format json]` (or `--dismiss`) — answer the question a session waits on (the one `chi send --wait` exited 3 with), then wait and print what comes next as `chi send --wait` does: the reply (0), the next question (3), still running after `--timeout` (4). `--option` is 1-based or the label, repeated on a multi-select question; `--text` is free text, or a Deny's reason; `--dismiss` leaves it unanswered and the model finishes its reply. A question no longer open (the web answered first) isn't answered again: it waits for that turn's reply. An option the question doesn't offer exits 2; a worker that is gone exits 1 (`send the task again: chi send --wait -m "…" ID`). What a parent may pick follows `guardrails.parent_answers`, or the session's own `parent_answers` when stricter (`soft`, the default: a deny, a plain answer, Continue and "Allow once"; `standard`: no allow; `hard`: no Continue either), never a wider scope or an allow on chi's own config; a refused pick exits 1 with what to answer instead (a convention, not a security boundary; see [Guardrails](guardrails.md#answers-from-a-parent-agent))
27
+ - `chi schedule <add|list|show|tick|run-now|enable|disable|cancel>` — durable schedules on this machine: a prompt with a clock that fires out of no worker (cron, systemd, or chi web's hub with `schedule.auto_tick`), as the next link of a session's chain or as a turn in the session itself (see [Scheduling](scheduling.md) and [Durable schedules](#durable-schedules-chi-schedule)). A usage error exits 2, an unknown session or schedule 1; `chi --remote X schedule list` exits 2
27
28
  - `chi remote add NAME URL [--token-file F | --token-stdin]`, `chi remote ls`, `chi remote rm NAME` — the other machines whose chi web the session commands reach with `--remote NAME` (see [Remote sessions](#remote-sessions))
28
29
  - `chi review FILE… [--dir DIR] [--model M]`, `chi review --from ID [FILE…]` — start a review session to invite participants to: for files of a project, or as a review of a working session that starts from its recap, not its transcript (see [Review sessions](#review-sessions))
29
30
  - `chi share ID --name NAME [--identity EMAIL] [--agent] [--expires 3d] [--cap 2]`, `chi share ls [ID]`, `chi share cap ID P DOLLARS`, `chi share revoke ID [P] | --all` — invite a participant to a session through chi web on the LAN (read-only review turns in a review session, pair turns that act under your guardrails in a working session) (a link of their own, 7 days, a $2 spending cap), list them, change one's cap, revoke them (see [Participants](#participants))
@@ -36,7 +37,7 @@
36
37
  - `chi update [--dry-run] [--to VERSION] [--json] [--no-gem] [--no-bundles] [--no-desktop]` — update an installed chi: the gem, the system bundle, the shipped bundles you installed and the desktop helper, and check the bundles from elsewhere, in one table (see [Updating](#updating))
37
38
  - `chi home`, `chi home set [--ruby PATH]`, `chi home doctor` — the chi home: the one Ruby (and its gem path) every chi launch boots, whoever launches it. `chi home` shows it (its Ruby, the newest chi in it, gem home and path, `mise`, when and by which chi it was set, and the chi you ran when that is another one; exit 1 with the problem when it can't boot chi), `set` records this chi's Ruby and gem path (or `--ruby PATH`'s own; it must have samagotchi), `doctor` checks it and what should follow it, one row each with `ok`, `warn` or `fail` and what to run (exit 1 on a `fail`; see [Checking it](configuration.md#checking-it-chi-home-doctor)). At a terminal, a chi on another Ruby than the home's says so in one dim line a day. This machine only (see [The chi home](configuration.md#the-chi-home))
38
39
  - `chi project`, `chi project init [DIR]` — this folder's project, and making a folder in no git repository one. `chi project` prints how its project was found (`kind` `git`, `marker` or `none`), its `root` (what memories are keyed on), its `top` (where the AGENTS.md chain starts) and its project `memories` folder, with a `note` for a marker a repository makes ignored, or a `$HOME` that is a git repository. `init` writes `DIR/.chi/project.yml` (DIR defaults to this folder and is created when missing), so DIR and every subfolder share one memories folder, group their sessions and take `chi context --project`; it writes nothing in a git repository (the repository already is the project, exit 0), says so for a folder already marked, refuses `$HOME`, a folder above it and `/` (exit 1), and marks a folder inside another marked project as its own, nested one. It prints the root and the memories folder (with the memories already there: a folder chi ran in at its top keeps them), the running sessions in DIR to restart (`chi sessions restart ID`: their prompt keeps the old project) and, when DIR has saved sessions, that a running chi web groups them after its restart. This machine only. See [Projects without git](memory.md#projects-without-git)
39
- - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Plugins](plugins.md) and [Bundle hooks](hooks.md#bundle-hooks)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `upgrade <name>` upgrades an installed bundle by name from the source it came from (its folder, archive or git URL), `upgrade <source>` from a new one; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Bundles](bundles.md#the-btw-bundle), [the mcp bundle](bundles.md#the-mcp-bundle) [the loop-guard bundle](bundles.md#the-loop-guard-bundle), [the check-in bundle](bundles.md#the-check-in-bundle) and [the skills bundle](bundles.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). `install`/`upgrade` `--force` overwrites existing entries and also installs a bundle whose `requires_chi` this chi doesn't meet; without it such a bundle is refused (exit 1), nothing written. A plain `install` of a bundle that is already installed from the same source rewrites its rules, plugin, scripts and record: the summary lists a file that already holds the bundle's bytes as `Up to date: …`, one you wrote as `Skipped: …`, and ends with `NAME was already installed from SOURCE: its rules, plugin and scripts now match it and its record was rewritten` (plus, when a memory was kept, `your own copies of its memories were kept (chi bundle upgrade NAME merges the bundle's changes into them)`). A source with class-file hooks (`hooks/*.rb` or a manifest `hooks:` entry) is refused, `--dry-run` too; an older install's hooks are removed by the next install or upgrade (`Removed (chi no longer loads class-file bundle hooks): …`, an edited one copied to the trash first; see [Class-file hooks are retired](hooks.md#class-file-hooks-are-retired)). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
40
+ - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Plugins](plugins.md) and [Bundle hooks](hooks.md#bundle-hooks)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `upgrade <name>` upgrades an installed bundle by name from the source it came from (its folder, archive or git URL), `upgrade <source>` from a new one; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Bundles](bundles.md#the-btw-bundle), [the mcp bundle](bundles.md#the-mcp-bundle) [the loop-guard bundle](bundles.md#the-loop-guard-bundle), [the check-in bundle](bundles.md#the-check-in-bundle) and [the skills bundle](bundles.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). `install`/`upgrade` `--force` overwrites existing entries and also installs a bundle whose `requires_chi` this chi doesn't meet; without it such a bundle is refused (exit 1), nothing written. A plain `install` of a bundle that is already installed from the same source rewrites its rules, plugin, scripts and record: the summary lists a file that already holds the bundle's bytes as `Up to date: …`, one you wrote as `Skipped: …`, and ends with `NAME was already installed from SOURCE: its rules, plugin and scripts now match it and its record was rewritten` (plus, when a memory was kept, `your own copies of its memories were kept (chi bundle upgrade NAME merges the bundle's changes into them)`). A source with class-file hooks (`hooks/*.rb` or a manifest `hooks:` entry) is refused, `--dry-run` too; an older install's hooks are removed by the next install or upgrade (`Removed (chi no longer loads class-file bundle hooks): …`, an edited one copied to the trash first; see [Class-file hooks are retired](hooks.md#class-file-hooks-are-retired)). `status NAME` lists the bundle's guardrail rules and scripts beside its plugin, each `[ok]`, `[missing]` or `[modified]` (edited: the next install replaces it, its copy kept in the trash; an edited rule doesn't load, so it counts in `issues=`), and `diff NAME` names an edited rule or script with its recorded and current sha256 (chi keeps no base text for them). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
40
41
 
41
42
  - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` — seed System 1's record from the approvals this machine's sessions already answered: each ask is judged by the configured model and written to `guardrails/judgments.jsonl` with the answer the user gave, then a per-rule table prints (asks, allowed, denied, would-be auto-allows, false allows, asks with no judgment). A parent agent's answer is not the user's: only its deny is replayed, and only where the parent could have allowed. `--dry-run` judges without writing, `--threshold` overrides `guardrails.system_one.threshold` for the table, `--model` the configured model for this run. Exit 2 when `guardrails.system_one.mode` is off or no `host` is set; a usage error exits 2 (see [System 1](guardrails.md#system-1-experimental))
42
43
  - `chi guardrails rule <id>` — print exactly `asks` when that rule is loaded, not disabled and votes `ask` in this `guardrails.mode` for every model; any other line says why not (no such rule, disabled, another mode, it denies, for some models only, rules that failed to load, `guardrails.enabled: false`). Exit 0 either way, 2 for a usage error. It reads config.yml and the installed bundles as a worker does (`GuardrailWiring#rules`) and takes the rule's id, never a command; the [coordinator skill](bundles.md#the-coordinator-bundle) reads it before it merges without asking
@@ -436,6 +437,60 @@ bundle). After a gem update, the first interactive start of the new version
436
437
  (`chi`, `chi web`; not `-p` or `--non-interactive`) says in one line when
437
438
  bundles or the helper can be updated.
438
439
 
440
+ ### Durable schedules (`chi schedule`)
441
+
442
+ A schedule is a prompt with a clock, kept in
443
+ `~/.local/state/samagotchi/schedules.json` and fired by a **tick** that needs no
444
+ worker: cron, systemd, `chi schedule tick`, or chi web's hub when
445
+ `schedule.auto_tick: true`. How it works, what "busy" means and the DST caveat:
446
+ [Scheduling](scheduling.md). This machine only — `chi --remote studio schedule
447
+ list` exits 2.
448
+
449
+ ```
450
+ # the daily coordinator, every Monday 09:00 local, each run a fresh link of the chain
451
+ chi schedule add --session 3f9a1c2e --weekday 1 --time 09:00 -m "Audit the open PRs" --title "Monday audit"
452
+
453
+ # every 30 minutes, as a turn in the session itself
454
+ chi schedule add --continues 3f9a1c2e --every 30m --mode same -m "Check the build"
455
+
456
+ chi schedule list # id mode recurrence next due target [skips 3 of 12 (≈1 h at a 5 min tick)]
457
+ chi schedule list --all --format json # the disabled ones and the fired one-offs too
458
+ chi schedule show 4b7e9a # the entry in full: anchor, head, prompt, counters
459
+ chi schedule tick # fire everything due; quiet when nothing is
460
+ chi schedule tick --dry-run # print what would fire, change nothing
461
+ chi schedule run-now 4b7e9a # fire it now, whatever its next_due says
462
+ chi schedule disable 4b7e9a # stop it, keep it in the file
463
+ chi schedule enable 4b7e9a
464
+ chi schedule cancel 4b7e9a # delete it (by id, a unique prefix, or its title)
465
+ ```
466
+
467
+ `add` needs a target (`--session ID`, or `--continues ID` for the same session)
468
+ and exactly one recurrence kind — `--every 45m|2h|1d`, `--weekday 1..7` with
469
+ `--time HH:MM` (local wall clock), or `--date 2026-11-20T09:00` — plus `-m|--message|--prompt TEXT`.
470
+ `--title` names it, `--mode chain|same` picks the mode (chain by default),
471
+ `--catch-up once|none` what an overdue entry does, `--keep-links N` how many chain
472
+ links stay behind the head (`0` keeps all), and `--max-failures` / `--max-skips`
473
+ how many consecutive failures or busy targets disable it. A blank or over-16-KiB
474
+ prompt, two recurrence kinds, a `scratch` target and a `--weekday` without
475
+ `--time` are all refused with exit 2.
476
+
477
+ `tick` prints one line per entry it touched (`fired`, `skipped`, `missed`,
478
+ `failed`, or `would fire` under `--dry-run`), with the reason; nothing due prints
479
+ `no schedules due`, and a tick that finds the lock held by another prints `no
480
+ schedules due (another tick holds the lock)` — both exit 0, so a crontab line
481
+ that runs every five minutes is quiet. `run-now`, `enable`, `disable` and `cancel`
482
+ take the id, a unique prefix of it, or the title; a missing one exits 1, an ambiguous one 2 naming the matches.
483
+
484
+ Point cron at it (see [Scheduling](scheduling.md#running-the-tick) for systemd
485
+ and launchd), or turn on `schedule.auto_tick` and let chi web's hub tick every
486
+ `schedule.tick_interval_minutes` minutes; the lock covers both, so they never
487
+ fire twice.
488
+
489
+ The model can register its own schedules with `schedule_task` (defaulting to the
490
+ session it is in), list them with `list_schedules` and stop them with
491
+ `cancel_schedule`; like `register_reminder`, these are owner-voiced tools, refused
492
+ in a participant's review or pair turn.
493
+
439
494
  ## Flags
440
495
 
441
496
  Samagotchi exposes one flag that feeds a prompt (`-p`, `--prompt`) and one that
@@ -174,6 +174,7 @@ or `chi web` restart.
174
174
  | `recap.*` | Worker start | The engine builds its recap job at start, and again when it binds a session with its own `recap.enabled` or `recap.sentences` (`--set`, see "Per-session settings") and when `/recap on\|off\|default` changes the session's own value. |
175
175
  | `session.idle_exit_minutes`, `session.project_env`, `session.carry_last_answer`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.task_wakes`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer and project env; the other values are read for the next corresponding decision. |
176
176
  | `session.retention_*`, `session.max_count`, `session.keep_status`, `session.sweep_interval_hours` | Next retention sweep | `chi web` sweeps on its configured schedule and on `GET /api/sessions`; `chi sessions prune` uses the values for that invocation. The sweep interval is checked on the next hub tick. |
177
+ | `schedule.*` | Next CLI run | `chi schedule add` and `chi schedule tick` read them for that run; with `schedule.auto_tick` on, `chi web`'s hub ticks on `schedule.tick_interval_minutes`. |
177
178
  | `web.*` except `web.participants.*` | `chi web` start | Restart `chi web` to change its bind address, port or page settings. |
178
179
  | `log.*` | Process start | Each process holds its configured log sink and level after first use; restart that process. |
179
180
  | `history.file` | Next history access | The TUI and web composer resolve its path when they read or append history. |
@@ -2270,6 +2271,14 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2270
2271
  | `session.max_count` | `500` | yes | Keep the newest N; `0` = uncapped. |
2271
2272
  | `session.keep_status` | none | yes | Comma list of statuses never pruned (a session a worker or `chi` has open is never pruned anyway). |
2272
2273
  | `session.sweep_interval_hours` | `24` | yes | How often the retention sweep runs. |
2274
+ | `schedule.auto_tick` | `false` | | `chi web`'s hub runs `chi schedule tick` too, sharing the schedule lock with cron. Off by default: cron or systemd is the documented driver. See [Scheduling](scheduling.md). |
2275
+ | `schedule.tick_interval_minutes` | `5` | | How often the hub ticks when `schedule.auto_tick` is on, and how late a `catch_up: none` schedule may be before it moves to its next occurrence. |
2276
+ | `schedule.default_keep_links` | `5` | | Chain links a schedule keeps behind its current one (`0` keeps all). Archived links are outside the retention sweep, so this is what bounds them. See [Scheduling](scheduling.md). |
2277
+ | `schedule.default_max_failures` | `3` | | Consecutive failures that disable a schedule. |
2278
+ | `schedule.default_max_skips` | `12` | | Consecutive busy targets that disable a schedule; `list` prints it as a time window. |
2279
+ | `schedule.default_catch_up` | `once` | | An overdue schedule fires once at the next tick (`once`) or moves to its next occurrence (`none`). |
2280
+ | `schedule.max_per_session` | `10` | | Enabled schedules one chain may hold. `schedule_task` (and `chi schedule add`) refuse past it, so a routine that re-schedules itself cannot pile up entries. |
2281
+ | `schedule.max_total` | `50` | | Enabled schedules this machine may hold, across every chain. |
2273
2282
  | `image.max_side` | `1568` | | See "Images". |
2274
2283
  | `image.max_bytes` | `3750000` | | See "Images". |
2275
2284
  | `image.max_per_request` | `20` | | See "Images". |
data/docs/context.md CHANGED
@@ -250,7 +250,9 @@ you're away from the terminal. All of these must hold:
250
250
  project source's first text may predate the session, so neither wakes;
251
251
  - `context.wake` is on (default `true`);
252
252
  - the session's worker is up and idle: no turn running, nothing queued, no
253
- question or continue offer waiting, not just started;
253
+ question or continue offer waiting, not just started. A changed push from a
254
+ session-scoped source can start an idle-exited worker; it never restarts an
255
+ explicitly stopped session;
254
256
  - under `session.max_wakes` turns in a row with no message from you (the
255
257
  budget delegate reports and finished background tasks use too; a turn that
256
258
  failed, or was stopped before the model call, pauses wakes until you write);
@@ -343,6 +345,10 @@ the chain's, names what moved and what stayed:
343
345
 
344
346
  > Context moved with this session: pr-412, ci. Left with 3f2a9c1e: notes (pushed).
345
347
 
348
+ On a continue, text from a pushed source that the old session had not absorbed
349
+ or read is also carried in a separate note, clearly framed as third-party
350
+ text. The source itself stays with its old link; forks do not carry this text.
351
+
346
352
  `chi send --new --continues` and the web's Continue toast say the same in a
347
353
  few words, and `chi context ls` marks a session's source that stays
348
354
  (`· stays`; `--format json` has the derived `movable`). Project sources are
data/docs/guardrails.md CHANGED
@@ -527,7 +527,9 @@ message:
527
527
  `batch`;
528
528
  - owner-voiced tools, which act in the owner's name beyond the turn:
529
529
  `delegate` and any `delegate_*` tool, `register_reminder`,
530
- `cancel_reminder`, `send_note`, `forget_outputs`, `task_create`,
530
+ `cancel_reminder`, `schedule_task`, `list_schedules`, `cancel_schedule`
531
+ (a durable schedule outlives the session and fires on this machine),
532
+ `send_note`, `forget_outputs`, `task_create`,
531
533
  `task_stop`, `find_tools` (it loads tools into the session), `objectives`
532
534
  (a move on the owner's list), and
533
535
  `memory_write` (a memory reaches the owner's later sessions); and the tools
data/docs/hooks.md CHANGED
@@ -623,6 +623,9 @@ stripped of surrounding whitespace, except file text, an edit's
623
623
  | `register_reminder` | `name` | — | `description`, `interval_minutes` |
624
624
  | `cancel_reminder` | `name` | — | — |
625
625
  | `list_reminders` | — | — | — |
626
+ | `schedule_task` | `prompt` | — | `title`, `every`, `weekday`, `time`, `date`, `mode`, `keep_links` |
627
+ | `list_schedules` | — | — | `include_disabled`, `all` |
628
+ | `cancel_schedule` | `name` | — | `all` |
626
629
  | `list_sessions` | — | — | `cwd` |
627
630
  | `send_note` | `text` | — | `session` |
628
631
  | `context_read` | `name` | — | `offset`, `limit` |
@@ -365,6 +365,7 @@ The Bridge exposes:
365
365
  the kind already taken is answered `200` as that one.
366
366
  - `GET /session/:id/state` — `session_state_snapshot` (JSON).
367
367
  - `GET /session/:id/stats` — `Engine#stats_snapshot` for attached `/stats`: the metrics, with the context window and prompt profile asked from the server before the first turn.
368
+ - `GET /session/:id/activity` — `Worker#activity_snapshot`: `{session_id, turn_running, input_pending, awaiting_continue, open_streams, reminders, tasks_holding}` — what a `chi schedule tick` asks a live worker before it fires (the tick runs out of process and cannot read a worker's memory). Read-only and small (under 300 bytes). `501 activity_unavailable` when this Bridge was built without an `on_activity:` step; the worker advertises it in `Bridge::FEATURES` (`activity`). See [scheduling.md](../scheduling.md).
368
369
  - `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
369
370
  the messages itself, then streams from its `event_seq`). Read by the web's full show
370
371
  (`GET /api/sessions/:id`, `?parts=1`) and the plugin's `list_sessions`; every message is copied.
@@ -45,9 +45,10 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
45
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) |
46
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` |
47
47
  | `Installer` | one install/upgrade/dry run of one bundle (below) |
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` |
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). Each folder is one loop, `#install_set(kind_of_file, incoming)`: every file's `AssetState` (`AssetState.in`: the files there at any depth, the incoming and the recorded ones), its row in `FileActions`' `ASSET_INSTALL` or (an upgrade over a record) `ASSET_UPGRADE`, one `Installer::FileResult` per incoming file with the state and action (a dry run's word through `AssetInstaller::DRY_WORDS`, nothing written), a `save_first` file copied to the Installer's `Trash` first (`Trash#copy`, as `guardrails/x`, `plugin/x`, `scripts/sub/x`), then the folder replaced; the named-but-missing and symlink `InstallError`s come before it, the checksum warning after the write (a plugin or script, not a dev link). An older chi's `hooks/` keep `Trash#copy_edits` |
49
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 |
50
+ | `AssetState` (asset_state.rb) | one rule, plugin or script of a bundle's own folder as a command meets it, derived, never stored: `AssetState.of(path, kind_of_file:, recorded:, incoming:)` → `state` (`:new`, `:unchanged` (the source's bytes, a dev link to them too), `:pristine`, `:edited`, `:linked` (a link to other bytes), `:dropped_pristine`, `:dropped_edited`, `:missing`; with no source, as uninstall and status read it, a file there is a `dropped_*` or `:linked`), `#edited?` (the asset rule: `Trash.edit?`, its bytes neither the recorded sha, none recorded counting as edited, nor the incoming file's; a link never), `#label` (`guardrails/x`, as the trash and the plan name it). `AssetState.in(dir, …)` is a folder's set. Read by `AssetInstaller`, `ShippedUpdate#trash_bound`, `Uninstaller#save_edits`, `Status.asset_states` and `chi bundle diff`; `asset_state_spec` checks it against the corpus's asset cases |
51
+ | `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). Assets have their own tables, `ASSET_INSTALL`, `ASSET_UPGRADE` (an upgrade lists a replaced rule as installed, the plugin and a script as updated: the one cell keyed by kind, `[state, kind_of_file]`) and `ASSET_UNINSTALL`, read with `.for_asset(command, asset)`; their `do` adds `:remove` (deleted, `save_first` copying an edit). 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
52
  | `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) |
52
53
  | `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 |
53
54
  | `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 |
@@ -60,9 +61,9 @@ user-facing side (what a bundle owns, profiles, needs) is in [memory.md](../memo
60
61
  | `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)) |
61
62
  | `Builder` | `chi bundle build`: memories (+ the installed bundle's rules, plugin, scripts; never an older record's hooks) → zip/tar/dir |
62
63
  | `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 |
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
+ | `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`: the edited `AssetState`s against the shipped source over the rules, plugin and scripts folders, and `Trash.edits` over 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.edited_executables` (`chi bundle check`'s "does chi load it?") stays sha-only, as `Status.plugin_status` does. `ShippedUpdate.external` is the rest: what `chi update` only checks |
64
65
  | `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`) |
66
+ | `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`; per rule and script `Status.asset_states`: ok, `modified` = `AssetState#edited?`, missing; `issues=` counts behind, missing and unreadable files, a missing or edited rule (it doesn't load) and a missing script, not an edited memory or script); `chi bundle list` (`Installed#behind`) |
66
67
  | `IndexUpdater`, `IndexSync` | the managed `index.md` lines (Installer; and the write/edit tools for memories) |
67
68
  | `EntrySize` | one memory's size in tokens: `memory.entry_warn_tokens`' crossing note after a memory write, `chi self`'s `big memories` row |
68
69
  | `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`) |
@@ -113,7 +114,7 @@ hooks is refused before its manifest is read (`Installer#refuse_hooks`, then `Ma
113
114
  run too; `ShippedUpdate`'s plan skips one the same way. Over such a record, `AssetInstaller#retire_hooks` (after the
114
115
  plugin and scripts, so a refused install leaves them) copies every edited or unknown file under `hooks/` to the trash
115
116
  (`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
+ run's `:would_remove`) with the `AssetInstaller::RETIRED` note, which `#summary` lists on a line of
117
118
  its own; `ShippedUpdate#trash_bound` names the same files (`Trash.edits` over `hooks/`). `Provenance#write` records
118
119
  `"hooks" => {}` and deletes the old hook bases. An older chi reading such a record sees a bundle with no hooks.
119
120
 
@@ -148,8 +149,9 @@ The order matters: warnings print in it, and a plugin/script the manifest names
148
149
  differs copied to the trash first (`Trash#copy`, said by `#trash_copy_line`; a profile's in `InstallResult#trash_copies`). A
149
150
  same-name file the bundle didn't write is the user's: skipped and never recorded (`@owned`). Model overlays
150
151
  (`<name>.<key>.md`) get no index line (`note_overlay`).
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
152
+ 4. `AssetInstaller#run`: guardrails, plugin, scripts (`#install_set` each: its `AssetState`s and the `ASSET_*` table),
153
+ then `#retire_hooks` (an older record's `hooks/`); each set replaces the previous version's, an edited file copied to
154
+ the trash first. requires_chi warnings for the plugin and
153
155
  rules. A file that is a symlink or resolves outside the bundle dir (`AssetInstaller#linked?`) is skipped with a
154
156
  warning (a rule) or raises `InstallError` (the plugin, a script).
155
157
  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.
data/docs/memory.md CHANGED
@@ -591,7 +591,11 @@ memory still marked as behind an earlier upgrade keeps its mark either way.
591
591
  rule above: edited, or with nothing recorded to tell), `missing` or
592
592
  `unreadable`, and that a file stays behind and why. Its `issues=` counts files
593
593
  behind, missing or unreadable (and a plugin that won't load, or class-file hooks an older chi installed, which no
594
- longer load: `Hooks (N): not loaded`), not edits. A
594
+ longer load: `Hooks (N): not loaded`), not edits. `chi bundle status NAME`
595
+ lists the bundle's guardrail rules and scripts too (`Rules (N):`, `Scripts (N):`),
596
+ each `[ok]`, `[missing]` or `[modified]`: edited, so the next install or upgrade
597
+ replaces it, its copy kept in the trash. An edited or missing rule doesn't load
598
+ and counts in `issues=`, as a missing script does. A
595
599
  file an older chi marked, which no retry has looked at, says `may be behind`, and so
596
600
  do the counts in `chi bundle list`, `chi self` and `chi update`'s last line.
597
601
  `chi bundle status NAME` also has a `Manifest:` line: `[kept]` when chi reads