samagotchi 0.136.0 → 0.137.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +15 -6
  5. data/docs/guardrails.md +12 -5
  6. data/docs/internals/engine.md +4 -4
  7. data/docs/internals/web-frontend.md +20 -10
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +2 -1
  10. data/lib/samagotchi/bridge.rb +4 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/desktop/macos.rb +3 -2
  13. data/lib/samagotchi/engine.rb +6 -1
  14. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  15. data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
  16. data/lib/samagotchi/guardrails/outside.rb +5 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +3 -19
  19. data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
  20. data/lib/samagotchi/guardrails/review_paths.rb +32 -2
  21. data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
  22. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  23. data/lib/samagotchi/version.rb +1 -1
  24. data/lib/samagotchi/web/message_display.rb +6 -3
  25. data/lib/samagotchi/web/participant_review.rb +2 -1
  26. data/lib/samagotchi/web/public/answer_fold.js +44 -15
  27. data/lib/samagotchi/web/public/app.js +12 -1
  28. data/lib/samagotchi/web/public/chip_popover.js +20 -42
  29. data/lib/samagotchi/web/public/context_chips.js +16 -6
  30. data/lib/samagotchi/web/public/copy.js +8 -5
  31. data/lib/samagotchi/web/public/details_motion.js +74 -0
  32. data/lib/samagotchi/web/public/families_view.js +15 -6
  33. data/lib/samagotchi/web/public/index.html +67 -49
  34. data/lib/samagotchi/web/public/info_bar.js +1 -1
  35. data/lib/samagotchi/web/public/lists_view.js +1 -0
  36. data/lib/samagotchi/web/public/participants_panel.js +1 -1
  37. data/lib/samagotchi/web/public/popover_modal.js +57 -0
  38. data/lib/samagotchi/web/public/revise_stack.js +76 -6
  39. data/lib/samagotchi/web/public/revise_switch.js +55 -0
  40. data/lib/samagotchi/web/public/scope.js +3 -3
  41. data/lib/samagotchi/web/public/send_turn.js +4 -2
  42. data/lib/samagotchi/web/public/settings_chip.js +2 -2
  43. data/lib/samagotchi/web/public/stale_params.js +26 -0
  44. data/lib/samagotchi/web/public/start_settings.js +1 -1
  45. data/lib/samagotchi/web/public/start_toolkit.js +1 -1
  46. data/lib/samagotchi/web/public/stream_handlers.js +10 -5
  47. data/lib/samagotchi/web/public/working_set_rail.js +2 -0
  48. data/lib/samagotchi/web/public/working_set_view.js +2 -2
  49. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12638807efa522a91eb43ef7934f29aa0a4473d48da39897d6244b755eafdfdc
4
- data.tar.gz: e4dbe2698ef9517d9ccbacbd6e216720d8f0ce8e75bef8974f05951b7e1d2bfd
3
+ metadata.gz: 0bac134a85c720d669fc13d4a63f67aaced78c3078b1335a24e32639e9cce367
4
+ data.tar.gz: 0e7a25de11d0a1b1701671998639bc337d84262f8a26039d4f04f8d3ef6fc587
5
5
  SHA512:
6
- metadata.gz: 605c9b52f0d2b949e2f875ef939ba01401def71fb879ef78affe5e97b9f04ba2c761e48e17c801525617a87cfb4f7bed2d5b64045080f6408974e97a7e8f3f07
7
- data.tar.gz: c8d922fa4080f5b098ad953859506cfded71dc2be9038f836e88f53c288abca31265df0acdda01d3b945e43107d5c41b6ab9d5e2c3fa9196d4be284654e9d8e9
6
+ metadata.gz: b52a815fbe6ee0f013935933c156eee85ce4ca2dd15971cc096606dc4a57eba46756c096ecfcaaa7ab2c8b9919e80a34c2d5ee76a2053fa526195eb387a4e476
7
+ data.tar.gz: c1149439f9e4ce5d22368f8d634d1d2913b4e418cbfadff5f7bf61b0ca6469e3b45ea64e42240b5e9d806f5f83e5e5aa9a9810a3be45deca33a37a50a901bf26
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ 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.137.0] - 2026-10-30
11
+
12
+ ### Changed
13
+
14
+ - Web: a Revise stack's top answer has a ‹ N/M › switcher that shows an earlier version in its place (view only;
15
+ Copy copies the version shown; a reload shows the latest).
16
+ - Web: a long prompt folds like a long answer (same threshold, open by default; this browser remembers the fold).
17
+ - Web: every popover behaves like the ⚙ panel: a modal dialog (Tab stays in it, Esc gives focus back to its chip),
18
+ and on a phone a tap no longer leaves a hover tint.
19
+ - Web: a delegate report's code blocks get copy buttons, like an answer's. On a phone or touch screen a code
20
+ block's copy button sits in a strip above the first line, not over it.
21
+ - Web: a phone's session bar shows ⚙ on first view, right after the status.
22
+ - Web: the open ⚙ panel's running-turn note follows a turn starting or ending.
23
+ - Web: a markdown delegate report in the running turn's stage reads as one plain row.
24
+ - A Verify or Revise sent during a running turn always queues behind it (it no longer cuts the model's thinking
25
+ for a line that waits anyway); the page says it queues.
26
+ - Guardrails: a write to the chi shim asks, and only you can allow it, like home.json. A pair turn's plugin tool
27
+ call that names no command or path asks the owner.
28
+
29
+ ### Fixed
30
+
31
+ - Web: other viewers of a Verify turn no longer see a Revise offer on its answer until they reload.
32
+ - Web: a stale `?view=` (the removed turn view's) leaves the address bar.
33
+ - Desktop: the helper checks the chi home the helper's chi actually reads.
34
+
35
+ Update with `chi update`, then restart `chi web`.
36
+
37
+ ## [0.136.1] - 2026-10-30
38
+
39
+ ### Changed
40
+
41
+ - Web: collapsibles keep their shape. A steps block, a thinking block, the recap and a long answer's fold are the
42
+ same box open or closed (the bar, the border, the width); a click changes the height, animated unless the system
43
+ prefers reduced motion. A closed answer fold is its box's one line, "▸ first line · N lines"; open, the row is the
44
+ box's top edge, "▾ N lines", and no longer repeats the answer's first line above the answer (a delegate report's
45
+ likewise, in the report's tint).
46
+
47
+ Update with `chi update`, then restart `chi web`. `chi home doctor` says what else to do.
48
+
10
49
  ## [0.136.0] - 2026-10-30
11
50
 
12
51
  ### Changed
@@ -5218,7 +5257,9 @@ and long-lived sessions.
5218
5257
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5219
5258
  hotkey panel that send selected text or the clipboard to your sessions.
5220
5259
 
5221
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.136.0...HEAD
5260
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.137.0...HEAD
5261
+ [0.137.0]: https://github.com/dm1try/samagotchi/compare/v0.136.1...v0.137.0
5262
+ [0.136.1]: https://github.com/dm1try/samagotchi/compare/v0.136.0...v0.136.1
5222
5263
  [0.136.0]: https://github.com/dm1try/samagotchi/compare/v0.135.0...v0.136.0
5223
5264
  [0.135.0]: https://github.com/dm1try/samagotchi/compare/v0.134.0...v0.135.0
5224
5265
  [0.134.0]: https://github.com/dm1try/samagotchi/compare/v0.133.0...v0.134.0
data/docs/architecture.md CHANGED
@@ -258,10 +258,10 @@ higher-level events so UIs get clean turn boundaries without inferring them:
258
258
  - `:turn_started` — `{ session_id:, prompt:, turn_id:, cut_after: }` (`cut_after`: the seconds a cut waits for this
259
259
  model's thinking, `0` = never; plus `continue: true` for a continue turn, `resumed: true` for a step-limit Continue,
260
260
  `images:` with the prompt's image refs)
261
- - `:turn_completed` — `{ result:, turn_summary:, display_pending: }` (`result`: the final
261
+ - `:turn_completed` — `{ turn_id:, result:, turn_summary:, display_pending: }` (`result`: the final
262
262
  `LLM::ModelResult`; Engine event (clients get it without `result`); `display_pending`: `after_turn` hooks may still
263
263
  present the answer)
264
- - `:turn_canceled` — `{ cancellation_reason:, cancelled_by:, duration_ms: }` (`cancelled_by`: the bundle or hook label that
264
+ - `:turn_canceled` — `{ turn_id:, cancellation_reason:, cancelled_by:, duration_ms: }` (`cancelled_by`: the bundle or hook label that
265
265
  stopped it, for `cancellation_reason: :hook`; else nil)
266
266
 
267
267
  These are the Engine's raw events. The worker's SSE projection omits `call` from
data/docs/cli.md CHANGED
@@ -1023,15 +1023,20 @@ hides it for that browser (`chi_markdown_tip_dismissed` in localStorage).
1023
1023
  Every prompt and answer has a copy button (on hover; always shown, dimmed, on a
1024
1024
  touch screen), and so does each code block of a rendered answer. An answer
1025
1025
  copies its Markdown source, not the rendered text; a code block copies just
1026
- its code; a prompt copies the text as you typed it.
1026
+ its code; a prompt copies the text as you typed it. On a phone or a touch
1027
+ screen a code block's button sits in a strip above its first line, clear of
1028
+ the code.
1027
1029
 
1028
1030
  A long answer (over 25 lines or 2,000 characters of its text) can be folded
1029
- by hand: a slim "▾ N lines" row sits just above it once its turn has ended.
1030
- Click it and the answer folds to one row, "▸ its first line · N lines" (the
1031
+ by hand: a "▾ N lines" row is its top edge once its turn has ended. Click
1032
+ it and the answer closes to one line in its own box,
1033
+ "▸ its first line · N lines" (the
1031
1034
  first line of what a hook presented in its place, else its first heading,
1032
1035
  else its first line); click again to unfold. A delegate report's reply
1033
- folds the same way. Folding is this browser's choice, kept per session in
1034
- localStorage (`chi_answer_fold:<session id>`, the last 200 folded answers;
1036
+ folds the same way, and so does a long prompt you sent (over the same
1037
+ threshold; its folded row shows its first line as typed). A delegate
1038
+ report itself starts folded. Folding is this browser's choice, kept per session in
1039
+ localStorage (`chi_answer_fold:<session id>`, the last 200 folded answers and prompts;
1035
1040
  the keys of sessions gone from the list are dropped when the list loads);
1036
1041
  other viewers and the participant page are not affected. A jump into a
1037
1042
  folded answer (a claim link, say) unfolds it for that page load.
@@ -1049,7 +1054,11 @@ steps and its answer, all behind it (a warn card, a verdict card and the
1049
1054
  recap stay on view). Click the stub to unfold that version in place (kept per
1050
1055
  browser, with the folded answers); a time divider between two folded stubs
1051
1056
  moves into the later stub. The newest version has a chip, "v3 · 2 earlier ▾",
1052
- listing every version by its prompt; pick one to jump to its stub. Revise
1057
+ listing every version by its prompt; pick one to jump to its stub. Before
1058
+ that chip, a "‹ 3/3 ›" switcher reads an earlier version's answer in the
1059
+ newest one's place (its left edge dashed while it shows; Copy copies the
1060
+ version on view); › back to the newest. Switching only changes what this
1061
+ page shows, and a reload shows the newest again. Revise
1053
1062
  again on the newest version adds to the stack. Stacks are session data (the
1054
1063
  stamp is on the Revise prompt, see [sessions.md](sessions.md), Revise
1055
1064
  stacks): every owner's page shows them, live and after a reload; the
data/docs/guardrails.md CHANGED
@@ -4,7 +4,7 @@ Every tool call the model makes passes one check before it runs, in both
4
4
  loops (native and chat hosts). The verdict is **allow**, **ask** or
5
5
  **deny**; the strictest vote wins, and a deny can't be undone by a later
6
6
  voter. Of two asks the first one names the rule, except a rule only the
7
- user may allow (`chi-config`, `chi-home`, `chi-hooks`, `shell-touches-chi`,
7
+ user may allow (`chi-config`, `chi-home`, `chi-shim`, `chi-hooks`, `shell-touches-chi`,
8
8
  `chi-context-cmd`, `chi-broadcast`): it takes over, and only the scopes both asks offer
9
9
  are offered.
10
10
 
@@ -221,7 +221,7 @@ rules off: `guardrails: {disable: [write-outside-repo, git-outside-repo]}`.
221
221
  Whatever the setting, a parent can't allow a call on chi's own files: a
222
222
  `write`, `edit` or `execute` on the config dir (config.yml, installed bundles'
223
223
  rules), the hooks dir or the approval store, by its rule (`chi-config`, `chi-home`,
224
- `chi-hooks`, `shell-touches-chi`), its paths or its command. "Allow once" on a
224
+ `chi-shim`, `chi-hooks`, `shell-touches-chi`), its paths or its command. "Allow once" on a
225
225
  config.yml rewrite would allow everything from then on, so those questions are
226
226
  the user's alone (`user_only`): `chi answer` and the worker refuse the allow
227
227
  with `only the user can allow it`. The same holds for the guardrails bundle's
@@ -285,7 +285,7 @@ Built in, for `write`, `edit` and `memory_write` (symlinks resolved; see
285
285
  - deny: the approval store's dir, installed bundles (`memories/.bundles/`),
286
286
  and [attached context](context.md) sources (`<state dir>/context/`: a
287
287
  source's command runs later, outside the gate; `chi context` changes them);
288
- - ask (once or for the session): `config.yml`, `home.json` (the [chi home](configuration.md#the-chi-home): the Ruby every chi launch boots) and the plain hooks dir.
288
+ - ask (once or for the session): `config.yml`, `home.json` (the [chi home](configuration.md#the-chi-home): the Ruby every chi launch boots), the chi shim (`$XDG_STATE_HOME/samagotchi/bin/chi`, the `chi` every tool runs: `chi-shim`) and the plain hooks dir.
289
289
 
290
290
  `execute` can still reach them; the guardrails bundle asks about shell
291
291
  commands that name them.
@@ -497,7 +497,10 @@ message:
497
497
  `ruby -run -e httpd`, `php -S`, a static server, a script's `listen(`/
498
498
  `HTTPServer`/`createServer`): the owner's browser sends its cookies (chi
499
499
  web's on 127.0.0.1 or the LAN address, kept for every port) to the page
500
- it opens (`PairBoundary::OPENER`, `PairBoundary::LISTENER`);
500
+ it opens (`PairBoundary::OPENER`, `PairBoundary::LISTENER`). The list
501
+ names generic servers only: a project's dev server (`npm run dev`, `npx
502
+ vite`, `bundle exec rails server`) runs and listens until the execute's
503
+ limit kills it (above);
501
504
  - a program whose name the shell expands: a `$var`, a `$(…)` or a glob in a
502
505
  command's name (`bin/ch? …`, `e=ch; ${e}i …`, `$CMD x`), whatever it would
503
506
  turn out to be (`PairBoundary::VERB`);
@@ -552,6 +555,10 @@ share`, `/bin/SH -c`, `NOHUP`: a disk that folds case, macOS's default, runs
552
555
  them as git, chi, sh, nohup; `ShellLex.program_name`), and so is chi or git
553
556
  config in a script's text.
554
557
 
558
+ It asks the owner, fresh and once, before a plugin or MCP tool's call whose
559
+ `targets:` name no command or path (`PairBoundary::UNTARGETED`: a browser
560
+ MCP's navigate, say), since nothing here can tell what it reaches.
561
+
555
562
  And it **asks the owner** before a `write` or `edit` that may land outside the
556
563
  session's project root, and before a command that may write there, every time:
557
564
  the ask is fresh (`Verdict#fresh_ask`: no stored approval answers it, and no
@@ -913,7 +920,7 @@ every surface answers for the same paths):
913
920
  | Primitive | Where | Used by |
914
921
  |---|---|---|
915
922
  | expand (`..` the kernel's way), real, lexical_real, reals | `PathResolve` (`ProtectedPaths` keeps the old names) | every surface |
916
- | in a dir: `under?` (case-folded on a folding disk; `fold: false` exact), `strictly_under?`, `within?` (root resolved too) | `PathResolve` | `under?`: ShellPaths, ReviewPaths, PairCommand, ChildBoundary, SelfProtect's words; `Outside.within?` and RmTargets' `strictly_under?` fold only when the dir exists (a missing session root keeps the case it was typed in); `within?`: ProtectedPaths, ScratchWrites, ParentApprovals, SelfProtect's paths |
923
+ | in a dir: `under?` (case-folded on a folding disk; `fold: false` exact), `strictly_under?`, `within?` (root resolved too) | `PathResolve` | `under?`: ShellPaths, ReviewPaths, PairCommand, ChildBoundary, SelfProtect's words; `Outside.under_folding_existing?` and RmTargets' `strictly_under?` fold only when the dir exists (a missing session root keeps the case it was typed in); `within?`: ProtectedPaths, ScratchWrites, ParentApprovals, SelfProtect's paths, ChiDirs.review (`ProtectedPaths.within?` is the same predicate) |
917
924
  | tmp dirs and "not one the session is in" | `PathResolve.tmp_roots(session:)` (exact case) | Outside (`Outside.tmp_roots` is the list it and Targets read), RmTargets; `touches_chi?` takes all of them |
918
925
  | a shell word's `~`, `$HOME`, XDG dirs, `$TMPDIR` | `PathResolve.expand_word` | ShellPaths::Walk, ShellGitDirs; PairCommand has its own `~`/`$HOME` (`expand_tilde`) |
919
926
  | a program word's name: basename, case-folded on every disk (`GIT`, `/bin/SH`, `ſh`) | `ShellLex.program_name` / `.program?` | PairCommand, ImplicitWriters, SelfProtect's file runners, ShellGitDirs, SharedGit |
@@ -79,7 +79,7 @@ Engine#run_turn(session, prompt, on_event:, max_iterations:, cancel_controller:,
79
79
  │ # {cancellation_reason, cancelled_by, duration_ms} is emitted; before the replace,
80
80
  │ # stamp_opening puts a Verify turn's `verify` and a completed Revise turn's `revises`
81
81
  │ # (re-targeted to its stack's top) on the opening message, metadata never sent, and
82
- │ # :turn_completed carries `verdict` / `revises` when there are
82
+ │ # every end event carries `turn_id`, :turn_completed `verify` / `verdict` / `revises` when there are
83
83
  │ # then the :after_turn hook (status, messages, present: the AnswerDisplay presenter,
84
84
  │ # refs: the AnswerRefs adder)
85
85
  │ # store_answer_display (one replace of the answer: `display` and/or `display_refs`;
@@ -548,9 +548,9 @@ Turn events (the main ones; `Events` in `events.rb` keeps the shared sets):
548
548
  | `:working_set_updated` | after `:generation_started` (the running turn's conversation, the turn's strategy) and once in `end_turn` before the end event (the kept messages), only when the set differs from the last one emitted for the session and is not empty, except one empty event when the last one had items (a rollback emptied the set): a tool-free turn emits none (`Engine#emit_working_set`); between turns `Engine#announce_working_set` announces it under the next turn's strategy (after a `/working-set` op, held behind the command's `command_ran`) | `{items: [{kind, key, state (live\|stale\|forgotten\|dropped), tokens, ops, count, first_id, pinned, has_drop}], at, adds?: [{kind, key, label}]}` (`WorkingSet`, internals/working-set.md; `adds` while the user's adds are pending) |
549
549
  | `:hook_notice` | a hook's or plugin's notify during a turn | `{hook, text, level}` (announced with `between_turns: true` outside a turn) |
550
550
  | `:guardrail_warning` | a guardrails line during a turn | `{message}` |
551
- | `:turn_completed` | normal end | Engine event `{result, turn_summary, display_pending}`, plus `verdict` (a Verify turn's recorded verdict) and `revises` (a Revise turn's stamp, `{turn_id}`) when there are; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
552
- | `:turn_canceled` | Stop, hook stop, Ctrl-C | `{cancellation_reason, cancelled_by?, duration_ms}` |
553
- | `:turn_failed` | an error | `{error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
551
+ | `:turn_completed` | normal end | Engine event `{turn_id, result, turn_summary, display_pending}`, plus `verify: true` (a Verify turn, verdict or not: the page's `data-verify`), `verdict` (a Verify turn's recorded verdict) and `revises` (a Revise turn's stamp, `{turn_id}`) when there are; the Bridge's SSE frame omits `result` (answer remains in `turn_summary.output`) |
552
+ | `:turn_canceled` | Stop, hook stop, Ctrl-C | `{turn_id, cancellation_reason, cancelled_by?, duration_ms}` |
553
+ | `:turn_failed` | an error | `{turn_id, error_class, message, duration_ms, error_kind?, retryable?, host?, summary?}` |
554
554
  | `:answer_display` | after the after_turn hooks (observers only, not the sink) | `{display, refs?}` (display a string, or nil; refs the count of the answer's refs, only when a hook attached some) |
555
555
 
556
556
  A turn's origin is a `TurnOrigin` (`lib/samagotchi/turn_origin.rb`, a `Data.define` of `client_id`, `enqueued_id`
@@ -10,6 +10,10 @@ only library. Pure modules (`*_model.js`, `session_state.js`, `sessions_list.js`
10
10
  and have `node --test` specs under `spec/web/public/` (happy-dom for the DOM ones); the pages as a whole are covered
11
11
  by `npm run e2e` (`spec/e2e/`). `app.js` has no unit spec.
12
12
 
13
+ **`app.js` has a size cap**: `npm run lint` fails past `max-lines` in `eslint.config.js` (counted as `wc -l` counts,
14
+ a little over its size today). A new feature goes into a module of its own, wired from `app.js` in a few lines; a
15
+ refactor that shrinks `app.js` ratchets the cap down with it. Never raise it: extract instead.
16
+
13
17
  **New exports go in new modules.** The static JS is served at unversioned URLs. Today every load revalidates
14
18
  (app.rb `serve_static` sends `Cache-Control: no-cache` with a content ETag, and `sw.js` has no fetch handler), but an
15
19
  open tab still holds the modules it loaded, and a proxy that ignores `no-cache` can serve an old one: a new export
@@ -17,9 +21,9 @@ on an existing module, imported by a new version of another, then finds the old
17
21
  page. A factory dep a new version stops passing is the same hazard the other way round: the store's redraws took
18
22
  `updateInfoBar` and the chip controls out of `stream_handlers.js`'s deps (and `updateInfoBar`/`updateComposerMode`
19
23
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
20
- until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`,
24
+ until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
21
25
  `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
22
- `session_fields.js`, `feature_gates.js`, `revise_stack.js` and `shell_command.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` and `popover_modal.js` are new modules for that reason.
23
27
 
24
28
  ## Two pages, two models
25
29
 
@@ -46,7 +50,7 @@ change.
46
50
  | **`store.current`** (`session_state.js`: `emptySessionState` / `sessionStateFrom`; per field: `session_fields.js FIELDS`) | the open session's fields, about 40 (info bar, chips, verdicts, timing, worker, stream cursor) | `store.update` / `setStatus` from app.js, `stream_handlers.js`, `send_turn.js` (`firstPreview`, `owner`), `session_actions.js` (`status`); `forgetSession`'s `replaceCurrent`. Two writers in place, on purpose (D8 in `FIELDS`): live_timing.js's `activeTurn`, verdict_fresh.js's running list | the store runs the field's redraws (`FIELDS[key].redraws`) |
47
51
  | **`store.sessions`** (the hub's projection) | every card | `store.update({sessions})` from app.js: `openSessionEvents` (`applySessionEvent`), `refresh` (the list fallback), `addCreatedSession`, `applyView` (re-sorted), `replaceSelectedCard` (`setLiveStatus` through `setStatus`, `readTurnEnd`, `setUsedMemories`), the stream's close (`withoutWorker`); `session_actions.js` (`setSessions`) | the store: `lists` and `infoBar`. **Always a new array**; an entry is replaced, never changed in place. `followSelectedSession` copies the open one's owner and worker (`fieldsFromCard`) and, when not streaming, its status (`setStatus(…, "hub")`) into `store.current` |
48
52
  | **Factory closures**: the `create*` factories (popovers, panels, views) | each component's own state: open-for, busy, error, its own fetched list | the factory | an `onChange` hook back into app.js, or a getter the factory reads at draw time |
49
- | **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), kept command bubbles, which texts were shown, unnamed badges, folded answers (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
53
+ | **The history DOM** | the conversation as drawn: which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), kept command bubbles, which texts were shown, unnamed badges, folded answers and long prompts (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`); the switcher's version on view is `revise_stack.js`'s own (a copy `.stack-view` before the top answer, the top `.stack-swapped`; never stored) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` and `verify` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
50
54
  | **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
51
55
  | **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
52
56
 
@@ -104,10 +108,11 @@ a store write. A value written unchanged redraws nothing (a chunk with the same
104
108
  An input the store doesn't hold calls `store.touch(name)` where it changes: `infoBar` for the clock (liveTurn's
105
109
  `onTick`), the versions (`noticeVersions`), `restartingIds`, the stream (`startStream`, `workerGone`), the working
106
110
  set's and the participants' `onChange`, the continue and review in-flight sets
107
- (`session_actions.js`); `composer` and `infoBar` for `liveTurn.running` (`setTurnRunning`) and at the end of
108
- `renderSessionView` (a whole view drawn). Left as they are, on purpose: the `narrowScreen` listener calls
109
- `updateComposerMode()` (the bar doesn't read it), `applyStripHidden`/`applyView` call `updateSessionTitle()`, and
110
- `closeStream`/`stream.restart` set `streamControl = null` without a redraw (a resync or a workerGone follows).
111
+ (`session_actions.js`); `composer` and `infoBar` for `liveTurn.running` (`setTurnRunning`, with `settingsPanel`:
112
+ the open ⚙ panel's running-turn note) and at the end of `renderSessionView` (a whole view drawn). Left as they are,
113
+ on purpose: the `narrowScreen` listener calls `updateComposerMode()` (the bar doesn't read it),
114
+ `applyStripHidden`/`applyView` call `updateSessionTitle()`, and `closeStream`/`stream.restart` set
115
+ `streamControl = null` without a redraw (a resync or a workerGone follows).
111
116
 
112
117
  A function that writes more than once batches its synchronous part, so each redraw runs once: `select` (before its
113
118
  `await resync()`), `clearSelection`, the hub's `snapshot` and `session` events, `workerGone`, `setTurnRunning`,
@@ -197,8 +202,13 @@ Three, for one job. Pick by what the piece is:
197
202
 
198
203
  Listeners: every factory is a page-lifetime singleton, so `addEventListener` without a remove is fine; a per-render
199
204
  listener lands on a fresh node. A popover goes through `chip_popover.js createChipPopover` (its own outside-click,
200
- Esc, placement and resize); `context_chips.js`, `model_picker.js`, `ref_menu.js`, `command_list.js` and the lists'
201
- families/chains popovers still hand-roll theirs.
205
+ Esc, placement and resize); `context_chips.js` and the lists' families/chains popovers (`families_view.js
206
+ createFamilyPop`) still place their own. Every popover behaves the same (`popover_modal.js`): a modal dialog
207
+ (`modalDialog`: `aria-modal`, Tab wraps inside it), the opener's `aria-expanded` (a lit-drawn chip writes
208
+ `aria-expanded="false"` statically; the shell flips it), Esc closes it and focus goes back to the opener
209
+ (`focusBack`), and its `:hover` tints (the opener's too) sit in `@media(hover:hover)`, so a tap on a phone leaves
210
+ none behind. `model_picker.js`, `command_list.js` (listboxes: the focus stays in the input), `ref_menu.js` (a menu)
211
+ and `update_sheet.js` (a native modal `<dialog>`) aren't popovers in that sense.
202
212
 
203
213
  ## Stale answers
204
214
 
@@ -256,7 +266,7 @@ reach another device or the server.
256
266
  | `chi_markdown_tip_dismissed` | `markdown_tip.js` | the markdown tip dismissed |
257
267
  | `chi_update_available_shown` | app.js | update notices already shown |
258
268
  | `chi_notify`, `chi_push_device` | `notifications.js` (an injected storage) | the bell's state, this device's push id |
259
- | `chi_answer_fold:<session>` | `answer_fold.js`, `revise_stack.js` | the answers folded by hand, and the Revise stubs unfolded by hand (`stub:<turn id>`) (swept with the session list) |
269
+ | `chi_answer_fold:<session>` | `answer_fold.js`, `revise_stack.js` | the answers and long prompts (`<turn id>:prompt`) folded by hand, and the Revise stubs unfolded by hand (`stub:<turn id>`) (swept with the session list) |
260
270
  | `chi_review_history:<grant>`, `chi_review_history_session:<session>` | `review_history.js` (participant page) | a participant's composer history |
261
271
 
262
272
  sessionStorage: `chi_client_id` (`turn_events.js`: this tab's client id, kept over a reload mid-turn) and
data/docs/sessions.md CHANGED
@@ -6,7 +6,7 @@ Sessions are plain files — no DB. Each session is `~/.local/state/samagotchi/s
6
6
 
7
7
  **Verify verdicts:** a web Verify turn stamps its opening user message with `verify: {turn_id, quote}` (the selected source turn and quote; metadata only, never added to the prompt). When that turn completes and the final assistant reply's last non-empty line is a valid `Verdict: confirmed|refuted|unclear — <reason>`, the session file appends `{anchor: {turn_id, quote}, verdict, reason, by, check_turn_id, at}` to its optional `verdicts` array. `by` is the turn's author (nil for the owner), `check_turn_id` names the Verify turn, and `at` is its timestamp. The array is append-only; older files without it have no saved verdicts. An `unclear` verdict can be checked again by **Verify fresh** (a delegate child that gets only the claim; see *Delegating*): its verdict is appended to the parent's `verdicts` with `fresh: true`, `of` (the verdict it checked again) and `child_id`, from the parent's `fresh_verdicts/` dir, which its worker takes between turns. Verdicts are display state: none enters a prompt.
8
8
 
9
- **Revise stacks:** a web Revise turn (POST /turn `revises: {turn_id}`, the answer it rewrites) stamps its opening message (its prompt) with `revises: {turn_id}` at the turn's end, only when the turn completed with an answer (`AnswerTail`): a failed, canceled or empty turn revised nothing (`Engine#stamp_opening`). A draft no prompt carries (a stale page, a rolled-back turn) is no target: no stamp. A draft a stamped turn already revised is re-targeted to its stack's top, the newest version, so a stack is a chain `v1 ← v2 ← v3`. Like `verify`, it is metadata only, never added to the prompt (no change to a request's bytes); chi web's history passes it on the opening message (and on a wake turn's note), and `:turn_completed` carries the stamped `revises` (the participant view's too). The history also marks a Verify turn's opening `verify: true`: the page offers no Revise on its answer. The web page draws the stacks from these stamps (docs/cli.md, Revise). Like a Verify turn's, a Revise message waits for the running turn's end (`SessionInbox.turn_end_data?`), is sent only to a worker whose `bridge.json` names the `revise` feature (else 409 `revise_unavailable`), and a failed turn gives it back (`:prompt_restored`'s `revises`). Participants can't Revise: chi web and the Bridge drop it from their turns. Both anchors travel as one `TurnAnchors` inside chi; on the wire and in an input file each keeps its own key (`verify`, `revises`).
9
+ **Revise stacks:** a web Revise turn (POST /turn `revises: {turn_id}`, the answer it rewrites) stamps its opening message (its prompt) with `revises: {turn_id}` at the turn's end, only when the turn completed with an answer (`AnswerTail`): a failed, canceled or empty turn revised nothing (`Engine#stamp_opening`). A draft no prompt carries (a stale page, a rolled-back turn) is no target: no stamp. A draft a stamped turn already revised is re-targeted to its stack's top, the newest version, so a stack is a chain `v1 ← v2 ← v3`. Like `verify`, it is metadata only, never added to the prompt (no change to a request's bytes); chi web's history passes it on the opening message (and on a wake turn's note), and `:turn_completed` carries the stamped `revises` (the participant view's too). The history also marks a Verify turn's opening `verify: true`, and so does its live end event (`:turn_completed`'s `verify: true`, with its `turn_id`): the page offers no Revise on its answer. The web page draws the stacks from these stamps (docs/cli.md, Revise). Like a Verify turn's, a Revise message waits for the running turn's end (`SessionInbox.turn_end_data?`; the Bridge takes either as `delivery: queue`, so it never cuts and its ack and `:turn_enqueued` say it queues), is sent only to a worker whose `bridge.json` names the `revise` feature (else 409 `revise_unavailable`), and a failed turn gives it back (`:prompt_restored`'s `revises`). Participants can't Revise: chi web and the Bridge drop it from their turns. Both anchors travel as one `TurnAnchors` inside chi; on the wire and in an input file each keeps its own key (`verify`, `revises`).
10
10
 
11
11
  **Input files:** a message for a session's worker waits in `input/<timestamp>.json` (`SessionInbox.write_input`: `prompt`, `client_id`, `enqueued_id`, and `images`, `no_interrupt` and `delivery` when they apply; `delivery` is written only for `"queue"`). While a turn runs, the worker merges them at each step boundary in name order, except a queued one or one with images: those wait for the turn's end and run one turn each, in name order, and a file after them still merges (`Worker#pending_input_drain`). Such a file, with a prompt, from the user, `chi send` or a delegating parent, also ends a running `task_wait` (`SessionInbox.steer_waiting?`, a peek the worker's turn polls through `Engine#input_waiting?`; never in a participant's turn): the file stays for the drain. A queue is sent only to a worker whose `bridge.json` names the `delivery` feature; an older one would steer it, so `SessionManager.deliver_turn` refuses it (409 `delivery_unavailable`), and any other 4xx the Bridge answers is a refusal too, never a fallback file (except a 413, a body over its size limit: that one still goes in as a file, its `delivery` kept). A session command recognized in a file runs without a turn; its outcome is kept at `input/settled/<enqueued_id>` (`completed`, `failed` for a command error, `canceled` with `cancel_reason` `busy` or `dropped` for one refused mid-turn or dropped as the worker left; `origin: command`) so `chi send --wait` ends instead of waiting for a turn that will not run, its detail saying `the command failed` or `the command was refused (busy)` rather than a turn's words. **Reply files:** a turn that ends with visible text leaves its reply in `output/<timestamp>.txt`, or `output/<timestamp>-<enqueued_id>.txt` when the turn ran an input file with an id (`SessionInbox.write_output`), and the session's `last_turn` keeps that `enqueued_id`; so a wait for one queued message (`chi send --queue --wait`, `ReplyWait`'s `turn:`) finds its own turn's reply or end, never another turn's. The names still sort by time, and every reader takes `*.txt`.
12
12
 
data/docs/testing.md CHANGED
@@ -22,7 +22,8 @@ with two rules only, `no-undef` (a name used but never imported or declared: a d
22
22
  style pass: add a rule only for a bug class it would have caught. It runs in about 2 s, in CI's `npm test` job and in
23
23
  `rake release:check`; `rake lint` stays RuboCop alone (CI's rubocop job has no node). A name that is also a browser
24
24
  global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
25
- It also checks `spec/e2e/` for one thing, the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
25
+ It also caps `app.js`'s size (`max-lines`; see docs/internals/web-frontend.md).
26
+ It also checks `spec/e2e/` for two things: `no-unused-vars`, and the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
26
27
  Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); time the page
27
28
  keeps (a pointer's dwell, a hold) runs on Playwright's `page.clock` (`install`, then `pauseAt` and `runFor`: the
28
29
  stage's hand-off dwell in stage.spec.js); a pause that is the behaviour outside the page (a slow post held in a
@@ -1108,6 +1108,10 @@ module Samagotchi
1108
1108
  pending_limit = fetched(parsed, "pending_limit")
1109
1109
  pending_limit = nil unless pending_limit.is_a?(Integer) && pending_limit.positive?
1110
1110
  end
1111
+ # A Verify or Revise turn is a turn of its own too (the worker holds
1112
+ # it for the turn's end, SessionInbox.turn_end_data?): never a cut,
1113
+ # and the ack and :turn_enqueued say it queues behind a running turn.
1114
+ delivery = Delivery::QUEUE unless anchors.empty?
1111
1115
 
1112
1116
  # A session command sent as a message (chi send -m "/model x", chi -p,
1113
1117
  # a UI whose command list lags) runs as the command, as typed in a
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.136.0
3
+ version: 0.137.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -535,7 +535,8 @@ module Samagotchi
535
535
  # Why a launch argv (strings) can't run chi, or nil: its ruby isn't an
536
536
  # absolute path, an absolute path in it is gone, or it boots the chi
537
537
  # home (home.json's ruby) and the home can't boot chi
538
- # (ChiHome.problem).
538
+ # (ChiHome.problem). home.json is the one the helper's chi reads
539
+ # (#launch_config_home), not this process's.
539
540
  def argv_problem(argv)
540
541
  return "the launch file's ruby #{argv[0]} isn't an absolute path: run chi desktop install --force" unless argv[0].start_with?("/")
541
542
 
@@ -546,7 +547,7 @@ module Samagotchi
546
547
  return nil unless home_boot?(argv)
547
548
 
548
549
  home = begin
549
- ChiHome.read
550
+ ChiHome.read(path: ChiHome.path(env: { "XDG_CONFIG_HOME" => launch_config_home }))
550
551
  rescue ChiHome::Invalid
551
552
  nil
552
553
  end
@@ -1627,11 +1627,16 @@ module Samagotchi
1627
1627
  # event]. +save:+ also saves the session (a failed turn: a worker exits
1628
1628
  # after it). The disk writes come after the step: every emitter waits
1629
1629
  # for the event lock. +exhausted:+ the turn ran out of iterations
1630
- # (its limit goes in last_turn).
1630
+ # (its limit goes in last_turn). The end event names its turn
1631
+ # (`turn_id:`, as :turn_started does) and, for a Verify turn,
1632
+ # `verify: true` (its opening's stamp: a page watching it live offers
1633
+ # no Revise on its answer, verdict or not, as a reload does).
1631
1634
  def end_turn(turn, outcome, save: false, exhausted: false)
1632
1635
  seconds = turn.elapsed
1633
1636
  synchronize_events do
1634
1637
  kept, event = yield(seconds)
1638
+ event[:turn_id] = turn.id
1639
+ event[:verify] = true if turn.verify
1635
1640
  revises = stamp_opening(kept, turn, outcome)
1636
1641
  replace_session_messages(turn.session, kept) if kept
1637
1642
  verdict = record_verdict(turn, outcome)
@@ -266,13 +266,15 @@ module Samagotchi
266
266
  require_relative "memory_bundle/provenance"
267
267
  require_relative "context_sources"
268
268
  require_relative "chi_home"
269
+ require_relative "chi_shim"
269
270
  Guardrails::ProtectedPaths.new(
270
271
  store_dir: File.dirname(@approvals.path),
271
272
  bundles_dir: MemoryBundle::Provenance.bundles_dir,
272
273
  config_path: Samagotchi::ConfigFile.global_path,
273
274
  hooks_dir: Hooks::Loader.hooks_dir,
274
275
  context_dir: ContextSources.root,
275
- home_path: ChiHome.path
276
+ home_path: ChiHome.path,
277
+ shim_path: ChiShim.path
276
278
  )
277
279
  end
278
280
  end
@@ -50,11 +50,11 @@ module Samagotchi
50
50
  .map { |dir| PathResolve.real(dir.to_s.chomp("/")) }.reject(&:empty?).uniq
51
51
  end
52
52
 
53
- # chi's state dir and config dir, as Paths has them now, and the hooks
53
+ # chi's state dir and config dir (as .protected finds it), and the hooks
54
54
  # dir when config.yml puts it outside the config dir (hooks run as the
55
55
  # owner: a participant reads none of them).
56
56
  def review
57
- config = File.join(Paths.config_home, Paths::APP_DIR)
57
+ config = ConfigFile.config_dir
58
58
  hooks = begin Hooks::Loader.hooks_dir rescue nil end
59
59
  hooks = nil if hooks.to_s.empty? || PathResolve.reals(hooks).any? { |real| PathResolve.within?(real, config) }
60
60
  [Paths.state_dir, config, hooks].compact
@@ -28,9 +28,9 @@ module Samagotchi
28
28
  session = PathResolve.real(root)
29
29
  tmp = nil
30
30
  reals.any? do |real|
31
- next false if within?(real, session)
31
+ next false if under_folding_existing?(real, session)
32
32
  next false if MemoryBundle::IndexSync.memory_scope(path)
33
- next false if (tmp ||= session_tmp_roots(session)).any? { |dir| within?(real, dir) }
33
+ next false if (tmp ||= session_tmp_roots(session)).any? { |dir| under_folding_existing?(real, dir) }
34
34
 
35
35
  true
36
36
  end
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  def self.inside?(path, root:)
46
46
  reals = resolve(path) or return false
47
47
  session = PathResolve.real(root)
48
- reals.all? { |real| within?(real, session) }
48
+ reals.all? { |real| under_folding_existing?(real, session) }
49
49
  end
50
50
 
51
51
  # Whether a shell command may write +path+ as a tmp file (the child
@@ -57,7 +57,7 @@ module Samagotchi
57
57
  reals = resolve(path) or return false
58
58
  session = PathResolve.real(root)
59
59
  tmp = session_tmp_roots(session)
60
- reals.all? { |real| tmp.any? { |dir| within?(real, dir) } }
60
+ reals.all? { |real| tmp.any? { |dir| under_folding_existing?(real, dir) } }
61
61
  end
62
62
 
63
63
  # +path+ resolved (PathResolve.reals: the lexical answer and where
@@ -91,7 +91,7 @@ module Samagotchi
91
91
  # on a disk that folds case when +root+ exists (resolved, it has its
92
92
  # on-disk case), by their exact text when it doesn't (a missing root
93
93
  # keeps the case it was typed in).
94
- def self.within?(path, root) = PathResolve.under?(path, root, fold: File.exist?(root))
94
+ def self.under_folding_existing?(path, root) = PathResolve.under?(path, root, fold: File.exist?(root))
95
95
  end
96
96
  end
97
97
  end
@@ -100,6 +100,10 @@ module Samagotchi
100
100
  NET = "a participant's turn runs a command chi can't read for sure (git or chi behind a program it doesn't " \
101
101
  "know, or a $(…) kept in a value the shell expands later), so the owner approves it"
102
102
  VERB = "a participant's turn runs only a program named plainly: no $, $(…) or glob in a command's name"
103
+ # A plugin or MCP tool whose targets: name no command or path (a
104
+ # browser MCP's navigate, say): nothing here can tell what it reaches.
105
+ UNTARGETED = "a participant's turn calls a plugin tool chi can't see the targets of (no command or path), " \
106
+ "so the owner approves it"
103
107
  UNREADABLE = "a participant's turn runs only commands chi can read: split this command, or write the file " \
104
108
  "with the write tool"
105
109
  # A pair turn's execute runs without the environment variables whose
@@ -135,7 +139,7 @@ module Samagotchi
135
139
  ParentApprovals::CHI_TEXT,
136
140
  %r{\.local/state/samagotchi|web-grants|grants\.json|web-token},
137
141
  %r{(?:\A|[\s/'"=])\.(?:ssh|gnupg|aws|kube|docker)(?![\w.-])},
138
- %r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)?|\.envrc|\.?mise(?:\.[\w-]+)?\.local\.toml|(?:\.config/mise|\.?mise)/config(?:\.[\w-]+)?\.local\.toml|[._]netrc|\.npmrc|\.pypirc|
142
+ %r{(?:\A|[\s/'"=])(?:\.env(?:\.[\w.-]*)?|\.envrc|#{MisePaths::LOCAL_FILE}|#{MisePaths::LOCAL_DIR_FILE}|[._]netrc|\.npmrc|\.pypirc|
139
143
  \.git-credentials)(?![\w-])}x,
140
144
  /\bid_(?:rsa|dsa|ecdsa|ed25519)\b|credentials|\.(?:pem|p12|pfx|tfstate)\b/
141
145
  )
@@ -199,9 +203,12 @@ module Samagotchi
199
203
  # (LINK), one that runs mise or direnv (ENV_LOADER), a write to mise's
200
204
  # config (MISE_CONFIG), a command chi can't read for sure (NET:
201
205
  # PairCommand#net_ask?), a write that may land outside the project (OUTSIDE_WRITE, or
202
- # NO_PROJECT without one); nil when nothing asks.
206
+ # NO_PROJECT without one), a plugin tool's call that names no command
207
+ # or path (UNTARGETED: Targets#args is set for plugin tools only); nil
208
+ # when nothing asks.
203
209
  def ask_reason(verdict)
204
210
  targets = verdict.targets
211
+ return UNTARGETED if targets&.args && !targets.command && targets.paths.compact.empty?
205
212
  return LINK if targets&.command && command(targets, nil).links?
206
213
  return ENV_LOADER if targets&.command && command(targets, nil).loads_env?
207
214
  return MISE_CONFIG if mise_config_write?(verdict)
@@ -211,7 +218,7 @@ module Samagotchi
211
218
  project_root ? OUTSIDE_WRITE : NO_PROJECT
212
219
  end
213
220
 
214
- # A write or edit of a mise config file (PairCommand.mise_config?), or a
221
+ # A write or edit of a mise config file (MisePaths.config?), or a
215
222
  # command that writes or names one (PairCommand#mise_config_write?).
216
223
  def mise_config_write?(verdict)
217
224
  targets = verdict.targets
@@ -219,7 +226,7 @@ module Samagotchi
219
226
  return command(targets, nil).mise_config_write? if targets.command
220
227
  return false unless WRITE_TOOLS.include?(verdict.call&.dig(:name).to_s)
221
228
 
222
- targets.paths.to_a.compact.any? { |path| PairCommand.mise_config?(File.expand_path(path, targets.cwd || Dir.pwd)) }
229
+ targets.paths.to_a.compact.any? { |path| MisePaths.config?(File.expand_path(path, targets.cwd || Dir.pwd)) }
223
230
  end
224
231
 
225
232
  # A write or edit whose path may land outside the project root, or a
@@ -273,22 +273,6 @@ module Samagotchi
273
273
  Outside.inside?(path, root: root) || Outside.tmp_write?(path, root: root)
274
274
  end
275
275
 
276
- # mise's config files (mise.toml, .mise.toml, mise.<env>.toml, .config/mise.toml,
277
- # anything under .config/mise/, mise/ or .mise/ config*.toml, and the
278
- # .tool-versions it reads too): mise runs their [env] at the next worker
279
- # start (ProjectEnv), and installs the tools they pin.
280
- MISE_CONFIG_PATH = %r{(?:\A|/)(?:\.?mise(?:\.[^/]+)?\.toml|\.config/mise\.toml|\.config/mise/.+|
281
- \.?mise/config[^/]*\.toml|\.tool-versions)\z}x
282
- MISE_CONFIG_TEXT = %r{(?<![\w-])(?:\.?mise(?:\.[\w.-]+)?\.toml|\.config/mise|\.?mise/config[\w.-]*\.toml|
283
- \.tool-versions(?![\w.-]))}x
284
-
285
- # Whether +path+ is one of mise's config files (MISE_CONFIG_PATH), as
286
- # given or as it resolves.
287
- def self.mise_config?(path)
288
- path.to_s.match?(MISE_CONFIG_PATH) ||
289
- (path.to_s.start_with?("/") && PathResolve.reals(path).any? { |real| real.match?(MISE_CONFIG_PATH) })
290
- end
291
-
292
276
  # Whether +path+ is in a .git/hooks folder, as given or as it resolves
293
277
  # (ShellPaths::HOOKS_PART): git runs what is there.
294
278
  def self.git_hook?(path)
@@ -624,7 +608,7 @@ module Samagotchi
624
608
  end
625
609
 
626
610
  # Whether it writes (or, not only reading, names) a mise config file
627
- # (.mise_config?), a script it hands on included: the owner approves
611
+ # (MisePaths.config?), a script it hands on included: the owner approves
628
612
  # it. True for one it can't read.
629
613
  def mise_config_write?
630
614
  refusal
@@ -780,7 +764,7 @@ module Samagotchi
780
764
  reason = if SHELLS.include?(program) || program == "eval"
781
765
  nested_refusal(script)
782
766
  elsif PairCommand.interpreter?(program)
783
- @mise_config = true if script.match?(MISE_CONFIG_TEXT)
767
+ @mise_config = true if script.match?(MisePaths::CONFIG_TEXT)
784
768
  script_text_refusal(script)
785
769
  end
786
770
  return reason if reason
@@ -1184,7 +1168,7 @@ module Samagotchi
1184
1168
  resolved.each do |word, path|
1185
1169
  return PairBoundary::GIT_HOOKS if !@read_only && PairCommand.git_hook?(path || word)
1186
1170
 
1187
- @mise_config = true if !@read_only && PairCommand.mise_config?(path || word)
1171
+ @mise_config = true if !@read_only && MisePaths.config?(path || word)
1188
1172
 
1189
1173
  if path
1190
1174
  return PairBoundary::SECRET if @paths.secret_refusal([path])
@@ -15,7 +15,8 @@ module Samagotchi
15
15
  # command runs later, outside the gate; chi context writes
16
16
  # it); nothing legitimate writes there through the file tools.
17
17
  # ask (once / session): config.yml, home.json (the chi home: the
18
- # Ruby every chi launch boots) and the plain hooks dir. The
18
+ # Ruby every chi launch boots), the chi shim (ChiShim: the
19
+ # `chi` every tool runs) and the plain hooks dir. The
19
20
  # system bundle's config protocol edits config.yml with
20
21
  # write/edit, so this keeps the user in the loop without
21
22
  # breaking it.
@@ -31,7 +32,8 @@ module Samagotchi
31
32
  # @param hooks_dir [String] the plain hooks dir
32
33
  # @param context_dir [String, nil] <state dir>/context (ContextSources.root)
33
34
  # @param home_path [String, nil] home.json (ChiHome.path)
34
- def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil, home_path: nil)
35
+ # @param shim_path [String, nil] the chi shim (ChiShim.path)
36
+ def initialize(store_dir:, bundles_dir:, config_path:, hooks_dir:, context_dir: nil, home_path: nil, shim_path: nil)
35
37
  @deny = [
36
38
  ["guardrail-store", store_dir, "the approval store belongs to the user"],
37
39
  ["installed-bundles", bundles_dir, "installed bundles (hooks, rules, manifests) are changed by chi bundle install"],
@@ -40,6 +42,7 @@ module Samagotchi
40
42
  @ask = [
41
43
  ["chi-config", config_path, "changes chi's config.yml"],
42
44
  ["chi-home", home_path, "changes the Ruby every chi launch boots"],
45
+ ["chi-shim", shim_path, "changes the chi every tool's chi runs"],
43
46
  ["chi-hooks", hooks_dir, "changes chi's hooks"]
44
47
  ].select { |_, root, _| root }
45
48
  end