samagotchi 0.148.0 → 0.149.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/bundles.md +38 -1
  5. data/docs/cli.md +17 -0
  6. data/docs/configuration.md +4 -2
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +2 -1
  9. data/docs/internals/objectives.md +264 -0
  10. data/docs/internals/web-frontend.md +4 -1
  11. data/docs/plugins.md +4 -2
  12. data/docs/releasing.md +1 -1
  13. data/docs/sessions.md +42 -0
  14. data/docs/system-one.md +17 -1
  15. data/lib/samagotchi/bundles/coordinator/manifest.yml +6 -4
  16. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  17. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +9 -0
  18. data/lib/samagotchi/bundles/decision-card/manifest.yml +13 -0
  19. data/lib/samagotchi/bundles/decision-card/plugin.rb +280 -0
  20. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/config.rb +7 -1
  23. data/lib/samagotchi/context_note.rb +17 -5
  24. data/lib/samagotchi/engine.rb +113 -6
  25. data/lib/samagotchi/engine_peers.rb +11 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +3 -2
  27. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  28. data/lib/samagotchi/note_command.rb +1 -1
  29. data/lib/samagotchi/note_delivery.rb +6 -2
  30. data/lib/samagotchi/objectives.rb +395 -0
  31. data/lib/samagotchi/objectives_audit.rb +115 -0
  32. data/lib/samagotchi/objectives_check.rb +335 -0
  33. data/lib/samagotchi/objectives_command.rb +192 -0
  34. data/lib/samagotchi/plugin/api.rb +2 -1
  35. data/lib/samagotchi/plugin/context.rb +21 -1
  36. data/lib/samagotchi/session_commands.rb +15 -0
  37. data/lib/samagotchi/session_continue.rb +3 -0
  38. data/lib/samagotchi/session_inbox.rb +14 -6
  39. data/lib/samagotchi/session_manager.rb +1 -1
  40. data/lib/samagotchi/steer.rb +24 -4
  41. data/lib/samagotchi/testing/boundary.rb +1 -0
  42. data/lib/samagotchi/testing/host.rb +1 -0
  43. data/lib/samagotchi/tool_activity.rb +17 -0
  44. data/lib/samagotchi/tool_declarations.rb +34 -0
  45. data/lib/samagotchi/tools/builtin_calls.rb +2 -1
  46. data/lib/samagotchi/tools/builtins.rb +4 -1
  47. data/lib/samagotchi/tools/objectives_tool.rb +75 -0
  48. data/lib/samagotchi/tools/peers.rb +3 -0
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/message_display.rb +9 -0
  51. data/lib/samagotchi/web/participant_view.rb +1 -1
  52. data/lib/samagotchi/web/public/app.js +10 -0
  53. data/lib/samagotchi/web/public/cards_place.js +20 -4
  54. data/lib/samagotchi/web/public/commands.js +4 -1
  55. data/lib/samagotchi/web/public/index.html +2 -0
  56. data/lib/samagotchi/web/public/objectives.css +80 -0
  57. data/lib/samagotchi/web/public/objectives_entry.js +22 -0
  58. data/lib/samagotchi/web/public/objectives_model.js +100 -0
  59. data/lib/samagotchi/web/public/objectives_moves.js +145 -0
  60. data/lib/samagotchi/web/public/objectives_strip.js +242 -0
  61. data/lib/samagotchi/web/public/objectives_wiring.js +62 -0
  62. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  63. data/lib/samagotchi/web/public/turn_html.js +4 -1
  64. data/lib/samagotchi/web/session_show.rb +12 -1
  65. data/lib/samagotchi/worker.rb +3 -0
  66. data/lib/samagotchi/worker_inbound.rb +16 -8
  67. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcbb626a7c357711e1403ac1c9825d0930eb5eb7d4e9f36e0cf004f96b0a4de7
4
- data.tar.gz: d5a85b7198fa381dd4a352389386c3d4cf5a96d89a06834e13d9518c5412eb54
3
+ metadata.gz: c93a3995e0d6192e52b21e25127bedc26e2442b9b45002ed6d3b758fc214c837
4
+ data.tar.gz: 303ef3878f850b90a9f506fb8a09c710a25e18aca6836e8aa59ee27498258146
5
5
  SHA512:
6
- metadata.gz: b9e73f35692277dab5f9cb18435db900d35b941b64af1c878e7f3503ece14e5f46a35f120a457a53fc8d9d3fcb81c302af370b93e7920ac71d5aec2b723cd250
7
- data.tar.gz: 1bb3118536d48f1b9741d811cf9c2df9a85766dfc15e1fb758ac4caa99b95123774ef51c32afe01dc66d208562eef6ec2d7a8c236ea14264d56700f45952de8d
6
+ metadata.gz: da19432429657d32a29891f5ac57d2a987d9f19fb7b8c027f7b8038660e51b76bc4a066331e2ad3a49fe62747b7f8dd85b5cd8f9b59d47282d1ca9b4ab7aaa74
7
+ data.tar.gz: 33d8c08e66bf4eac732fabb7425a1ca00f3fabb8570418d5887c46db9ce0980466969437590908238861d67a267d139c9916e668bc546babf869cae3cc01f162
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and commands may change between minor versions. How releases are made:
7
7
  [docs/releasing.md](docs/releasing.md).
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.149.0] - 2026-11-04
11
+
12
+ ### Added
13
+
14
+ - `/objectives` gives a session a short list of what it is to achieve (`set A | B`, `add`, `done`, `drop`), kept in the conversation as a note; a continue carries it, and a session with no list gets no note and no prompt line. A first version to try. [docs/sessions.md](docs/sessions.md#objectives)
15
+ - The `objectives` tool lets the model record a move on an objective (a line, optional evidence): information only, it never changes the list, and `/objectives` shows each objective's latest. Declared on `full` and the coordinator toolkit, so a `full` request carries its schema with or without a list; other kits name it in the not-loaded line. The coordinator bundle's skill says a move or a `done` is never approval to merge. [docs/sessions.md](docs/sessions.md#objectives)
16
+ - The `decision-card` bundle (in the `dev` profile): in a session with objectives, a long answer ending in a question gets a card with a short summary and the question, which you answer in the composer. One side request per such answer; a session with no list makes none, and nothing enters the conversation. `chi bundle uninstall decision-card` takes it away. Plugins get `ctx.objectives`. [docs/bundles.md](docs/bundles.md#the-decision-card-bundle)
17
+ - The web shows a session's objectives in the conversation: a strip above it with the current list, each list note drawn as pills or a line of what you changed, and the model's moves as chips above an answer. A tap edits the list with no message or turn (`/objectives edit ID TEXT` is new, and the terminals keep the command), Not yet tells the agent in a message, and on a phone it is one line and a bottom sheet. The `objectives` tool gains `propose`: the model suggests a change and you apply it from a card; it never changes the list by itself. `decision-card`'s `/short` gives a long answer's short version on demand. A session with no list shows nothing new. The tool's schema changed, so the prompt cache misses once. [docs/sessions.md](docs/sessions.md#objectives)
18
+ - Check this (`/objectives check O2`, or the button in the web's list): on request, one side request reads an objective's latest move against what the session's tools returned and people said, and reports a verdict with reasons in a card. Information only: no status changes and nothing enters the conversation. `objectives.check_model` picks a cheaper model, whose host then gets up to 24,000 characters of tool output, known secret shapes redacted.
19
+ - `objectives.system_one: audit` (off by default) saves a System 1 yes/no per active objective on incoming messages and finished answers, for `chi judgments`; nothing acts on it. Its text goes to the System 1 host.
20
+
21
+ Update with `chi update` (coordinator 0.8.0, decision-card 0.2.0, dev profile 0.7.5). `chi update` installs decision-card for anyone on the dev profile; it does nothing in a session with no objectives.
22
+
10
23
  ## [0.148.0] - 2026-11-04
11
24
 
12
25
  ### Added
@@ -5611,7 +5624,8 @@ and long-lived sessions.
5611
5624
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5612
5625
  hotkey panel that send selected text or the clipboard to your sessions.
5613
5626
 
5614
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.148.0...HEAD
5627
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.149.0...HEAD
5628
+ [0.149.0]: https://github.com/dm1try/samagotchi/compare/v0.148.0...v0.149.0
5615
5629
  [0.148.0]: https://github.com/dm1try/samagotchi/compare/v0.147.0...v0.148.0
5616
5630
  [0.147.0]: https://github.com/dm1try/samagotchi/compare/v0.146.0...v0.147.0
5617
5631
  [0.146.0]: https://github.com/dm1try/samagotchi/compare/v0.145.0...v0.146.0
data/docs/architecture.md CHANGED
@@ -101,7 +101,7 @@ terminal renders the events it gets over the Bridge.
101
101
  | UI | `Samagotchi::TerminalUI` | REPL (Reline), rendering, REPL commands. Delegates all core work to an `Engine`. |
102
102
  | Model loops | `KernelLoop` (via `LLM::NativeBackend`), `LLM::ChatLoop` | The model↔tool loop: raw prompt, or a chat API (OpenAI chat, Anthropic Messages), chosen per host (see below). |
103
103
  | 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. |
104
- | 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, forget_outputs, find_tools/call_tool (the tool index), plus runtime/output-guardrails. |
104
+ | 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. |
105
105
  | Bundles | `lib/samagotchi/memory_bundle/*`, `BundleCommand` | `chi bundle`: install/upgrade (3-way merge), build, uninstall; the install record under `.bundles/<name>/` that the hook, guardrail and plugin loaders read ([internals/bundles.md](internals/bundles.md)). |
106
106
  | Background | `Samagotchi::Worker`, `Samagotchi::SessionManager` | `SessionManager` forks/spawns one worker process per session; 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. |
107
107
  | 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`. |
data/docs/bundles.md CHANGED
@@ -858,7 +858,7 @@ by themselves there.
858
858
  session: the coordinator session picks the change up from the handoff,
859
859
  and merges as it always does. Experimental: it may go if it doesn't earn its place.
860
860
  - **The coordinator toolkit.** The bundle declares `coordinator`: `lean`
861
- plus `delegate`, `delegate_result` and `list_sessions`
861
+ plus `delegate`, `delegate_result`, `list_sessions` and `objectives` (the skill records moves with it)
862
862
  ([Toolkits](configuration.md#toolkits)). `/coordinate` puts a fresh session
863
863
  on it before it asks the model (`ctx.use_toolkit`). When chi refuses (a reply
864
864
  or a turn already there) and the session's toolkit has no `delegate`, the
@@ -1240,3 +1240,40 @@ for one model family, loaded into the prompt only for the models a note's
1240
1240
  through upgrades; other notes stack with the bundle's
1241
1241
  ([Model notes](memory.md#model-notes)).
1242
1242
 
1243
+ ## The decision-card bundle
1244
+
1245
+ `chi bundle install decision-card` (or the `dev` profile) installs the bundle shipped with chi. It is a try-out, written
1246
+ only against this API (`lib/samagotchi/bundles/decision-card/plugin.rb`). It does nothing in a session with no
1247
+ [objectives](sessions.md#objectives): no request, no card.
1248
+
1249
+ In a session with an active objective, an answer that is long and ends in a question gets a card when the turn is over:
1250
+ a short summary, the question, and the objective it concerns when the model names one. You answer in the composer.
1251
+ The card has no buttons, so it invents no options, and it is no "needs you": no push, no waiting mark.
1252
+
1253
+ - **The gate**: a completed owner's turn (not a stopped one, a participant's or a `--non-interactive` one), at least
1254
+ `min_chars` characters, a question mark that ends a word in the last `tail_chars` of the prose (code is left out). All of
1255
+ it is read before any request.
1256
+ - **The request**: one side answer (`ctx.ask_model`) about the turn's prompt and answer, with the list in its prompt. It
1257
+ counts in `/stats`' side requests under `decision-card`. If the model says the answer asks nothing, or leaves out the
1258
+ summary or the question, there is no card.
1259
+ - **It may be late.** It runs on a thread, so the turn's end doesn't wait, and a one-slot local server answers it after
1260
+ the running request. A new turn cancels it, which frees that slot, and a card still to come is dropped.
1261
+ - **A shown card folds** to a line (`Asked: …`) when your next turn starts (a reminder's, a child's report's or a participant's turn leaves it open): in the web a row in the same place; the terminals
1262
+ print it as an update.
1263
+ - **A failed request** is only logged (`no_card` in the debug log): no card, no notice, no push.
1264
+ - **`/short`**: the last answer's short version on demand, in a card, by the same side request. The web's objectives offer it
1265
+ under the newest long answer of a session with a list (Short version), between turns. Typed, it works in any session; during
1266
+ a turn it refuses, since that turn's answer isn't there yet.
1267
+ - **Nothing else changes**: nothing enters the conversation, so the next request is byte for byte what it would be
1268
+ without the bundle. The card lives in the session's `cards.json`, as any card. `chi bundle uninstall decision-card` takes
1269
+ it all away (a member of a profile you uninstall stays out); a session you have open needs `chi sessions restart ID`.
1270
+
1271
+ ```yaml
1272
+ bundles:
1273
+ decision-card:
1274
+ min_chars: 1200 # how long an answer is long
1275
+ tail_chars: 300 # how far from the end the question may be
1276
+ model: null # a cheap configured model ("host:id" or an alias); null is the session's own
1277
+ timeout: 120 # seconds the side request may take (a number)
1278
+ max_tokens: 300 # the side answer's limit
1279
+ ```
data/docs/cli.md CHANGED
@@ -867,6 +867,23 @@ mid-turn too, and the lists, cards, top bar and tab show it in place of the firs
867
867
  message; a continue's next link keeps it. In the web, `✎` next to the session's
868
868
  title (its strip card, or the top bar) does it. See [Session titles](sessions.md#session-titles).
869
869
 
870
+ <a id="session-objectives"></a>
871
+ `/objectives` shows what the session is to achieve, each objective's latest move under it (`last move: …`, recorded by
872
+ the model's `objectives` tool); [Objectives](sessions.md#objectives) has what that does.
873
+ - `/objectives set Two coffees | Stops before checkout (done when: no order is placed)`: a new list. Parts are split by
874
+ ` | ` or by lines; a part may end `(done when: …)`. It replaces the list, and ids go on from the old one's.
875
+ - `/objectives add TEXT`: one more (several with ` | `).
876
+ - `/objectives edit O1 TEXT`: a new text, the rest of the line whole (it may end `(done when: …)`); id and status stay.
877
+ - `/objectives done O1 [O2…]`, `/objectives drop O1 [O2…]`: mark items. An id it doesn't have refuses the whole line, and
878
+ so does `done` on a dropped item. The model is shown a done item as `O1 [done] …` and no dropped one.
879
+ - `/objectives undo O1 [O2…]`: back to active, after a mistaken `done` or `drop`.
880
+
881
+ - `/objectives check O1`: an independent read of O1's latest move, one side request; the verdict comes as a card and as
882
+ `check: …` under the move. Information only: it edits nothing, and it is refused for an objective with no move or a dropped one, and during a participant's turn. See [Objectives](sessions.md#objectives).
883
+
884
+ All of them run mid-turn too. An edit answers with the new list at once and goes into the conversation between turns.
885
+ Refused in a chi REPL's session (`--no-shared`), as `chi note` is; `/objectives` alone still answers there.
886
+
870
887
  A `/model`, `/llm-context`, `/thinking`, `/reasoning-history` or `/toolkit` change that restarts the prompt cache
871
888
  asks first: the reply says what it costs, changes nothing, and names the line
872
889
  that applies it (`/thinking low --yes`, `/model qwen --default --yes`). `--yes` (or `-y`) applies it at once.
@@ -166,7 +166,7 @@ or `chi web` restart.
166
166
  | `toolkit.kits`, `execute.description`, `llm_context.policy` | Worker start | These are held by the engine's toolkit or tool registry. |
167
167
  | `toolkit.suggest.mode`, `toolkit.suggest.threshold`, `toolkit.suggest.defer_turns`, `toolkit.suggest.defer_tokens` | Worker start | Suggestion settings are config-only and are resolved when the worker runs its audit or on-mode ask. |
168
168
  | `skip_agents_md` | Prompt rebuild | A running engine caches its built prompt; a new worker or `/model` rebuild uses the changed value. |
169
- | `context.*` except `context.every_seconds`, `llm_context.*` except `llm_context.policy`, `steer.cut_after`, `memory.index_warn_tokens`, `memory.entry_warn_tokens`, `status.line`, `web.participants.*` | Next matching turn, display, poll or write | Readers resolve these settings when they perform that operation. Session-specific LLM context and thinking values remain saved in the session. A participant's grant keeps the expiry and cap it was issued with; `web.participants.*` is what the next invite gets, and the cap and queue of a grant from before caps. |
169
+ | `context.*` except `context.every_seconds`, `objectives.*`, `llm_context.*` except `llm_context.policy`, `steer.cut_after`, `memory.index_warn_tokens`, `memory.entry_warn_tokens`, `status.line`, `web.participants.*` | Next matching turn, display, poll or write | Readers resolve these settings when they perform that operation. Session-specific LLM context and thinking values remain saved in the session. A participant's grant keeps the expiry and cap it was issued with; `web.participants.*` is what the next invite gets, and the cap and queue of a grant from before caps. |
170
170
  | `context.every_seconds` | Next context poll | The poller checks it for each source that has no explicit interval. |
171
171
  | `guardrails.*` | Next tool call or approval | Guardrail rules and disable lists are reloaded when the config file changes; rule settings take effect on that evaluation. `guardrails.parent_answers` is checked when a parent answers a question. |
172
172
  | `system_one.host`, `system_one.model`, `system_one.timeout_ms`, `system_one.runs`, `system_one.judgments` | Next tool call or approval | Each ask reads them. |
@@ -1504,7 +1504,7 @@ toolkit:
1504
1504
  | `full` | every built-in; the default |
1505
1505
  | `lean` | `execute`, `read`, `write`, `edit`, `memory_read`, `memory_write`, `task_create`, `task_wait`, `task_get`, `task_list`, `task_stop`, `ask_user_question`, `web_fetch`, `context_read` |
1506
1506
  | `child` | `lean` plus `send_note`, without `memory_write`, `delegate`, `delegate_result` |
1507
- | `coordinator` | `lean` plus `delegate`, `delegate_result`, `list_sessions` (declared by the coordinator bundle) |
1507
+ | `coordinator` | `lean` plus `delegate`, `delegate_result`, `list_sessions`, `objectives` (declared by the coordinator bundle) |
1508
1508
  | `coding` | `child`, with the prompt text `- Be concise in your responses` / `- Show file paths clearly when working with files` (declared by the coordinator bundle, for build and fix children) |
1509
1509
  | `review` | `read`, `memory_read` (not `forget_outputs`, even under the `forget` layer): what a [participant's](internals/participants.md#turns) turn may call, whatever the session declares (the review boundary, [guardrails](guardrails.md#a-participants-review-boundary)). Reserved: `toolkit.kits.review` is refused ("review is reserved for participants' turns"), and a bundle can't declare it. A session may run on it too, as any kit |
1510
1510
 
@@ -2273,6 +2273,8 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2273
2273
  | `context.candidates.threshold` | `0.8` | | The probability at which the judge attaches a candidate in on mode (a judged line's `would: attach`). config.yml only. |
2274
2274
  | `context.candidates.offer_threshold` | `0.6` | | The probability from which on mode shows the candidate's card, once (`would: offer`, below `threshold`). config.yml only. |
2275
2275
  | `context.candidates.reask_after_turns` | `3` | | Owner prompts after a judged ask before the judge's one re-ask of that candidate. config.yml only. |
2276
+ | `objectives.check_model` | none | | The model [Check this](sessions.md#objectives) asks, its one side request (a host:id, an alias, a host's model id); none: the session's own. **Each Check this sends that model's host up to 24,000 characters of the session's tool output and messages, off the machine with a hosted model even from a local session;** known key shapes and `token=`-style values are redacted first, by pattern only. config.yml only. |
2277
+ | `objectives.system_one` | `off` | | `audit`: System 1 answers a yes/no per active objective on what comes in and goes out, saved as runs and acted on by nothing; see [System 1's saved runs](system-one.md#objectives-in-audit). `off` (the default): no conversation text goes to a System 1 host for this. Its host and model are `system_one.host` / `.model`. config.yml only. |
2276
2278
  | `toolkit.default` | `full` | | The toolkit a new session is stored on: the tools its prompts declare (`full`, `lean`, `child`, one of `toolkit.kits` or a bundle's). `chi --toolkit` picks one per session. See "Toolkits". |
2277
2279
  | `toolkit.child` | `full` | | A delegate child's toolkit, unless its `delegate` call names one (`toolkit:`). See "Toolkits". |
2278
2280
  | `toolkit.suggest.mode` | `off` | | `audit` asks System 1 for a toolkit suggestion without changing the session; `on` waits for it (up to `system_one.timeout_ms`) on an eligible turn and starts the session on a confidently suggested kit (Toolkits, On mode). The System 1 host, model and timeout come from `system_one.*`. config.yml only. |
data/docs/guardrails.md CHANGED
@@ -529,7 +529,8 @@ message:
529
529
  - owner-voiced tools, which act in the owner's name beyond the turn:
530
530
  `delegate` and any `delegate_*` tool, `register_reminder`,
531
531
  `cancel_reminder`, `send_note`, `forget_outputs`, `task_create`,
532
- `task_stop`, `find_tools` (it loads tools into the session), and
532
+ `task_stop`, `find_tools` (it loads tools into the session), `objectives`
533
+ (a move on the owner's list), and
533
534
  `memory_write` (a memory reaches the owner's later sessions); and the tools
534
535
  that read the owner's background tasks and their output, `task_wait`,
535
536
  `task_get`, `task_list` (a pair turn starts none);
data/docs/hooks.md CHANGED
@@ -56,7 +56,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
56
56
  |-------|--------------|---------------|
57
57
  | `:session_start` | The first turn this chi process runs for the session (so again after `--resume`, a worker that idle-exited and woke, or a restart) | `{ type: :session_start, session_id: "..." }` |
58
58
  | `:before_turn` | Before each turn starts | `{ type: :before_turn, session_id: "...", prompt: "..." (nil on a continue), resumed: true \| false (true on a step-limit Continue, which resumes the run before it; false on every other turn, a reminder's or a wake's too), messages: [...] (the history before this turn) }` |
59
- | `:after_turn` | After a turn completed or was stopped once the model was asked (not after one that failed, was cut short by an interrupt signal (SIGINT), or was stopped before its `:before_turn` hooks) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), refs: (see [Refs on the answer](#refs-on-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model)) }` |
59
+ | `:after_turn` | After a turn completed or was stopped once the model was asked (not after one that failed, was cut short by an interrupt signal (SIGINT), or was stopped before its `:before_turn` hooks) | `{ type: :after_turn, status: "completed" \| "canceled", present: (see [Presenting the answer](#presenting-the-answer-display-only)), refs: (see [Refs on the answer](#refs-on-the-answer-display-only)), messages: [...] (the conversation the turn stored; a cancelled or empty turn ends it with a `kind: turn_note` system message, and a context line is `kind: context`, see [sessions.md](sessions.md#notes-a-turn-leaves-for-the-model); on the native loop the last model message still holds its thought block until the next turn), answer: "..." (the answer's text with no thought block; nil for a stopped turn or one that ran out of steps; needs chi newer than 0.148.0) }` |
60
60
  | `:before_generation` | Before each LLM API call (both loops), before its `:generation_started`: the hook's time (a question it waits on) isn't the generation's | `{ type: :before_generation, iteration: N }` |
61
61
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
62
62
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
@@ -636,6 +636,7 @@ stripped of surrounding whitespace, except file text, an edit's
636
636
  | `delegate` | `task` | — | `model`, `session`, `cwd`, `toolkit`, `wait`, `timeout` |
637
637
  | `delegate_result` | — | — | `session`, `timeout` |
638
638
  | `ask_user_question` | `question` | — | `question`, `options`, `header`, `multi_select`, `allow_freeform` |
639
+ | `objectives` | `note` | — | `action`, `id`, `note`, `evidence`, `change`, `text` |
639
640
  | `forget_outputs` | `note` | — | `ids`, `note`, `keep`, `restore` |
640
641
 
641
642
  A `write`'s `content:` is the file text; an `edit` carries `old_text:` and
@@ -0,0 +1,264 @@
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 and what tools returned (`.record`, each entry cut at 1,500 characters, the newest 24,000 kept). It never holds the
224
+ agent's prose, the system prompt, the list's notes, a continue's carry note, which holds the agent's last answer
225
+ (`Objectives.outside_note?`, the rule the audit shares), or the objectives tool's calls, since a move isn't evidence for itself.
226
+ Note and evidence are capped at 1,000 characters each. Mid-turn it reads the loop's conversation (`Engine#live_conversation`), the running turn's runs too.
227
+
228
+ **Its frame.** The claim and the record sit between `<record-RANDOM>` tags made per request, so tool output printing a closing tag
229
+ stays inside; the request says that what's inside is data and its instructions aren't to be followed. No model is needed for
230
+ the frame to hold; a model may still be swayed by text inside it, which is why a verdict is information only.
231
+
232
+ **Its host.** The model is the session's, or `objectives.check_model`, whose host may be another one. The whole user text goes through
233
+ `Web::ParticipantView::Redact.text`, chi's participant redaction: known key shapes and `token=`-style values become `[redacted]`, and
234
+ the home folder `~`. That is by pattern, so a secret of another shape still goes.
235
+
236
+ **State.** `objective_checks.jsonl` beside the session: a `running` line, then one with `verdict` (confirmed, refuted, unclear,
237
+ `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
238
+ 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
239
+ 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
240
+ 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`
241
+ 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
242
+ 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.
243
+
244
+ **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
245
+ writes no `pending_card.json`, so no push), the list's row, and `check:` under the move in `/objectives`. A model that fails is
246
+ `no_answer`: logged (`objectives_check_failed`), an info card saying so, and the next tap may try again.
247
+
248
+ **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
249
+ command route is the owner's, and `card` and `objectives_updated` are in `ParticipantView::DROPPED`. A move made in the running
250
+ 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.
251
+
252
+ ## System 1 in audit
253
+
254
+ `ObjectivesAudit`, off unless `objectives.system_one: audit`. See [system-one.md](../system-one.md#objectives-in-audit).
255
+
256
+ ## Not in this version
257
+
258
+ Stable-prompt lines aren't here, and terminals have no picture: they keep `/objectives`.
259
+
260
+ Taking it out: old notes stay readable framed bubbles and keep being sent, and the key is ignored. Old `objectives` calls
261
+ stay as history; with a kit no longer declaring the tool, they are ordinary saved calls. The card's bundle uninstalls. The
262
+ page's pieces are the `objectives_*.js` modules and `objectives.css`, wired in a few lines of `app.js`; without them a list note
263
+ falls back to the default bubble once `noteBubbleTemplate`'s first line goes. Check this is `objectives_check.rb`, its line in the command and
264
+ 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` 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
 
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 |
@@ -1377,8 +1379,8 @@ that calls it, or for a real external bundle that does. No method on the chance
1377
1379
  `Samagotchi::Tools::Execute.timeout_seconds`.
1378
1380
  5. `Samagotchi::Tools::TaskRuntime::TASKS_DIR` and `EXIT_CODE_FILENAME`.
1379
1381
  6. `Samagotchi::AtomicFile.write`, `Samagotchi::Log.warn` /
1380
- `Samagotchi::Log.debug`, `Samagotchi::Config.get` and
1381
- `Samagotchi::VERSION`.
1382
+ `Samagotchi::Log.debug`, `Samagotchi::Config.get`,
1383
+ `Samagotchi::VERSION` and `Samagotchi::CancellationController` (`new`, `cancel!`: what `cancel:` takes).
1382
1384
 
1383
1385
  `Samagotchi::Testing::Boundary::ALLOWED` is the checked copy of this list,
1384
1386
  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:`
data/docs/sessions.md CHANGED
@@ -192,6 +192,48 @@ OSA
192
192
  [ -n "$picked" ] && pbpaste | "$chi" note --source clipboard $(print -r -- "$picked" | cut -f1)
193
193
  ```
194
194
 
195
+ ## Objectives
196
+
197
+ `/objectives set A | B` gives a session a short list of what it is to achieve, and `/objectives` shows it. The list goes
198
+ into the conversation as a note under its own frame (`[OBJECTIVES, set by the user. …]`), which asks the model to keep
199
+ work on it, to say when something falls outside it and not to change it itself. A session with no list behaves as it
200
+ would without the feature; its request only carries the `objectives` tool below (declared on `full`, named in the
201
+ not-loaded line elsewhere). Each item has an id (`O1`, never reused), a status (active, done, dropped) and
202
+ optionally a done-when hint, which nothing enforces.
203
+
204
+ An edit waits for a moment between turns: it lands within seconds in an idle session, and at the turn's end during one.
205
+ Several edits made during a turn go in as one list. A continue copies the list to the next link, as a second note after
206
+ the carry note; a review link doesn't. `!rollback` keeps it. A chi REPL's session (`--no-shared`) refuses an edit, as it
207
+ refuses a note. See [cli.md](cli.md#session-objectives) for the commands and
208
+ [internals/objectives.md](internals/objectives.md) for the mechanism.
209
+
210
+ The model can record a **move** with the `objectives` tool: a line on what it did or found for an objective, with
211
+ optional evidence. A move shows as the tool's row in the turn, and `/objectives` shows each objective's latest one under
212
+ it. It is information only: only you change the list or a status, and it never approves anything. A move costs one
213
+ generation when it's the step's only call. On a kit that doesn't declare the tool, `/toolkit add objectives` declares it
214
+ (the model can also reach it with `call_tool`). A participant's turn is denied it.
215
+
216
+ The model can also **propose** a change with that tool (add, reword, done, drop). You see a card when the turn is over:
217
+ Apply makes the edit, once, Dismiss leaves the list, and ignoring it changes nothing. A turn leaves at most 3. A proposed
218
+ reword is refused when you changed that objective since. The model never changes the list by itself.
219
+
220
+ **Check this** (`/objectives check O2`, or the button in the web's list, for an objective with a move) asks for an independent read of
221
+ the latest move: one side request, on your request only, on the session's model or `objectives.check_model`. It sees the
222
+ claim, what people and notes said and what tools returned, not the agent's prose, and no file: what the record doesn't
223
+ settle comes back `unclear`. **That is up to 24,000 characters of raw tool output, which with a hosted `check_model` leaves the machine on one tap, even from a local session.** chi's redaction takes out known key shapes and `token=`-style values first, by pattern only, so another secret in a tool's output still goes. The verdict (confirmed, refuted, unclear) and its reasons come as a card and under the
224
+ objective. It is information only: it changes no status, approves nothing, and isn't in the conversation or the model's next request.
225
+ A second tap while one runs asks nothing. Its cost is in the session's side line. Mid-turn it runs beside the turn; on a one-slot
226
+ local server it answers after the running request. A participant can't reach it. See
227
+ [internals/objectives.md](internals/objectives.md#check-this).
228
+
229
+ **In the web** a session with a list has a strip above the conversation: its objectives and how many are done (a phone
230
+ shows one line, the one being worked on). A tap opens the list, where you add, mark done, drop, undo and tap a text to
231
+ reword it, with no message and no turn. Mid-turn the strip has the change and says it applies after this turn. Each list
232
+ in the conversation shows as pills, or as one line of what you changed. A turn's moves show as chips above its answer;
233
+ Not yet on one (or in the list, for an objective with a move) sends `Not yet on O2 (…).` as a normal message. With the
234
+ [decision-card](bundles.md#the-decision-card-bundle) bundle, the newest long answer offers its Short version in a card,
235
+ which enters no history. A session with no list shows none of this. A participant doesn't see the list.
236
+
195
237
  ## Notes a turn leaves for the model
196
238
 
197
239
  A turn that ends without an answer leaves one line for the model to read on its next turn, as a tail