samagotchi 0.127.0 → 0.129.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/docs/architecture.md +15 -5
  4. data/docs/cli.md +2 -1
  5. data/docs/guardrails.md +155 -25
  6. data/docs/internals/participants.md +23 -5
  7. data/docs/internals/web-push.md +4 -1
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +3 -2
  10. data/lib/samagotchi/bridge.rb +2 -0
  11. data/lib/samagotchi/bridge_client.rb +8 -2
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/engine.rb +56 -0
  14. data/lib/samagotchi/guardrail_wiring.rb +6 -5
  15. data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
  16. data/lib/samagotchi/guardrails/outside.rb +54 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
  18. data/lib/samagotchi/guardrails/pair_command.rb +261 -66
  19. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
  20. data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
  21. data/lib/samagotchi/guardrails/read_only_shell.rb +239 -9
  22. data/lib/samagotchi/guardrails/review_paths.rb +22 -8
  23. data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
  24. data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
  25. data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
  26. data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
  27. data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
  28. data/lib/samagotchi/guardrails/targets.rb +128 -24
  29. data/lib/samagotchi/session_manager.rb +13 -14
  30. data/lib/samagotchi/system_one/self_protect.rb +108 -16
  31. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  33. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  35. data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
  36. data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
  37. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  38. data/lib/samagotchi/verify_anchor.rb +8 -0
  39. data/lib/samagotchi/version.rb +1 -1
  40. data/lib/samagotchi/web/app.rb +9 -7
  41. data/lib/samagotchi/web/participant_review.rb +4 -2
  42. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  43. data/lib/samagotchi/web/public/app.js +25 -5
  44. data/lib/samagotchi/web/public/composer.css +1 -0
  45. data/lib/samagotchi/web/public/notify.js +2 -1
  46. data/lib/samagotchi/web/public/participant.html +3 -2
  47. data/lib/samagotchi/web/public/participant.js +18 -7
  48. data/lib/samagotchi/web/public/participant_model.js +3 -2
  49. data/lib/samagotchi/web/public/participant_names.js +2 -2
  50. data/lib/samagotchi/web/public/question_card.js +4 -1
  51. data/lib/samagotchi/web/public/session_state.js +2 -0
  52. data/lib/samagotchi/web/public/stream_handlers.js +4 -2
  53. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  54. data/lib/samagotchi/web/public/verdict_fresh.js +23 -5
  55. data/lib/samagotchi/web/public/verify_card.css +1 -0
  56. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  57. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  58. data/lib/samagotchi/worker.rb +82 -32
  59. data/lib/samagotchi/worker_idle_exit.rb +17 -0
  60. data/lib/samagotchi/worker_link.rb +13 -5
  61. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d35df5e2cd9639c1da812c221e1c256567df7e2f9986df32c6cbc49cc9d6d431
4
- data.tar.gz: 7e98badd5615015b9841a9885404c130ff46fddefd05dc70bafd051ae37f92ef
3
+ metadata.gz: 91648ee9b8cf9906ded9d6f865f424b10148222e8007516d8d74718a5a4a9c41
4
+ data.tar.gz: 0ca9cd428118fd52e4c2dc3f1b5e1750d6d7f4bf41ca0d039c8769660a2db2e9
5
5
  SHA512:
6
- metadata.gz: 3f5eaf2d9dca71dd6951989506b64c67ce905396c3af824521aa6fcc8e974d7a4f7ca6843f307f9915b5b5986b3ab87e2707ca76a771e985fa095a99acf9cedf
7
- data.tar.gz: 65b7f35bcd7372d6c51a438c3808937266b035e8e67ac6c48511734d00f78ac12b9de3536359c67ad8797a4dc542e16a04649ece8d0dc7e8e729e6b7f60dd0a2
6
+ metadata.gz: 1fa243881749c0b856bcb0c40f42937a253f33b0a0904d6145950cbf81c52262bfd3bdcf883389041f06383d7796150d7ee74a74bb9b8a8118d90ac3b4807a1c
7
+ data.tar.gz: dc1b3dafdca94da6af0338e1a76b3df6e914847514f6eeb9e61359de941cc789955e5a4b04f6e04417402fcdc221d460940051dcde3101737694cb080b038e1a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,109 @@ 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.129.0] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - A delegate child's read-only command that writes only into a tmp dir (`sqlite3 -readonly -safe ../main/x.db "…" >
15
+ /tmp/rows`, `cp ../main/x.db /tmp/`, `… | tee /tmp/f`) no longer asks as a change outside its folder
16
+ (`child-boundary`); a tmp path that links back into the repository still asks, as does a target spelt with `$` or
17
+ `~`, or one after a `cd`/`pushd` the shell may not have taken (anything but one existing dir joined by `&&`).
18
+
19
+ ### Fixed
20
+
21
+ - The owner page's Verify fresh button no longer stays disabled ("restart this session to use Verify") after the
22
+ session's worker comes back with the `verify` feature: the cards follow the session's update without a reload.
23
+ - The web's "Couldn't start a fresh check." under a verdict card no longer vanishes at the next redraw (the participant
24
+ page re-creates its cards on every update): the page keeps it until the next attempt.
25
+ - A `write` or `edit` of a dangling symlink (or a path through one) or of a file with another hard link no longer
26
+ counts as in a tmp dir or in the session's repo: a write there lands outside, so the delegate child's
27
+ `child-boundary` and `path: outside_repo` rules ask for it.
28
+ - A `task_create` call's `env:` now counts in the guardrails' path checks: `env: {TMPDIR: <main checkout>}` with
29
+ `echo x > $TMPDIR/a.txt` (or a `HOME`, `GIT_DIR`, `GIT_WORK_TREE` there) asks as the delegate child's
30
+ `child-boundary`, and `rm -rf`, chi-dir and `git: outside_repo` checks read it too. An `env:` setting `BASH_ENV`,
31
+ `ENV`, `CDPATH`, `IFS`, an exported `BASH_FUNC_…`, `LD_PRELOAD` or a git config/diff-program variable makes the
32
+ command not read-only.
33
+ - An exit and a restart asked of one worker at once (an attached terminal's `/exit` with "stop them and exit" while
34
+ chi web's "Restart workers" runs) no longer both hear yes: the first one taken decides, and the other is refused
35
+ ("the worker is already leaving as a plain exit"), so a restart no longer reports "restarted" for a worker that
36
+ left with no successor.
37
+ - A delegate child's `task_create` whose `env:` redefines the shell (`BASH_ENV`, an exported `BASH_FUNC_cat%%`,
38
+ `CDPATH`, …) now asks as `child-boundary` from its own folder too: `cat a.txt` there with a startup file or an
39
+ exported function that writes the main checkout no longer ran unasked.
40
+
41
+ ### Security
42
+
43
+ - A participant's pair turn (`chi share`, a working session) can no longer call this machine's chi web on a loopback
44
+ name (`curl -X POST 127.0.0.1:4567/api/sessions/ID/participants`, `localhost`, `[::1]`, `0.0.0.0`, the host name,
45
+ with chi web's recorded or configured port, by any client or script): a LAN chi web takes a token-less loopback
46
+ request as the owner's, so the command could mint a grant or queue an owner turn. chi web's own auth is unchanged.
47
+ - A pair turn's "a write outside the project always asks the owner" now fails closed. A command runs unasked only when
48
+ it only reads, only writes (`>`, `cp`, `tee`) into the project or a tmp dir, or stays inside the project (it runs
49
+ there and every path it names lands there or in a tmp dir). `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`,
50
+ `dd of=~/x`, `touch`/`mkdir` outside, `perl -pi`, `python -c "open('/…', 'w')"`, `cd .. && make`, and a command with
51
+ a `$var`, a `$(…)` (but a commit message's heredoc), `xargs` or a `cd` it can't follow now ask. A tmp path is one by
52
+ the delegate child's rule (`/var/tmp` too, not the tmp dir the project is in, never through a dangling symlink or
53
+ onto a file with another hard link).
54
+ - A pair turn in a session whose folder is the home folder or above it asks before every write (the review boundary
55
+ already read nothing there).
56
+ - A pair command that makes a link (`ln`, `cp -s`/`-l`, a script's `symlink`) asks the owner, and one linking to home
57
+ or a folder above a secret is denied: `ln -s ~/.config g && cat g/gh/hosts.yml` passed, as the link didn't exist yet
58
+ when the paths were judged.
59
+ - A pair turn can no longer write git's hooks (`.git/hooks/*`, by `write`/`edit` or a command), schedule a command
60
+ (`crontab`, `at`, `batch`), run a credential printer (`gh auth token`, `git credential fill`, `gcloud … print-access-token`,
61
+ `op`, `aws configure get`, …) or `ps -E`, read the owner's background tasks (`task_wait`, `task_get`, `task_list`), or
62
+ run a program whose name is a `$var`, a `$(…)` or a glob (`bin/ch? …`).
63
+ - A guardrail check no longer cuts a heredoc's body out of the command it then runs (since 0.81.0,
64
+ `ShellLex.code_text` built its result on the caller's string): with the guardrails bundle's `command:` rules, an
65
+ owner's `git commit -m "$(cat <<'EOF' … EOF)"` or `git commit -F - <<EOF` could run with an empty message, and a pair
66
+ turn's `cat > docs/x.md <<EOF … EOF` with an empty body.
67
+ - Guardrails take a path's `..` the way the kernel does: after a symlinked folder it climbs out of the link's target.
68
+ A participant's review turn could read `node_modules/foo/../../.config/gh/hosts.yml` (with `foo` an `npm link` to
69
+ `~/src/foo`) as a project file while the read opened `~/.config/gh/hosts.yml`; a pair turn's secret check, the
70
+ protected paths (`mem/../config.yml` with `mem` a link to chi's memories), `outside_repo`, the delegate child's
71
+ boundary, `touches: chi_dirs` and `rm: outside_tmp` had the same gap. Each now refuses or asks when either reading of
72
+ the path does, and a write through a dangling symlink to `config.yml`, the hooks or `.bundles/` is asked or denied
73
+ like the file it creates.
74
+ - A shell `cd` or `pushd` whose `..` climbs out of a symlinked folder's target now counts as landing in either folder:
75
+ zsh (chi's shell on macOS) and bash chdir to the physical one when the logical one doesn't exist, and `cd -P`
76
+ always does. A pair turn's `cd node_modules/foo/../../.config/gh && cat hosts.yml` (or `cd -P
77
+ node_modules/foo/../.. && echo x > .zshrc`) was allowed while it read or wrote outside the project; `touches:
78
+ chi_dirs`, `git: outside_repo` and the delegate child's boundary read the same cd logically only. Later words are
79
+ now resolved and judged against both folders, and `cd -` leaves the folder unknown.
80
+ - System 1 (`on` mode) no longer auto-allows a command that climbs out of chi's memories folder into its config:
81
+ `cd …/memories; cd ..; echo 'mode: off' >> config.yml`, `cd "${PWD%/*}"` or `cd $(dirname $PWD)` passed its
82
+ own-files check, since only the text was read. The check now resolves the command's words like
83
+ `shell-touches-chi` (cd chains, symlinks, `..`, the call's `env:`), so `cd ~/.config; cd samagotchi; cat
84
+ config.yml` is refused too. A memories command keeps its exemption while its text shows no climb (`..`, `$`,
85
+ backticks, `cd -`) and every word resolves or is data (a `python3 -c` or heredoc body, `echo "- a note"`); a call
86
+ whose `env:` redefines the shell is never auto-allowed.
87
+
88
+ Update with `chi update`, then restart `chi web` and the session workers (`chi sessions restart`).
89
+
90
+ ## [0.128.0] - 2026-10-28
91
+
92
+ ### Changed
93
+
94
+ - Verify needs a worker that knows Verify turns: on an older worker chi web disables Verify and Verify fresh with
95
+ "restart this session to use Verify", and refuses anchored turns and fresh checks sent to it.
96
+ - `sqlite3 -readonly -safe …` (only with both flags and read/display options) and `gh`'s reading subcommands
97
+ (`run`/`pr`/`issue` list and view, `pr diff`/`checks`, `repo view`; never `--web`, `api` or `--watch`) count as
98
+ read-only shell commands, so they no longer ask.
99
+ - The shared composer card footer has less vertical padding on phones.
100
+
101
+ ### Fixed
102
+
103
+ - A Verify turn records a verdict only when its quote is in the reply of the turn it points at; otherwise it runs
104
+ as a plain turn.
105
+ - A verdict's View check opens the earlier session that holds the check (after a continue), or says the check is
106
+ no longer on the page, instead of doing nothing.
107
+ - An attached piped prompt that this terminal removed before it ran says so on stderr (still exit 1) instead of
108
+ "the model gave an empty answer".
109
+ - A participant's key hint wraps to a second line in a narrow comment box instead of being cut after one.
110
+
111
+ Update with `chi update`.
112
+
10
113
  ## [0.127.0] - 2026-10-28
11
114
 
12
115
  ### Added
@@ -4787,7 +4890,9 @@ and long-lived sessions.
4787
4890
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4788
4891
  hotkey panel that send selected text or the clipboard to your sessions.
4789
4892
 
4790
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.127.0...HEAD
4893
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.129.0...HEAD
4894
+ [0.129.0]: https://github.com/dm1try/samagotchi/compare/v0.128.0...v0.129.0
4895
+ [0.128.0]: https://github.com/dm1try/samagotchi/compare/v0.127.0...v0.128.0
4791
4896
  [0.127.0]: https://github.com/dm1try/samagotchi/compare/v0.126.0...v0.127.0
4792
4897
  [0.126.0]: https://github.com/dm1try/samagotchi/compare/v0.125.0...v0.126.0
4793
4898
  [0.125.0]: https://github.com/dm1try/samagotchi/compare/v0.124.0...v0.125.0
data/docs/architecture.md CHANGED
@@ -120,7 +120,7 @@ terminal renders the events it gets over the Bridge.
120
120
  - Attached (`bin/chi`, `--attach ID`, `--shared [--resume ID]`) → `TerminalUI::AttachLauncher`: no `Engine`
121
121
  and no `OwnerLock`; finds or starts the session's worker and runs `TerminalUI::AttachedLoop`
122
122
  as a client of its Bridge (`BridgeClient#follow`, `post_turn`, `post_command`, `cancel`, `answer`,
123
- `dismiss_question`). The worker runs in the session's `working_directory`, so `!cmd` and
123
+ `dismiss_question`; opening the SSE connection answers `BridgeClient::StreamOpen::Opened`, `Refused` or `Unreachable`). The worker runs in the session's `working_directory`, so `!cmd` and
124
124
  the tools don't depend on where the terminal attached from.
125
125
  - `bin/chi web` → builds `Web::Server` (Rack+WEBrick on `127.0.0.1:4567`, `--port`/`SAMAGOTCHI_WEB_PORT`, `--open`;
126
126
  `web.host: lan` adds a LAN address, `--new-token` replaces its access token).
@@ -173,6 +173,11 @@ agent logic and can be used without any terminal rendering; the UI is a thin lay
173
173
  but `RelineSeam` (prepended to `Reline::LineEditor`) sends its drawing to the
174
174
  `Screen`. The saved history and cards in a join or resync are rendered by
175
175
  `TerminalUI::JoinReplay` with the loop's screen, view, link, gap clock and renderer.
176
+ The loop's concerns live in mixins under `terminal_ui/attached_loop/`: `AttachedLoop::Composer`, `Questions`,
177
+ `Queueing`, `Sending` and `ExitCommands` (the `/exit` ask's reply text is `TerminalUI::ExitLines`).
178
+ With input from a pipe (`chi -p X </dev/null`), `TerminalUI::OwnTurns` keeps this
179
+ run's own prompts until their turns end; `#run` distinguishes an empty answer from a
180
+ prompt this terminal removed before it ran (both exit 1), as well as chi's other outcomes.
176
181
  Without a capable terminal it falls back to `PlainSurface` (append-only).
177
182
 
178
183
  #### Using the core
@@ -373,7 +378,10 @@ process that already owns the `Engine`); every worker starts it, and it exposes:
373
378
  the event log held (`WorkerIdleExit#hold_for_request`): `200 {status: "exiting"}` and it leaves
374
379
  like an idle exit, or `409 {status: "held", reason:}` with `turn_running`, `input_queued`,
375
380
  `continue_offered`, `client_connected` (a stream not named by the asker), `reminders`,
376
- `background_tasks` (with `tasks: [{id, command}]`: the running tasks a finish wake watches) or `starting`.
381
+ `background_tasks` (with `tasks: [{id, command}]`: the running tasks a finish wake watches), `starting`, or
382
+ `leaving_exit` / `leaving_delete` / `leaving_restart` when another client's exit of another kind is already taken
383
+ (`Worker#claim_exit`, claimed before a stop-all; the words are `WorkerIdleExit::LEAVING_WORDS`). A second exit of
384
+ the kind already taken is answered `200` as that one.
377
385
  - `GET /session/:id/state` — `session_state_snapshot` (JSON).
378
386
  - `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.
379
387
  - `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
@@ -503,7 +511,8 @@ for client discovery, with the Bridge's secret (see above).
503
511
  reading `bridge.json` or probing a port itself: `WorkerLink.resolve(session_dir, unlink:, owner:)` answers a typed
504
512
  outcome. The rule is the owner lock plus the sidecar's `pid`; there is no port probe (a port can be reused by another
505
513
  session's Bridge, a free lock is the kernel's proof the worker died). In order: no lock holder, or a chi REPL's: a
506
- sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read), else `None`;
514
+ sidecar is `Stale(:lock_free)` (removed with `unlink: true`, only if it is still the file that was read and the lock,
515
+ read again just before, is still free: a worker that took it meanwhile is resolved as that worker), else `None`;
507
516
  a holder with no kind yet: `Starting`; a leaving worker: `Leaving` (marked before its Bridge stops, on every way out:
508
517
  `Worker#teardown` marks it too, with `TEARDOWN_LEAVE_WAIT`); a worker whose sidecar names no port or another
509
518
  pid than the holder's (a worker that has not written its own yet): `Starting`, `stuck` once it has held the lock
@@ -562,7 +571,8 @@ it holds with `update.notify: off`; a child that prints no rows fails with its o
562
571
  done install refreshes the hub's installed version (`SessionHub#refresh_installed`) before its `done`.
563
572
  Worker restarts target every worker older than the version installed now (refreshed the same way)
564
573
  and publish per-session results. Only one of an install, a worker restart pass and a web restart runs
565
- at a time (409 `update_running`). `POST /api/update/restart_web` (409 `restart_unavailable` without a
574
+ at a time (409 `update_running`). `POST /api/update/restart_workers` (`handle_restart_workers`) starts the worker pass
575
+ by itself. `POST /api/update/restart_web` (409 `restart_unavailable` without a
566
576
  chi wrapper to exec) schedules WEBrick shutdown only after its response is sent; after the hub and
567
577
  listener stop, `Server` execs the RubyGems chi wrapper with every flag chi web was started with, as
568
578
  `RunOptions`' parser recorded them (`RunOptions.web_restart_argv`), but the one-shot `--open` and
@@ -615,7 +625,7 @@ model: [internals/remote-sessions.md](internals/remote-sessions.md).
615
625
  |---|---|---|---|---|
616
626
  | `GET /api/sessions/resolve?id=` | owner | an id or a unique prefix | 200 `{id}` | 404 `not_found`; 400 `ambiguous_id` with `ids` |
617
627
  | `GET /api/sessions/summaries` | owner | `dir` (a folder: its project; none: every project), `cwd`, `live=1`, `archived=1`, `tests=0`, `sort`, `order`, `limit` | 200: the rows `chi sessions list` prints (`SessionManager.session_summaries`: `status`, `live`, `busy`, `owner`, `recap`, `ctx_pct`, `waiting`, `waiting_id`, `title`, `preview`, …) | 400 `invalid_dir`, `invalid_cwd` |
618
- | `GET /api/projects?dir=` | owner | optional page-scope folder | 200: project rows with counts, roots, default/current marks; no `dir`: no current project | 400 `invalid_dir`; 503 `no_hub` |
628
+ | `GET /api/projects?dir=` | owner | optional page-scope folder | 200: project rows (`Web::ProjectList`: the hub's projects plus the start project) with counts, roots, default/current marks; no `dir`: no current project | 400 `invalid_dir`; 503 `no_hub` |
619
629
  | `POST /api/sessions` (more fields) | owner | `toolkit`, `tools`, `no_tools`, `parent_answers`; `client_id` (`cli:send`/`cli:answer`: the first line is checked as chi send checks it); `check_vision: true` (images follow) | 201 adds `model_warning`, `parent_answers_warning` (a level looser than config.yml's, capped) and `first_turn_id` (the id the prompt it starts with gets for its turn; null for an `idle` create) | 400 `invalid_toolkit`, `invalid_parent_answers`, `invalid_continues` (beside `continues`); 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
620
630
  | `POST /api/sessions/:id/turn` (more fields) | owner | `client_id` as above; `check_vision: true` with `images` | as before | 403 `parent_answers_refused`, `parent_continue_refused`; 422 `no_vision` |
621
631
  | `POST /api/sessions/:id/wait/prepare` | owner | `question_id` (absent: the pending one; `null`: none), `turn`, `created`, `busy` (+ `queue`) | 200 `{session_id, cursor, baseline: {messages, question_id, last_turn}, own_turn, known_turn, running, live, settled, last_turn_id}` (`WaitStart`) | 409 `busy`, `waiting` (`SendCommand::Busy`'s words, and its fields: `question`, `parent_strategy`) |
data/docs/cli.md CHANGED
@@ -1210,7 +1210,8 @@ pills there (a review turn refuses `/` lines).
1210
1210
 
1211
1211
  After the presets, both pages have a built-in **Verify**: the quote, then
1212
1212
  an ask to check the claim against the code and end on a line `Verdict:
1213
- confirmed|refuted|unclear — <reason>`. On your page a selection in a turn
1213
+ confirmed|refuted|unclear — <reason>`. A session whose worker predates Verify (started by an older chi) refuses it (409
1214
+ `verify_unavailable`); a participant's page disables Verify and Verify fresh with a hint to restart the session. On your page a selection in a turn
1214
1215
  also offers **Copy link**: a link to that turn and quote for a participant's
1215
1216
  browser (the one their review link was opened in), which opens their page
1216
1217
  scrolled to the quote and highlights it; with no one invited, a link to it
data/docs/guardrails.md CHANGED
@@ -274,7 +274,8 @@ sub-agent](sub-agent.md)) tell them to use `chi answer` only.
274
274
 
275
275
  ## Protected paths
276
276
 
277
- Built in, for `write`, `edit` and `memory_write` (symlinks resolved):
277
+ Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
278
+ [How paths resolve](#how-paths-resolve)):
278
279
 
279
280
  - deny: the approval store's dir, installed bundles (`memories/.bundles/`),
280
281
  and [attached context](context.md) sources (`<state dir>/context/`: a
@@ -305,7 +306,21 @@ else the parent's checkout). Rule `child-boundary`, source `core`; it asks when
305
306
  overwriting a branch other than its own.
306
307
 
307
308
  Read-only commands (`git -C ../app log`, `cat ../app/x`) and paths elsewhere
308
- (`/usr/bin`, `~/.gem`) never ask. The answer is once or for the child's
309
+ (`/usr/bin`, `~/.gem`) never ask, nor does a read-only command that writes only
310
+ into a tmp dir (`/tmp`, `/var/tmp`, `$TMPDIR`): its `>` / `>>` targets, `cp`'s
311
+ destination (the last argument, or `-t` / `--target-directory`) and `tee`'s
312
+ files, resolved against its `cwd:` and any `cd` or `pushd` before them, as in
313
+ `sqlite3 -readonly -safe ../app/x.db "select 1" > /tmp/rows` or
314
+ `cp ../app/x.db /tmp/x.db`. A target that links back out of the tmp dir (a
315
+ symlink, a dangling one, a file with another hard link), `cp -l`/`-s`, `mv`,
316
+ `&>`, `>|` and `>&file` still ask, as does a target with a `$` or `~` (the
317
+ call's `env:` may set it), a `popd`, and a `cd`/`pushd` that isn't one existing
318
+ dir spelt `/…`, `.` or `..` joined by `&&` only (it may fail or be skipped and
319
+ leave the shell elsewhere); so does every tmp write when the
320
+ child's folder is itself in that tmp dir. A `task_create` call's paths resolve under chi's env and
321
+ under its own `env:` (its `$TMPDIR`, `$HOME`, `GIT_DIR`, …): either one naming another checkout asks. An `env:` that
322
+ redefines the shell (`BASH_ENV`, `BASH_FUNC_cat%%`, `CDPATH`, …, as under `skip_read_only` below) always asks, even
323
+ for `cat a.txt` in the child's own folder: what it runs is in no word of the command. The answer is once or for the child's
309
324
  session only: a repo or rule answer would hold in every worktree, for every
310
325
  child, so a stored repo or rule approval doesn't let it through either. Its
311
326
  ask goes before the rules, so with the guardrails bundle in strict mode the
@@ -337,7 +352,9 @@ participant's turn may call only the `review` kit's tools:
337
352
  `.pypirc`, ssh keys (`id_rsa`, `id_ed25519`, …), `*.tfstate`,
338
353
  `secrets.yml`/`.json`, `.docker/config.json`, `.kube/config` and any config
339
354
  under `.git` (a submodule's too). The path is checked as given and as it
340
- resolves, so a symlink in the project to `~/.ssh` is `~/.ssh`. A session
355
+ resolves, so a symlink in the project to `~/.ssh` is `~/.ssh`, and
356
+ `node_modules/foo/../../.config/gh/hosts.yml` with `foo` a link to `~/src/foo`
357
+ is `~/.config/gh/hosts.yml` ([How paths resolve](#how-paths-resolve)). A session
341
358
  whose folder is the home folder or above it reads nothing.
342
359
  - `memory_read` with `scope: project` (a read without a scope would fall back
343
360
  to the system memories). The prompt's preloaded memories and the memory
@@ -391,31 +408,56 @@ message:
391
408
  each word resolved as `touches: chi_dirs` resolves it; a glob that matches a
392
409
  secret or whose fixed part leads into a denied folder (`~/.ss?/id_*`,
393
410
  `~/.config/*/config.yml`, `.en?`), a `$var` word whose fixed part does
394
- (`~/.ss${d}h`), and a text match for the rest;
411
+ (`~/.ss${d}h`), and a text match for the rest. A prefix that comes through a
412
+ variable (`x=~/.s; cat ${x}sh/config`) isn't followed: such a command isn't
413
+ denied, it asks (its `$var` word and the assignment's path place it outside
414
+ the project);
395
415
  - sweeping a folder that holds secrets: `find`, `tar`, `cp`, `rsync`, `scp`,
396
416
  `zip`, `ditto`, `grep`/`rg`/`ag` and the like given home or any folder above
397
- a denied one;
398
- - the environment and the keychain: `env` or `printenv` alone, `set`,
399
- `export`, `declare` or `typeset` that list, `launchctl getenv`, `ps e`,
400
- `security`, `/proc/*/environ`. On top of it, a pair turn's `execute` runs
417
+ a denied one, and linking to one (`ln -s ~/.config g`, `ln -s ../.. up`);
418
+ - the environment, the keychain and stored credentials: `env` or `printenv`
419
+ alone, `set`, `export`, `declare` or `typeset` that list, `launchctl
420
+ getenv`, `ps e`/`ps -E`, `security`, `/proc/*/environ`, and the commands
421
+ that print a credential (`gh auth token`, `git credential fill` and the
422
+ `git credential-*`/`docker-credential-*` helpers, `gcloud … print-access-token`,
423
+ `op`, `aws configure get`/`export-credentials`, `az account
424
+ get-access-token`, `heroku auth:token`, `kubectl config view --raw`;
425
+ `PairCommand::CREDENTIAL_PRINTERS`). On top of it, a pair turn's `execute` runs
401
426
  without every variable whose name matches `KEY|TOKEN|SECRET|PASS|CREDENTIAL|AUTH`
402
427
  (`PairBoundary::SECRET_ENV_NAME`, case-insensitive), and a command still
403
428
  running at the limit is killed, never handed over as a background task;
404
429
  - git settings that make git run a command (`core.hooksPath`,
405
430
  `core.fsmonitor`, `core.sshCommand`, `core.pager`, `core.editor`, a `!`
406
431
  alias) by `git config` or `git -c`, and `git config --global`/`--system`;
432
+ and writing git's hooks: a `write` or `edit` of a path in a `.git/hooks`
433
+ folder (as given or as it resolves), or a command that isn't read-only and
434
+ names one (`printf … > .git/hooks/pre-commit`, `cp x .git/hooks/`);
407
435
  - running chi, by any wrapper (`bin/chi`, `bundle exec chi`, `ruby … bin/chi`,
408
436
  `env … chi`, `watch`, `npx`, `sh -c 'chi …'`, `$(chi …)`), in an assignment's
409
437
  value or an alias, a function, after `-exec`/`-c`/`-e`, but `chi --version` and
410
438
  `chi --help`: its commands act in the owner's name (shares, caps, sends,
411
439
  answers, sessions);
440
+ - calling chi web on this machine: a command whose text names a loopback name
441
+ (`localhost` and its subdomains, `127.x`, `0.0.0.0`, `[::1]`, `::ffff:127.x`,
442
+ `0:PORT`) or this machine's host name together with chi web's port (the one
443
+ the running chi web recorded in `web.json`, config's `web.port`, 4567), by
444
+ `curl`, `wget`, `nc`, `/dev/tcp`, a script's URL or anything else: a loopback
445
+ request without a token is the owner on a LAN chi web
446
+ (`PairBoundary::CHI_WEB`). A Bridge answers only with its secret, which sits
447
+ in chi's state dir;
448
+ - a program whose name the shell expands: a `$var`, a `$(…)` or a glob in a
449
+ command's name (`bin/ch? …`, `e=ch; ${e}i …`, `$CMD x`), whatever it would
450
+ turn out to be (`PairBoundary::VERB`);
412
451
  - leaving something running: `&` at a command's end, `nohup`, `setsid`,
413
- `disown`, `screen -d…`, `tmux new -d`;
452
+ `disown`, `screen -d…`, `tmux new -d`, and scheduling it: `crontab`, `at`,
453
+ `batch`;
414
454
  - owner-voiced tools, which act in the owner's name beyond the turn:
415
455
  `delegate` and any `delegate_*` tool, `register_reminder`,
416
456
  `cancel_reminder`, `send_note`, `forget_outputs`, `task_create`,
417
457
  `task_stop`, `find_tools` (it loads tools into the session), and
418
- `memory_write` (a memory reaches the owner's later sessions);
458
+ `memory_write` (a memory reaches the owner's later sessions); and the tools
459
+ that read the owner's background tasks and their output, `task_wait`,
460
+ `task_get`, `task_list` (a pair turn starts none);
419
461
  - `ask_user_question`, with the advice to ask in the reply: nobody on the
420
462
  participant's side answers a card.
421
463
 
@@ -426,13 +468,41 @@ own, one another interpreter runs (`ruby -e`, `python - <<EOF`) by its text
426
468
  `tee`, `git commit -F -`) is not looked into: where it goes is checked. A
427
469
  command the checks can't read is refused, never let through on a text match.
428
470
 
429
- And it **asks the owner** before a `write` or `edit` outside the session's
430
- project root, and before a command that writes there (a redirection's target,
431
- `tee`'s files, a `cp`/`mv`/`ln`/`install`/`rsync`/`ditto` destination, or a
432
- target it can't resolve), every time: the ask is fresh (`Verdict#fresh_ask`: no
433
- stored approval answers it, and no rule lowers it), so it comes even in auto
434
- mode. The tmp dirs (`Dir.tmpdir`, `$TMPDIR`, `/tmp`, `/private/tmp`) and
435
- `/dev/null` neither ask nor deny.
471
+ And it **asks the owner** before a `write` or `edit` that may land outside the
472
+ session's project root, and before a command that may write there, every time:
473
+ the ask is fresh (`Verdict#fresh_ask`: no stored approval answers it, and no
474
+ rule lowers it), so it comes even in auto mode. A command fails closed
475
+ (`PairCommand#outside_writes?`): it runs unasked only when it
476
+
477
+ - only reads (`ReadOnlyShell.read_only?`, wherever it reads), or
478
+ - only reads but for writes it can name (a `>`/`>>` target, `cp`'s
479
+ destination, `tee`'s files) into the project or a tmp dir
480
+ (`ReadOnlyShell.read_only_but_writes?`), or
481
+ - stays inside the project: every part of it runs in the project, and every
482
+ path it names lands in the project or a tmp dir, an option's or an
483
+ assignment's value (`of=`, `--output=`, `GIT_DIR=`), a glob's fixed part
484
+ (`~/oth*`), `git -C`'s folder and the paths in a script it hands on (`sh -c`
485
+ read as a command, `python -c`/`ruby -e`/a heredoc by the paths in its text)
486
+ among them. The program's own word doesn't count (`/usr/bin/sed`), nor does
487
+ `PATH=…`, nor a message's heredoc (`git commit -m "$(cat <<'EOF' … EOF)"`).
488
+
489
+ Anything else asks: `rm -rf ~/x`, `sed -i … ~/x`, `curl -o ~/x`, `dd of=~/x`,
490
+ `touch`/`mkdir` outside, `perl -pi … ~/x`, `cd .. && make`, `cd && make`, and
491
+ whatever the checks can't place: a `$var` (`rm -rf $DIR`, `> "$OUT"`), a `$(…)`,
492
+ `xargs`, a `cd $X`. A path "lands" in the project or a tmp dir every way it
493
+ resolves ([How paths resolve](#how-paths-resolve)) and never through a dangling
494
+ symlink or onto a file with another hard link (`Outside.inside?`,
495
+ `Outside.tmp_write?`, the child boundary's tmp rule): the tmp dirs are
496
+ `Dir.tmpdir`, `/tmp` and `/var/tmp`, except the one the project itself is in.
497
+ `/dev/null` and the like neither ask nor deny. A script file's body (`ruby
498
+ x.rb`, `make`, `sh ./run.sh`) isn't read: it runs as in-project work.
499
+ A command that makes a link asks too, wherever it points (`ln`, `link`, `cp
500
+ -s`/`-l`, `sh -c 'ln …'`, an interpreter's `symlink`/`link(`): a path through
501
+ a link the same command makes is judged before the link exists, so `ln -s ..
502
+ up && cat up/…` can't be read for what it reaches.
503
+ A session whose folder is the home folder or above it has no project to
504
+ keep the turn in (`ReviewPaths.home_root?`, the review boundary's test): every
505
+ write there asks.
436
506
 
437
507
  A participant's ask names them on the owner's card ("Sam's turn asks: approve
438
508
  tool call?", `approval.author`/`author_name`, `approval.asked_by`, the same
@@ -448,7 +518,10 @@ The trust model is a team's (everyone invited is trusted): the boundary is an
448
518
  hostile participant; a review session is. A pair turn can still reach, through
449
519
  the owner's rules: `web_fetch` (an open egress path: what the turn can read it
450
520
  can send out in a URL), plugin and MCP tools, `memory_read` and `context_read`,
451
- `list_sessions` (the owner's other sessions' titles), and anything a script
521
+ `list_sessions` (the owner's other sessions' titles), chi web by a name the
522
+ check doesn't know (a DNS name or a decimal address for 127.0.0.1, a port
523
+ forward, a port spelt in a variable), where a LAN chi web still takes a
524
+ token-less loopback request as the owner's, and anything a script
452
525
  does that the command checks don't read (a script file's body, an interpreter's
453
526
  `-e` code). Share a working session only with people you would let type in it.
454
527
 
@@ -557,9 +630,17 @@ every command in it (split at `;`, `&&`, `||`, `|`) is `ls`, `cat`, `head`,
557
630
  writes or runs something (`sed -i`, `sed 's/a/b/w f'`, `find -delete`/`-exec`,
558
631
  `rg --pre`, `sort -o`, `awk` with `>`/`|`/`system(`, `git -c`, `git diff
559
632
  --ext-diff`, …) and no redirection other than to `/dev/null` or another fd.
633
+ `sqlite3` is read-only only with both `-readonly` and `-safe`; options are
634
+ checked even after the database name, and only its read/display options are
635
+ allowed (unknown options and dot-command arguments are rejected). `gh` allows
636
+ only `run list|view`, `pr list|view|diff|checks`, `issue list|view` and
637
+ `repo view`; `--web`/`-w`, `gh api` and `pr checks --watch` are not read-only.
560
638
  `$(…)`, backticks, `$VAR` (other than `$HOME`, `$XDG_*`, `$TMPDIR` and `$PWD`
561
639
  at a word's start), an env prefix, `sh -c`, `xargs`, `eval`, `sudo` and the
562
- like make a command not read-only. It errs towards asking.
640
+ like make a command not read-only, as does a `task_create` `env:` that sets `BASH_ENV`, `ENV`, `CDPATH`, `IFS`,
641
+ `SHELLOPTS`, `BASHOPTS`, `BASH_FUNC_…`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `GIT_EXTERNAL_DIFF`,
642
+ `GIT_CONFIG_PARAMETERS` or `GIT_CONFIG_COUNT` (they change what its words run; `PATH` doesn't count). It errs
643
+ towards asking.
563
644
 
564
645
  A tool name may be a glob, so one rule covers a plugin's tools (an MCP server's,
565
646
  say): `tool: "mcp_*"` or `tool: ["mcp_{git,gh}_*", web_fetch]` (`*`, `?`, `[…]` and
@@ -642,6 +723,41 @@ one folder; a link in the repo that points elsewhere is outside). Never outside:
642
723
  - a tmp folder (`$TMPDIR`, `/tmp`, `/var/tmp`), unless the session's repo is
643
724
  itself in that tmp folder: a sandbox there still gets asked about its siblings.
644
725
 
726
+ A path that is or goes through a dangling symlink (a write would create the
727
+ link's target) or a file with another hard link (a write changes the other
728
+ name too) is always outside, in the repo or a tmp folder alike
729
+ (`Outside.resolve`, which the child boundary's tmp writes read as well).
730
+
731
+ #### How paths resolve
732
+
733
+ Every path check (protected paths, `outside_repo`, `touches`, `rm`, `memory:
734
+ prompt`, the child, review and pair boundaries, System 1's own-files test)
735
+ resolves paths with `Guardrails::ProtectedPaths`:
736
+
737
+ - `..` is taken the kernel's way (`.expand`): after a symlinked folder it
738
+ climbs out of the link's target, so `repo/link/../x` with `link` pointing
739
+ to `/elsewhere/deep` is `/elsewhere/x`, not `repo/x`;
740
+ - symlinks are resolved on the longest part that exists, and a dangling
741
+ symlink is followed to the file a write through it would create (`.real`);
742
+ - a check reads the path both ways, the lexical answer (`File.expand_path`,
743
+ stopping at a dangling link) and where the kernel lands (`.reals`): it
744
+ refuses, asks or votes when either is protected, outside or a secret, so
745
+ the kernel's answer only ever adds to the lexical one. A file tool call's
746
+ paths (`Targets#paths`, what approvals show) list both when they differ.
747
+
748
+ In a shell command a file argument resolves the same way, against the folder
749
+ the command is in by then. A `cd` or `pushd` to such a path may land either
750
+ way: `cd link/..` is back where it started only while that logical folder
751
+ exists (zsh, which chi's shell is on macOS, and bash chdir to the physical one
752
+ when it doesn't), and `cd -P`, `set -P` and zsh's `chase_links`/`chase_dots`
753
+ always take the physical one. So after it the command counts as in both
754
+ folders: each later word is resolved against each (a word that can't be
755
+ resolved from one isn't resolved), and judged both ways as above; past eight
756
+ folders, or after `cd -`, the folder is unknown. After `cd link` a `../x`
757
+ climbs out of the link's target as well. git's `-C`, `--git-dir` and
758
+ `--work-tree` (and `GIT_DIR`/`GIT_WORK_TREE`) are counted both ways, from each
759
+ folder the command may be in.
760
+
645
761
  `git: outside_repo` (shell tools only) reads the command for git subcommands
646
762
  that change a checkout: `commit`, `add`, `reset`, `checkout`, `switch`,
647
763
  `rebase`, `merge`, `push`, `stash` (not `stash list`/`show`), `rm`, `mv`,
@@ -878,12 +994,26 @@ gate below passes, in this order:
878
994
  config dir (config.yml), the hooks dir, the installed bundles dir, the
879
995
  guardrails store (where `approvals.json` and `judgments.jsonl` live) or the
880
996
  attached context dir, and no `execute` or `task_create` command naming any
881
- of them, however it spells them (`ParentApprovals::CHI_TEXT`:
882
- `$XDG_CONFIG_HOME/samagotchi/config.yml`, `~/.config/samagotchi/…`, a
883
- `cd ~/.config` then `samagotchi/config.yml`). The memories dir is
884
- deliberately not one of these: reading or appending a memory file can be
885
- auto-allowed, unless the command has a `..` segment (`memories/../config.yml`
886
- climbs out of it). A protected rule
997
+ of them (or chi's state dir). A command is checked twice: by its text
998
+ (`ParentApprovals::CHI_TEXT`: `$XDG_CONFIG_HOME/samagotchi/config.yml`,
999
+ `~/.config/samagotchi/…`, a `cd ~/.config` then `samagotchi/config.yml`),
1000
+ and by its words resolved as `shell-touches-chi` resolves them
1001
+ (`Guardrails::ShellPaths::Walk`: cd chains, `~`, `$HOME` and the XDG dirs,
1002
+ symlinks, `..`; under the call's `env:` too), so `cd ~/.config; cd
1003
+ samagotchi; cat config.yml` or a symlink into the config dir is refused.
1004
+ A call whose `env:` redefines the shell (`Targets#redefines_shell?`:
1005
+ `BASH_ENV`, `BASH_FUNC_…`, …) is refused whatever it names. The memories
1006
+ dir is deliberately not one of these: reading or appending a memory file
1007
+ can be auto-allowed, by a command none of whose words lands in chi's dirs
1008
+ outside the memories, whose text shows no climb (`SelfProtect::CLIMB`: a
1009
+ `..` segment, any `$` or `$(…)`, backticks, a `~` other than the home one,
1010
+ `cd -`), and whose words the walk can't resolve are only data read while
1011
+ it knew the dir: a script or heredoc body (`python3 -c '…'`, `cat >> x.md
1012
+ <<'EOF'`), a phrase (`echo "- a note"`) or a glob with no `/`. So
1013
+ `cd memories; cd ..`, `cd "${PWD%/*}"`, `cd $(dirname $PWD)` or `cd -`
1014
+ loses the exemption, and the memories text then refuses it. A script body
1015
+ is not looked into beyond its text: an accident guard, not containment.
1016
+ A protected rule
887
1017
  (`Verdict::PROTECTED_RULES`) may be listed by name and System 1 may answer
888
1018
  it, but a call that really writes config.yml or the hooks is refused here;
889
1019
  5. the judgment's `p` is at least `guardrails.system_one.threshold` (0.8);
@@ -176,8 +176,11 @@ Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt
176
176
  to act: the turn only reads (`Guardrails::ReviewBoundary`), and proposes what the owner applies ([Apply](#apply)).
177
177
  - **`:pair`**, a working session's participant invited to act: the turn acts with the session's tools under the
178
178
  owner's guardrails, its asks go to the owner and offer once only, and `Guardrails::PairBoundary` denies secrets,
179
- chi itself, background jobs, the environment, owner-voiced tools and the model's own question, and asks before a
180
- write outside the project ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it acted itself.
179
+ chi itself and chi web on loopback, background and scheduled jobs, the environment and credential printers, git's
180
+ hooks, owner-voiced tools and the owner's task outputs, and the model's own question, and asks before a command
181
+ that makes a link and before a write that may land outside the project, failing closed (a session at or above
182
+ home has none: every write asks) ([guardrails](../guardrails.md#a-participants-pair-boundary)). No Apply: it
183
+ acted itself.
181
184
 
182
185
  `ParticipantMode.of(session, features:)` is the session's: what a participant invited now gets. No session is
183
186
  `:review`. A worker names `pair_turns` (`Delivery::PAIR_FEATURE`) when it runs pair turns; one without it runs
@@ -230,6 +233,15 @@ worker checks their link (`participants.json`: revoked, expired) and their cap a
230
233
  dequeued (`:turn_dequeued`) and audited (`revoked`, `cap_reached`) instead of run, so a revoke also stops what they
231
234
  had queued; its settled record (`TurnCheckpoint.record_refused`: canceled, `cancel_reason` the reason) ends their
232
235
  wait on it at once, which `ParentReport` reads as status `refused`, exit 1.
236
+
237
+ **The Verify gate.** A worker names `verify` (`VerifyAnchor::FEATURE`, in the Bridge's `FEATURES`) when it understands a
238
+ Verify turn's anchor (`VerifyAnchor`, `{turn_id, quote}`: a UUID and a quote of 1 to 200 characters). A turn carrying an
239
+ anchor, or a Verify fresh check, sent to a connected worker without it is refused 409 `verify_unavailable`
240
+ (`VerifyAnchor::UNAVAILABLE`: "restart this session to use Verify"; `SessionManager` checks `worker_has?` before it
241
+ delivers, on the features of the sidecar its client was made from), since an older worker would run it as a plain prompt. A session with no connected worker is not gated: the
242
+ worker that starts for it is current. The participant page's session summary carries `verify_unavailable`
243
+ (`VerifyAnchor.worker_unavailable?`, from the worker's features; `participant_review.rb`), and the page disables
244
+ Verify and Verify fresh with a "restart this session" hint.
233
245
  **The owner's input during a review turn queues.** While a participant's turn runs, the owner's input never merges
234
246
  into it, from any client (the web, an attached TUI, `chi send` without `--queue`): the Bridge's `POST /turn` makes it
235
247
  `delivery: queue` whatever it asked, so it never steers or cuts (`TurnAccumulator#participant_turn?`, under the event
@@ -264,7 +276,9 @@ queued, as `--turn T` does.
264
276
  **The tool calls.** In a review session `Guardrails::ReviewBoundary` denies a participant's turn everything but
265
277
  reads of the project ([guardrails](../guardrails.md#a-participants-review-boundary)). In a working session
266
278
  `Guardrails::PairBoundary` takes its seat: the owner's checks and rules decide the rest, as for the owner's turns
267
- ([guardrails](../guardrails.md#a-participants-pair-boundary)). Approvals and questions are owner-only: either
279
+ ([guardrails](../guardrails.md#a-participants-pair-boundary)). Both judge a path every way it resolves
280
+ (`ProtectedPaths.reals`: a `..` after a symlinked folder climbs out of its target, as the read opens it;
281
+ [guardrails](../guardrails.md#how-paths-resolve)). Approvals and questions are owner-only: either
268
282
  boundary denies the model's own question, and `/answer` is an owner route. A pair turn's ask reaches the owner's card
269
283
  naming who asks (`approval.author`, `author_name`; `Guardrails::Approval::PARTICIPANT_HEADER`) with Allow once as its
270
284
  only scope, and System 1 never judges it (`GuardrailWiring#system_one_judge`). A grant without `--identity` (a LAN
@@ -362,7 +376,7 @@ fields: a field added to an event later reaches nobody until it is named there.
362
376
  owner's participant view has none. The page draws it as one line in the comment box card's footer
363
377
  (`.composer-card-foot`, `usageItems`): "$0.42 of
364
378
  $2.00 cap · 1/3 queued · link: 6 d left · model", each with a title in words; warn colours from 80% of the cap, a
365
- full queue and a link's last day, "over" at the cap. The key hint is the line above the text field (hidden on a
379
+ full queue and a link's last day, "over" at the cap. The key hint is the line above the text field, wrapping to at most two (hidden on a
366
380
  phone). The usage is cut at the end (the model first); folded, it stays as the card's footer under the grip. It is redrawn at each re-read (a
367
381
  turn's end, a queued or taken-back comment), not while a turn streams.
368
382
  - **A fork's note.** A review fork (`chi review --from`, `/coordinate review`) starts with a note holding the source's
@@ -420,7 +434,11 @@ fields: a field added to an event later reaches nobody until it is named there.
420
434
  - **Verify verdict cards** (`verdict_card.js`). The built-in Verify preset carries the selected source turn id and quote
421
435
  with the submitted turn, without changing its prompt text. A Verify turn that fails fast and is rolled back hands
422
436
  the claim back with its prompt (`prompt_restored`'s `verify`), so the owner's resend from the composer is a Verify
423
- turn again. A completed Verify reply whose final non-empty line has
437
+ turn again. Before the Engine stamps the anchor or records a verdict, it checks that the source turn exists and
438
+ its assistant reply contains the quote. Matching is case-sensitive, collapses whitespace, and strips common
439
+ Markdown presentation (links, headings, list/blockquote prefixes, emphasis and code delimiters) from both sides;
440
+ this matches the rendered text the page selects without treating different casing as the same claim. A fresh child
441
+ checks the same anchor against its parent's history. A completed Verify reply whose final non-empty line has
424
442
  a valid `Verdict: confirmed|refuted|unclear — <reason>` appends a verdict to the session. Both pages place its card
425
443
  after the source turn, name its author, show the reason and quote, and link to the checking turn; a live completion
426
444
  adds the card without a reload. The participant view receives only the card fields through its allowlist, with the
@@ -202,7 +202,10 @@ yet). Results: `:ok` stamps the device's
202
202
  `last_ok_at`; `:gone` removes it; `:failed` keeps its code in `last_error` and logs `push_failed`; `:bad_jwt` logs
203
203
  one `push_bad_jwt` warning (on chi web's terminal too) and stops all pushes until chi web restarts. Log lines carry
204
204
  `device:` (the short id), `code:`, `ms:`, `kind:`: never the endpoint, keys or text. `#stop` drops pending holds and
205
- joins with a timeout; `Server.start`'s ensure calls it. Specs drive `#pump` with a moved clock
205
+ joins with a timeout; `Server.start`'s ensure calls it. chi web's own restart (`restart_web`, `Server.reexec`) execs
206
+ without either: the shows still in their hold and the retries waiting in `@pending` / `@retries` go with the process,
207
+ unsent and not in `sent.json`. The exec'd chi web's seed brings back only what restart catch-up covers (a question
208
+ still open); a closed-meanwhile or non-question show held then is not pushed. Specs drive `#pump` with a moved clock
206
209
  (`start(hub, sender: false)`), and one example runs the real thread with a client that blocks.
207
210
 
208
211
  ## The routes (`web/push_routes.rb`)
data/docs/sessions.md CHANGED
@@ -21,7 +21,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
21
21
 
22
22
  A session is deleted if **expired by age OR overflow by count** (unless `keep_status` or the live-owner guard: a session a worker or `chi` still has open is never pruned). A session's `status` is its turn state (`idle`/`running`), not whether a worker is alive. Orphan dirs without a `*.json` are never deleted, except skeleton-only ones (see *Empty sessions*). Deletion removes both `*.json` and sidecar dir atomically. Set both `DAYS=0` and `MAX=0` to retain forever.
23
23
 
24
- **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken, a later exit request (a stop-all from another terminal) is answered as that same exit: the first one decides how the worker leaves. A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
24
+ **Worker idle exit:** a background worker (plain `chi`, Web UI sessions) exits after `SAMAGOTCHI_SESSION_IDLE_EXIT_MINUTES` (`session.idle_exit_minutes`, default `30`, `0`=never) with no turn running or queued, no client on its stream (an open web tab or attached terminal keeps it), no reminder registered, no background task it watches for a [finish wake](internals/background-tasks.md#finish-wake) still running or waiting for its wake turn (with `session.task_wakes: wake`; not one whose turn you stopped) and no continue offer (the step-limit question) waiting for an answer. It removes `bridge.json` and frees `owner.lock`; the next prompt or `--attach` wakes a new worker (`WorkerIdleExit`, `SessionManager.run_session_loop`). As its Bridge stops, the worker marks the lock leaving (`OwnerLock#mark_leaving`: `leaving_at`, and `leave_wait`, its `recap.timeout`), since it keeps the lock while it writes the recap on leave: an `--attach` or `--resume` meanwhile (here, or a remote one through chi web's wake) prints `(waiting for the session to finish its recap…)` (`LaunchMode::RECAP_WAIT`) and waits for the release, up to that budget plus `OwnerLock::LEAVE_MARGIN` (5 s), before starting the new worker (`OwnerLock.await_release`). A lock held by a worker that isn't leaving (a live one, a stuck one) is left as before. A client can also ask the worker to exit right away (Bridge `POST /session/:id/exit`; `/exit` in an attached terminal does, `/detach` doesn't): the same rules apply without the timeout (even with `0`), and the asker's own stream doesn't count. The reply names what keeps it up (for background tasks, which ones and how to stop them). When every hold is one the user can stop — reminders, a watched task still running, a delegate with a live worker — the 409 also carries `holds: [{kind:, items: [{id:, label:, detail:}]}]` (a reminder's detail is its schedule, `every 20m`/`every 1h`, `, due now` when it is already due), and an attached terminal asks what to do with them; its "stop them and exit" posts `{"stop": "all"}`, which clears the reminders, stops those tasks (`TaskRuntime.stop_task`, as `task_stop`) and stops those children (`SessionManager.stop_session`) before leaving, re-checking first that nothing the user can't stop appeared meanwhile (`HoldStop`). A delegate counts only for that ask: it never holds the idle exit, and an exit that deletes or archives the session does not ask about it. The session's status stays as it was, not `stopped`. With `"restart": true` (a worker whose sidecar lists the `restart` feature) the worker hands the session to a new worker on the newest chi installed: it leaves, then starts its successor once its lock is free, keeping even an empty session and writing no recap. Clients' streams don't hold a restart (a web tab and an attached terminal reconnect to the new worker); what would be lost does: a turn running or queued, a continue offer, a question or approval waiting, a `/btw` (or another anytime command) still running, a delegate's approval relay open or settled in the last 10 minutes, and background tasks this conversation started that still run (`WorkerIdleExit#hold_for_restart`; 409 `held` with `reason`). Reminders go along: as it leaves, the worker writes them to `<id>/reminders.json` (0600: `session_id`, `written_at`, and each reminder's id, name, description, interval and next fire as wall-clock time; `ReminderCarry`), and its successor reads and deletes the file as it starts, before its idle scheduler runs. A restored reminder keeps its id and schedule; one that came due meanwhile fires once at once, and one further ahead than its interval (a clock that jumped) is due one interval from now. The file counts only for the same session within 2 minutes of `written_at`, and not once the session is stopped: otherwise it is deleted unread (log `reminders_carry_dropped`, at warn unless the session was stopped). A stop that lands while the worker leaves for a restart wins: the worker writes no file, and no successor starts (`SessionManager.run_session_loop`, log `restart_dropped`); an archive deletes the file of each session it archives. Once an exit or restart is taken (claimed before a stop-all stops anything), the first one decides how the worker leaves: a later request of the same kind is answered as that exit, one of another kind (a stop-all from another terminal after a restart, chi web's "Restart workers" after a `/exit`) gets 409 `held` with `reason` `leaving_exit`, `leaving_delete` or `leaving_restart`, and a restart says it was refused rather than "restarted". A write that fails keeps the worker up with its reminders (log `reminders_carry_failed`), and the caller reports no new worker. A reminder already due when the restart is asked runs its turn first, so the restart's wait (10 s) may report no new worker yet though the hand-off follows. A worker from before this still refuses a restart for its reminders (409 `reminders`). Only an asked restart carries them: the fallback's TERM (a worker that can't be asked, below), a stop, an idle exit (which reminders hold anyway) and a crash drop them as before. What lives only in the worker's memory goes: the event history a late client replays, the kept `/btw` answers, a plugin's in-memory state.
25
25
 
26
26
  **Empty sessions:** a session nothing happened in is deleted when it is left, so `chi` → `/exit` leaves no trace. Empty means: no messages, no turn tried (a failed turn leaves a `last_prompt` and keeps it), no memory used, no pending question, nothing in `input/`, `notes/` or `images/`, nothing in its dir beyond the worker's skeleton, mode `assist`, and the default model. A session on another model (`/model`, `--model`) counts as prepared and stays (`SessionManager.discardable?`). The worker checks as it idle-exits or leaves on a client's request and deletes it once its lock is free, checking again first. The attached `/exit` then says `Detached; the session is empty: it will be discarded if nothing arrives before its worker stops.` (a note or prompt landing first keeps it). The REPL (`--no-shared`) does the same at `/exit` or Ctrl-D. The retention sweep catches those killed first: empty sessions over an hour old with no live owner, whatever `DAYS`/`MAX`, and skeleton-only dirs with no `*.json`. `session.keep_empty: true` (env `SAMAGOTCHI_SESSION_KEEP_EMPTY`) turns all of it off.
27
27
 
data/docs/testing.md CHANGED
@@ -255,14 +255,15 @@ well formed (unique ids, known keys and labels, a rule that exists in its set).
255
255
  ## While you work: affected specs
256
256
 
257
257
  ```sh
258
- script/affected-specs # the specs for this branch's change, best first, at most 10
258
+ script/affected-specs # the specs for this branch's change, best first, at most 16
259
259
  bundle exec rspec $(script/affected-specs)
260
260
  script/affected-specs --why # with the reason for each pick; --all: no cap; --run: runs them
261
261
  script/affected-specs A..B # another range (A...B: from their merge-base); a single REV: vs the working tree
262
262
  ```
263
263
 
264
264
  A builder runs these while working, not the full suite: the specs the change touched, the specs named after the
265
- files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…), the specs that read
265
+ files it changed (`lib/samagotchi/foo/bar.rb` → `spec/foo/bar_spec.rb`, `bar_*_spec.rb`…;
266
+ `lib/samagotchi/foo/bar/baz.rb` → `spec/foo/bar_baz_spec.rb`), the specs that read
266
267
  docs or source as text (its `RULES`: add one when a full run catches what the names missed) and the golden of a changed
267
268
  file's layer (any `terminal_ui/*.rb` → `spec/terminal_ui_golden_spec.rb`; `turn_flow.rb` and `turn_note.rb`, whose
268
269
  words the REPL prints, pick it by a rule). Changes to `tool_declarations.rb` and `system_prompt.rb` also pick the