samagotchi 0.133.0 → 0.135.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +50 -34
  6. data/docs/configuration.md +9 -6
  7. data/docs/guardrails.md +146 -13
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/internals/engine.md +6 -3
  10. data/docs/internals/participants.md +7 -6
  11. data/docs/internals/tool-guardrails.md +2 -0
  12. data/docs/internals/web-frontend.md +262 -0
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +5 -2
  15. data/docs/testing.md +34 -4
  16. data/lib/samagotchi/bridge.rb +11 -6
  17. data/lib/samagotchi/bridge_client.rb +8 -5
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_home.rb +12 -2
  20. data/lib/samagotchi/config.rb +0 -2
  21. data/lib/samagotchi/desktop/macos.rb +19 -4
  22. data/lib/samagotchi/engine.rb +82 -12
  23. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  24. data/lib/samagotchi/guardrails/approval.rb +41 -5
  25. data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
  27. data/lib/samagotchi/guardrails/pair_command.rb +828 -86
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
  30. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
  32. data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
  33. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  34. data/lib/samagotchi/guardrails/targets.rb +8 -3
  35. data/lib/samagotchi/guardrails_command.rb +13 -10
  36. data/lib/samagotchi/home_command.rb +22 -6
  37. data/lib/samagotchi/home_doctor.rb +36 -3
  38. data/lib/samagotchi/home_notice.rb +8 -1
  39. data/lib/samagotchi/hooks/loader.rb +5 -0
  40. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  41. data/lib/samagotchi/log_line.rb +12 -0
  42. data/lib/samagotchi/message_parts.rb +1 -1
  43. data/lib/samagotchi/project_env.rb +3 -11
  44. data/lib/samagotchi/revise_anchor.rb +33 -0
  45. data/lib/samagotchi/self_report.rb +3 -1
  46. data/lib/samagotchi/session_inbox.rb +20 -12
  47. data/lib/samagotchi/session_manager.rb +13 -11
  48. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  49. data/lib/samagotchi/system_one/self_protect.rb +70 -16
  50. data/lib/samagotchi/testing/waiting.rb +13 -0
  51. data/lib/samagotchi/toolkit_binding.rb +16 -0
  52. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  53. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  54. data/lib/samagotchi/tools/execute.rb +6 -6
  55. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  56. data/lib/samagotchi/tools/read.rb +5 -5
  57. data/lib/samagotchi/turn_anchors.rb +49 -0
  58. data/lib/samagotchi/update_command.rb +7 -3
  59. data/lib/samagotchi/update_hint.rb +6 -1
  60. data/lib/samagotchi/verify_anchor.rb +5 -5
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -14
  63. data/lib/samagotchi/web/message_display.rb +17 -1
  64. data/lib/samagotchi/web/participant_access.rb +2 -2
  65. data/lib/samagotchi/web/participant_view.rb +8 -0
  66. data/lib/samagotchi/web/public/activity.js +1 -1
  67. data/lib/samagotchi/web/public/annotations.js +1 -1
  68. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  69. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  70. data/lib/samagotchi/web/public/app.js +462 -338
  71. data/lib/samagotchi/web/public/bubbles.js +3 -3
  72. data/lib/samagotchi/web/public/cards_place.js +9 -4
  73. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  74. data/lib/samagotchi/web/public/claim_link.js +4 -2
  75. data/lib/samagotchi/web/public/composer.css +11 -1
  76. data/lib/samagotchi/web/public/context_chips.js +9 -6
  77. data/lib/samagotchi/web/public/data.js +4 -2
  78. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  79. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  80. data/lib/samagotchi/web/public/history_reveal.js +27 -0
  81. data/lib/samagotchi/web/public/index.html +82 -37
  82. data/lib/samagotchi/web/public/latest.js +46 -0
  83. data/lib/samagotchi/web/public/lists_view.js +15 -13
  84. data/lib/samagotchi/web/public/live_timing.js +2 -0
  85. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  86. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  87. data/lib/samagotchi/web/public/method_api.js +14 -0
  88. data/lib/samagotchi/web/public/notifications.js +4 -11
  89. data/lib/samagotchi/web/public/participant.js +11 -6
  90. data/lib/samagotchi/web/public/participant_names.js +1 -1
  91. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  92. data/lib/samagotchi/web/public/project_picker.js +18 -22
  93. data/lib/samagotchi/web/public/question_cards.js +2 -2
  94. data/lib/samagotchi/web/public/refs.js +6 -7
  95. data/lib/samagotchi/web/public/restore.js +2 -0
  96. data/lib/samagotchi/web/public/review_start.js +1 -1
  97. data/lib/samagotchi/web/public/revise_stack.js +372 -0
  98. data/lib/samagotchi/web/public/scope.js +12 -20
  99. data/lib/samagotchi/web/public/send_turn.js +29 -16
  100. data/lib/samagotchi/web/public/session_actions.js +15 -14
  101. data/lib/samagotchi/web/public/session_fields.js +152 -0
  102. data/lib/samagotchi/web/public/session_payload.js +35 -0
  103. data/lib/samagotchi/web/public/session_state.js +2 -0
  104. data/lib/samagotchi/web/public/session_store.js +191 -0
  105. data/lib/samagotchi/web/public/stage_model.js +4 -4
  106. data/lib/samagotchi/web/public/stage_view.js +11 -16
  107. data/lib/samagotchi/web/public/start_page.js +4 -4
  108. data/lib/samagotchi/web/public/start_settings.js +29 -18
  109. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  110. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  111. data/lib/samagotchi/web/public/task_stop.js +1 -1
  112. data/lib/samagotchi/web/public/timing.js +7 -3
  113. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
  114. data/lib/samagotchi/web/public/turn_events.js +2 -2
  115. data/lib/samagotchi/web/public/turn_html.js +17 -9
  116. data/lib/samagotchi/web/public/turn_model.js +6 -6
  117. data/lib/samagotchi/web/public/turn_range.js +32 -0
  118. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  119. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  120. data/lib/samagotchi/web/public/verify_target.js +7 -10
  121. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  122. data/lib/samagotchi/web/server.rb +4 -4
  123. data/lib/samagotchi/web/session_control.rb +2 -2
  124. data/lib/samagotchi/worker.rb +9 -9
  125. data/lib/samagotchi/worker_inbound.rb +6 -5
  126. metadata +16 -3
  127. data/lib/samagotchi/web/public/hold.js +0 -78
data/docs/sessions.md CHANGED
@@ -6,6 +6,8 @@ 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`).
10
+
9
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`.
10
12
 
11
13
  **Removing a queued message:** a message queued for after the running turn (`chi send --queue`, the web's Alt+Enter, `/queue TEXT` in a terminal), or one with images (it waits for the turn's end too), can be taken back while it still waits: the worker's Bridge (`POST /session/:id/turn/dequeue {enqueued_id}`, the `unqueue` feature) deletes its input file and every UI drops it (`:turn_dequeued`). It is a race with the turn's end, decided by the same rename the worker uses to take the file: once a turn took it the answer is `409 already_started` (it runs), and an id the worker no longer knows is `404 unknown_enqueued_id`. A plain message sent without `--queue` is not removable (`409 not_queued`): a running turn's next step may take it at any moment. A wait for its turn (`chi send --queue --wait`, a delegate follow-up's wait) ends at once with `removed` (the tombstone `input/removed/<enqueued_id>` says so), and a `chi -p` run whose queued prompt went ends with exit 1. The `200` carries the removed `prompt`: the web and the attached terminal put it back into the composer to edit (not its images). The images a removed message named stay in the session's `images/`. Not removable: a queued session command (`/queue /model x`: it waits in the worker, not in `input/`), a message in a stopped worker's inbox (the next worker runs it), and the plain REPL's (`--no-shared`) own queue.
@@ -396,8 +398,9 @@ mise.toml pins Ruby 3.4 gets that `ruby`, `gem` and `bundle` in its tools while
396
398
  - **Pair turns** (a participant's, see [Participants](internals/participants.md)) run their `execute` without the
397
399
  project env. MCP servers are the session's, started once in the project env, so a pair turn's MCP calls do use
398
400
  it. A participant's command that runs `mise` or `direnv`, and any write to mise's config (`mise.toml`,
399
- `.mise.toml`, `mise.<env>.toml`, `.config/mise.toml`, `.config/mise/…`, `mise/` or `.mise/config*.toml`: your
400
- next worker start runs it, and its `[env]` can run commands), asks you first. `.envrc` and mise's local files
401
+ `.mise.toml`, `mise.<env>.toml`, `.config/mise.toml`, `.config/mise/…`, `mise/` or `.mise/config*.toml`, and
402
+ `.tool-versions`: your next worker start runs it, and its `[env]` can run commands), a delete or move of one
403
+ included, asks you first; a read-only mention (`find . -name mise.toml`) doesn't. `.envrc` and mise's local files
401
404
  (`mise.local.toml`, `mise.<env>.local.toml`, dotted or not, and `config.local.toml` / `config.<env>.local.toml` in
402
405
  `.config/mise/`, `mise/`, `.mise/`) are secrets to them, as `.env` is.
403
406
  - **What tools still drop**: every `execute`, task and `!cmd` takes no `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`,
data/docs/testing.md CHANGED
@@ -24,7 +24,9 @@ style pass: add a rule only for a bug class it would have caught. It runs in abo
24
24
  global (`name`, `status`, `open`…) passes even when its import is gone: give an export such a name only with care.
25
25
  It also checks `spec/e2e/` for one thing, the Playwright twin of `Samagotchi/SpecSleep`: no `page.waitForTimeout`.
26
26
  Wait for the state (a locator's `expect`, `data-phase`, an animation's end through `waitForFunction`); a pause that
27
- is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`.
27
+ is the behaviour (a pointer's dwell) carries `// eslint-disable-line no-restricted-syntax -- why`. And in the six
28
+ modules that hold the owner page's session state, one more: no write into `current`, `store.current` or `state()`
29
+ in place (`samagotchi/session-writes`; the store is the write path, see internals/web-frontend.md, The session store).
28
30
 
29
31
  CI's e2e job keeps the tests, chi and the fake model on the runner but runs Chromium in Playwright's image
30
32
  (`mcr.microsoft.com/playwright:v<version>-noble`, `playwright run-server` on the host network); the tests connect
@@ -41,7 +43,7 @@ paths (replace them with placeholders), no PATH tools (the suite pins `rg` as fo
41
43
  that sets PATH to test the lookup is tagged `:real_rg`), no Ruby-version-dependent `inspect` (a Hash's or an
42
44
  exception's text changes between Ruby versions: write the string yourself).
43
45
 
44
- The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_view.js`,
46
+ The web specs run in plain node. One that loads lit-html, itself or through a module it imports (`turn_block.js`,
45
47
  `turn_html.js`, `card.js`, `copy.js`, `images.js`, `scope.js`, `command_view.js`, `diff_view.js`, `read_view.js`,
46
48
  `question_cards.js`, `stream_handlers.js`, `stage_view.js`, `bubbles.js`, `cards_place.js`, the chips, `info_bar.js`, `info_bar_model.js`, `lists_view.js`, `context_chips.js`, `families_view.js`,
47
49
  `chains.js`, `working_set_view.js`, `working_set_rail.js`, `unqueue.js`, `toasts.js`, `notice_links.js`, `start_page.js`, `start_settings.js`, `start_toolkit.js`, `session_actions.js`, `send_turn.js`, `composer_mount.js`, `dock_reserve.js`,
@@ -69,6 +71,28 @@ either router adds its row there; a row where a UI differs on purpose (or a foun
69
71
  `repl` override and a `note`. The Ruby side runs each row with every core command (a new one fails its
70
72
  `CORE_ROUTES` check until listed there); a row's names that core lacks stand for plugin commands.
71
73
 
74
+ ## Levels
75
+
76
+ Each level owns its own share; a detail is asserted once, at the lowest level that has it.
77
+
78
+ | Level | Ruby | Web | Owns |
79
+ |---|---|---|---|
80
+ | Unit | a class, its collaborators stubbed or passed in | `npm test` (node; happy-dom for lit) | the details and the variants: a message's text, each refusal, the edge cases |
81
+ | Functional | a component through its real seams in one process (an Engine on `test_kernel`, a real Bridge, `Web::App` on Rack) | chi web's routes (`spec/web/`, Rack) | the component's happy path and its main negative paths, and the payload it hands the next layer |
82
+ | Process / e2e | a real `bin/chi`, worker or script (`run_chi`, a spawned process) | Playwright (`npm run e2e`) | one run per mechanism (the wiring a lower level can't see: a require, a spawn, a real browser), and a few whole journeys |
83
+
84
+ - **The variants are the units'.** A higher-level spec says which mechanism it is the one test of and where its
85
+ variants live (`remote_attach_stream_spec.rb`, `steer_delivery_e2e_spec.rb`, `terminal_ui/attached_loop_bridge_spec.rb`).
86
+ - **One real chi per mechanism** (spec/support/chi_cli.rb): a file keeps one `run_chi` per mechanism and forks the
87
+ rest (`fork_chi`). A script that runs chi (a bundle's `scripts/pair`) likewise: its mechanism on real chi, its
88
+ variants on a fake one (`FakeChiContext`).
89
+ - **A spec that spawns per assertion gets a seam.** A unit spec that starts a process for every check (a `git`, a
90
+ Ruby boot) passes the collaborator in or fakes it: a guardrails `Context` takes `git:`, so give it one `GitInfo`
91
+ per example, as the wiring keeps one per turn.
92
+ - **e2e** is for real-browser behaviour (layout, focus, reload, two tabs), one happy path per Ruby↔web mechanism and
93
+ a few journeys. Before adding a test, extend an existing journey's step; a variant goes to node, a Ruby↔web shape
94
+ variant to a shared fixture (`spec/fixtures/composer_routes.json`).
95
+
72
96
  ## Waiting and time
73
97
 
74
98
  A spec waits *for* something and never sleeps to wait: a fixed pause is too short on a loaded runner (a parallel
@@ -95,6 +119,9 @@ run, CI) and too long on a fast one. The contract a new spec follows:
95
119
  4. **"Nothing happens" is shown by counting passes** (the worker's loop rounds, spec/support/worker_loop_rounds.rb;
96
120
  a class's ticks), not by a pause.
97
121
  5. **A wall-clock bound** only as "not the full N s", with at least 5× headroom measured on the slow (x86) laptop.
122
+ Write it `be < time_bound(3, below: 5)`: the bound scaled like a helper's deadline, and kept under the unscaled
123
+ deadline it tells apart from (here a 5 s read timeout), so the check still fails on the full wait at any scale. A
124
+ cost bound with nothing to tell apart from ("no quadratic blow-up") takes no `below:`.
98
125
 
99
126
  The helpers are the bundle test kit's (`Samagotchi::Testing::Waiting`, [plugins.md](plugins.md#testing-a-bundle-experimental)),
100
127
  so core and bundle specs share one copy; `SpecWaiting` (spec/support/waiting.rb) includes them in every example
@@ -107,6 +134,7 @@ group and also answers them as module calls (`SpecWaiting.pop!`, for a stubbed l
107
134
  same deadline and message. A gate a stubbed lib thread waits on takes `timeout: 30`: it only ends a hang.
108
135
  - `wait_until(timeout:) { … }` returns the block's last value (falsy at the deadline): for a value the example
109
136
  checks after, or a `wait-ok` wait.
137
+ - `time_bound(seconds, below: nil)`: a wall-clock upper bound, scaled and kept under `below:` (rule 5).
110
138
  - `eventually(timeout: 5) { expect(…) }` (spec only) retries until the expectations hold, then re-raises the last
111
139
  failure prefixed `still failing after 5 s:`, so the matcher's own expected/got is in the message.
112
140
 
@@ -129,8 +157,8 @@ Every example also runs under a 120 s limit (`spec_helper`'s `around`; `integrat
129
157
  limit, `0` turns it off (a debugger session). The limit is not scaled.
130
158
 
131
159
  **The cops.** `Samagotchi/SpecSleep` (an untagged `sleep` in a spec) and `Samagotchi/SilentWait` (a `wait_until`
132
- whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`, or an untagged
133
- `Timeout.timeout`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
160
+ whose value is dropped, an example's last line included, `expect(wait_until { … }).to be(true)`, an untagged
161
+ `Timeout.timeout`, or a statement `thread.join(N)`, whose N is unscaled and whose nil is dropped: use `join!`) live in `rubocop/` and run in `rake lint`, so CI fails on them. RSpec counts `wait_for`,
134
162
  `eventually`, `join!` and `pop!` as an example's expectation (`RSpec/NoExpectationExample`), so a wait can be an
135
163
  example's last line. What the cops can't see, `script/affected-specs` hints at (`timing:`, below).
136
164
 
@@ -180,10 +208,12 @@ any other file requires it, so a spec file run alone still finds its requires. E
180
208
  | `env.rb` | *helper*: `with_env` sets ENV for a block and puts it back (nil unsets) |
181
209
  | `failing_saves.rb` | shared context "failing session saves": `Session#save` raising ENOSPC while `saves_fail`, and the worker's `save_failed` records |
182
210
  | `fake_chat_adapter.rb` | `FakeChatAdapter`: an `LLM::OpenAIChat` stand-in answering `#chat` with queued steps, recording each request |
211
+ | `fake_chi_context.rb` | `FakeChiContext`: a fake `chi context add/show/push/rm/ls` for the peer bundles' `scripts/pair` variants, logging each call |
183
212
  | `fake_executables.rb` | `FakeExecutables`: fake commands (`gh`, `git`) for a spec's PATH, symlinks to one launcher (macOS's first-exec check is paid once) |
184
213
  | `fake_provider_server.rb` | `FakeProviderServer`: an in-process HTTP provider replaying spec/fixtures/providers, recording requests |
185
214
  | `fake_tailscale.rb` | A fake `tailscale` for `Web::Tunnel::Tailscale` (status, serve, lock from JSON files; argv.log) |
186
215
  | `git_repo.rb` | *helper*: `git_repo`, a temp folder with `git init`, removed after the example |
216
+ | `lock_holder.rb` | *helper*: `spawn_lock_holder(session_dir)`, a stand-in worker process holding a session's owner lock, which says so on a pipe |
187
217
  | `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
188
218
  | `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
189
219
  | `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
@@ -11,7 +11,7 @@ require_relative "atomic_file"
11
11
  require_relative "bridge_secret"
12
12
  require_relative "delivery"
13
13
  require_relative "session_inbox"
14
- require_relative "verify_anchor"
14
+ require_relative "turn_anchors"
15
15
 
16
16
  require_relative "bridge/bounded_queue"
17
17
  require_relative "bridge/card_store"
@@ -634,7 +634,7 @@ module Samagotchi
634
634
  # turn (Guardrails::PairBoundary); a worker without it runs it as a
635
635
  # review turn, and chi web says so (Delivery::PAIR_FEATURE).
636
636
  FEATURES = ["restart", "task_stop", "reparent", Delivery::QUEUE_FEATURE, ReplyWait::TURN_IDS_FEATURE,
637
- VerifyAnchor::FEATURE,
637
+ VerifyAnchor::FEATURE, ReviseAnchor::FEATURE,
638
638
  SessionInbox::UNQUEUE_FEATURE, Delivery::REVIEW_FEATURE, Delivery::PAIR_FEATURE].freeze
639
639
 
640
640
  # The routes, by method and what follows /session/:id/: a handler takes
@@ -1073,7 +1073,10 @@ module Samagotchi
1073
1073
  # request's principal; the Bridge is loopback and trusts its caller).
1074
1074
  author = fetched(parsed, "author")
1075
1075
  author = nil unless author.is_a?(String) && !author.empty?
1076
- verify = VerifyAnchor.from(fetched(parsed, "verify"))
1076
+ # A Verify turn's claim, a Revise turn's draft; a participant can't
1077
+ # Revise (chi web drops it first, this is the worker's own layer).
1078
+ anchors = TurnAnchors.from(parsed)
1079
+ anchors = anchors.without_revises if Steer.participant_author?(author)
1077
1080
  if sid.to_s.strip.empty? || prompt.to_s.strip.empty?
1078
1081
  return [{ "Allow" => "POST" }, 400,
1079
1082
  { error: "missing_fields", detail: "session_id and prompt are required" }]
@@ -1135,7 +1138,7 @@ module Samagotchi
1135
1138
  delivery = Delivery::QUEUE if @accumulator.participant_turn?
1136
1139
  queued_behind_turn = Delivery.queue?(delivery) && !@accumulator.current_turn.nil?
1137
1140
  enqueue_turn(prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1138
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, verify: verify).tap do |ok|
1141
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, anchors: anchors).tap do |ok|
1139
1142
  next unless ok
1140
1143
 
1141
1144
  enqueued_event = { type: :turn_enqueued, enqueued_id: enqueued_id, client_id: client_id, prompt: prompt.to_s }
@@ -1299,9 +1302,11 @@ module Samagotchi
1299
1302
 
1300
1303
  # Write a turn into this session's input dir, reusing the file IPC the
1301
1304
  # worker polls. Never calls run_turn across the boundary.
1302
- def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil, verify: nil)
1305
+ def enqueue_turn(prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [], delivery: nil, author: nil,
1306
+ anchors: nil)
1303
1307
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1304
- no_interrupt: no_interrupt, images: images, delivery: delivery, author: author, verify: verify)
1308
+ no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
1309
+ anchors: anchors)
1305
1310
  end
1306
1311
 
1307
1312
  # ── HTTP plumbing ────────────────────────────────────────────────────────
@@ -14,8 +14,10 @@ require_relative "bridge_client/event_stream"
14
14
  require_relative "bridge_client/stream_request"
15
15
 
16
16
  module Samagotchi
17
- # Loaded on first use (a Verify turn): keeps the bundle test kit's load
18
- # budget (spec/samagotchi/testing/sandbox_spec.rb).
17
+ # Loaded on first use (any turn's input, a Verify or a Revise turn's
18
+ # anchor): keeps the bundle test kit's load budget
19
+ # (spec/samagotchi/testing/sandbox_spec.rb).
20
+ autoload :TurnAnchors, File.expand_path("turn_anchors", __dir__)
19
21
  autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
20
22
 
21
23
  # Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
@@ -192,16 +194,17 @@ module Samagotchi
192
194
  # owner; sent only when given (SessionManager.deliver_turn)
193
195
  # @param pending_limit [Integer, nil] with a participant's +author+: how
194
196
  # many of their turns may be running or waiting (429 too_many_turns past it)
197
+ # @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
198
+ # turn's draft: their own body keys ("verify", "revises")
195
199
  def post_turn(prompt:, client_id: nil, no_interrupt: false, images: nil, delivery: nil, author: nil, pending_limit: nil,
196
- verify: nil)
200
+ anchors: nil)
197
201
  body = { session_id: @session_id, prompt: prompt, client_id: client_id }
198
202
  body[:no_interrupt] = true if no_interrupt
199
203
  body[:author] = author if author
200
204
  body[:pending_limit] = pending_limit if author && pending_limit
201
205
  body[:images] = images if images && !images.empty?
202
206
  body[:delivery] = delivery unless delivery.nil? || Delivery.next_step?(delivery)
203
- anchor = VerifyAnchor.from(verify)
204
- body[:verify] = anchor.to_file if anchor
207
+ body.merge!(TurnAnchors.from(anchors).to_file) if anchors
205
208
  body[:deadline] = deadline
206
209
  post("turn", body, read_body: true)
207
210
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.133.0
3
+ version: 0.135.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:
@@ -42,6 +42,15 @@ module Samagotchi
42
42
  class Invalid < StandardError
43
43
  end
44
44
 
45
+ # A record of this process under Bundler (`bundle exec chi home set`)
46
+ # would name the bundle's Ruby and gems as the home every launch boots.
47
+ class UnderBundler < StandardError
48
+ def initialize(msg = "under Bundler (bundle exec) this chi's gem path is the bundle's, not a chi home: " \
49
+ "run chi itself, or name another Ruby with --ruby")
50
+ super
51
+ end
52
+ end
53
+
45
54
  # home.json's fields. +gem_path+ is what `Gem.paths=` takes (dirs joined
46
55
  # by File::PATH_SEPARATOR); +mise+ an absolute path or nil.
47
56
  Record = Data.define(:ruby, :gem_home, :gem_path, :mise, :set_at, :set_by_version) do
@@ -274,8 +283,9 @@ module Samagotchi
274
283
  nil
275
284
  end
276
285
 
277
- # The test UpdateCommand's @bundler makes: a first record from a
278
- # `bundle exec` would name the bundle's gems.
286
+ # Whether this process runs under Bundler (`bundle exec`), where a
287
+ # record of it would name the bundle's gems: record_first!, chi home
288
+ # set (UnderBundler) and chi update's @bundler.
279
289
  def under_bundler?(env = ENV) = !!(defined?(::Bundler) || !env["BUNDLE_GEMFILE"].to_s.empty?)
280
290
 
281
291
  # mise's absolute path: on +env+'s PATH, else in MISE_DIRS; nil without.
@@ -330,8 +330,6 @@ module Samagotchi
330
330
  Entry.new(key: "web.port", yaml_path: %w[web port], type: :integer, default: 4567, expose: %i[env config cli], lifecycle: :web_start),
331
331
  Entry.new(key: "web.host", yaml_path: %w[web host], type: :string, default: "127.0.0.1", expose: %i[env config cli], lifecycle: :web_start),
332
332
  Entry.new(key: "web.markdown", yaml_path: %w[web markdown], type: :bool, default: false, expose: %i[env config cli], lifecycle: :web_start),
333
- # The page's view of a turn: stage (the running turn pinned above the composer) or turn (one block of generations, the live one at the bottom); ?view= overrides it per page load.
334
- Entry.new(key: "web.view", yaml_path: %w[web view], type: :enum, default: "stage", expose: %i[env config cli], enum_values: %w[stage turn], lifecycle: :web_start),
335
333
  # The stage view's working set (files, memories and URLs the turn brought into context): a rail in the stage, a
336
334
  # footprint under each finished turn and a "set N" chip in place of the mem chip; ?working_set=1|0 overrides it per page load.
337
335
  Entry.new(key: "web.working_set", yaml_path: %w[web working_set], type: :bool, default: true, expose: %i[env config cli], lifecycle: :web_start),
@@ -259,6 +259,24 @@ module Samagotchi
259
259
  "the desktop helper's launch file sets #{baked.join(", ")} for its sessions' tools: chi desktop upgrade"
260
260
  end
261
261
 
262
+ # The config dir the helper's chi reads (its home.json, config.yml):
263
+ # the launch file's XDG_CONFIG_HOME when baked, else ~/.config.
264
+ # @return [String]
265
+ def launch_config_home
266
+ baked = read_launch["env"]
267
+ dir = baked["XDG_CONFIG_HOME"] if baked.is_a?(Hash)
268
+ # Spelled out: Paths.config_home's ~ is this process's HOME, not @env's.
269
+ dir.to_s.empty? ? File.join(@env["HOME"] || Dir.home, ".config") : dir
270
+ end
271
+
272
+ # Whether the helper's chi reads this chi's config dir (home.json,
273
+ # config.yml): false for a run with XDG_CONFIG_HOME elsewhere (a spec,
274
+ # a smoke run), whose home and bundles the helper never sees, though
275
+ # its launch file sits under the real ~/Library.
276
+ def reads_this_config?
277
+ File.expand_path(launch_config_home) == Paths.config_home(env: @env)
278
+ end
279
+
262
280
  # The chi version the launch file names, nil without one.
263
281
  def launch_version = read_launch["version"]
264
282
 
@@ -316,10 +334,7 @@ module Samagotchi
316
334
  # reads: the launch file's XDG_CONFIG_HOME when baked; disabled hosts
317
335
  # are left out. @return [Array<Hash>] name:, hosts:, state:
318
336
  def host_keys
319
- baked = read_launch["env"]
320
- dir = baked["XDG_CONFIG_HOME"] if baked.is_a?(Hash)
321
- # Spelled out: Paths.config_home's ~ is this process's HOME, not @env's.
322
- config_env = { "XDG_CONFIG_HOME" => dir.to_s.empty? ? File.join(@env["HOME"] || Dir.home, ".config") : dir }
337
+ config_env = { "XDG_CONFIG_HOME" => launch_config_home }
323
338
  hosts = ConfigFile.hosts_config(env: config_env).values.select(&:api_key_env).group_by(&:api_key_env)
324
339
  hosts.map do |name, entries|
325
340
  state = if HostKeys.from_keychain(name, env: @env) then :keychain
@@ -15,6 +15,7 @@ require_relative "steer"
15
15
  require_relative "turn_note"
16
16
  require_relative "turn_endings"
17
17
  require_relative "turn_origin"
18
+ require_relative "revise_anchor"
18
19
  require_relative "verify_anchor"
19
20
  require_relative "web/participant_view"
20
21
  require_relative "verdict"
@@ -1631,10 +1632,11 @@ module Samagotchi
1631
1632
  seconds = turn.elapsed
1632
1633
  synchronize_events do
1633
1634
  kept, event = yield(seconds)
1634
- stamp_verify_anchor(kept, turn)
1635
+ revises = stamp_opening(kept, turn, outcome)
1635
1636
  replace_session_messages(turn.session, kept) if kept
1636
1637
  verdict = record_verdict(turn, outcome)
1637
1638
  event[:verdict] = verdict if verdict
1639
+ event[:revises] = revises.to_h if revises
1638
1640
  emit_working_set(turn.on_event, turn.session, turn.session.messages)
1639
1641
  turn.session.status = Session::STATUS_IDLE
1640
1642
  record_last_turn(turn.session, outcome, seconds, turn.origin, event, limit: exhausted ? turn.limit : nil)
@@ -1647,16 +1649,63 @@ module Samagotchi
1647
1649
  end
1648
1650
  private :end_turn
1649
1651
 
1650
- def stamp_verify_anchor(messages, turn)
1651
- return unless turn.verify && messages.is_a?(Array)
1652
+ # The turn's anchors, stamped on its opening message (the prompt, or a
1653
+ # wake turn's note) in the messages it keeps: display metadata, never
1654
+ # in the prompt (LLM::ChatLoop#plain keeps them, no wire sends them).
1655
+ # A Verify turn's claim however it ended; a Revise turn's draft only
1656
+ # once it completed with an answer (#revision_target). Returns the
1657
+ # stamped ReviseAnchor (the end event's revises), or nil.
1658
+ def stamp_opening(messages, turn, outcome)
1659
+ return unless messages.is_a?(Array)
1660
+
1661
+ revises = revision_target(messages, turn, outcome)
1662
+ fields = { verify: turn.verify&.to_h, revises: revises&.to_h }.compact
1663
+ return if fields.empty?
1652
1664
 
1653
1665
  opening = messages.reverse.find do |message|
1654
- (message[:role] || message["role"]).to_s == "user" &&
1655
- (message[:turn_id] || message["turn_id"]).to_s == turn.id
1666
+ Steer.turn_prompt?(message) && (message[:turn_id] || message["turn_id"]).to_s == turn.id
1656
1667
  end
1657
- opening[:verify] = turn.verify.to_h if opening
1668
+ return unless opening
1669
+
1670
+ opening.merge!(fields)
1671
+ revises
1658
1672
  end
1659
- private :stamp_verify_anchor
1673
+ private :stamp_opening
1674
+
1675
+ # The turn a Revise turn revised (D6: "this turn revised that one"):
1676
+ # only a completed turn with an answer (AnswerTail) has revised
1677
+ # anything. A draft no prompt in the messages carries is no target (a
1678
+ # stale page, a rolled-back turn); one a stamped turn already revised
1679
+ # is re-targeted to its stack's top, the newest version, which the
1680
+ # person meant ("this draft").
1681
+ def revision_target(messages, turn, outcome)
1682
+ return unless turn.revises && outcome == "completed"
1683
+
1684
+ slice = AnswerTail.turn_slice(messages, turn.id)
1685
+ return unless slice&.any? { |message| AnswerTail.answer?(message) }
1686
+
1687
+ target = turn.revises.turn_id
1688
+ seen = [turn.id]
1689
+ until seen.include?(target)
1690
+ return reject_revise_anchor(turn.revises, :turn_missing) unless AnswerTail.turn_slice(messages, target)
1691
+
1692
+ seen << target
1693
+ newer = messages.reverse.find do |message|
1694
+ Steer.turn_prompt?(message) && ReviseAnchor.from(message[:revises] || message["revises"])&.turn_id == target
1695
+ end
1696
+ return ReviseAnchor.new(turn_id: target) unless newer
1697
+
1698
+ target = (newer[:turn_id] || newer["turn_id"]).to_s
1699
+ end
1700
+ reject_revise_anchor(turn.revises, :cycle)
1701
+ end
1702
+ private :revision_target
1703
+
1704
+ def reject_revise_anchor(anchor, reason)
1705
+ Log.warn(:turn, "revise_anchor_rejected", turn_id: anchor.turn_id, reason:)
1706
+ nil
1707
+ end
1708
+ private :reject_revise_anchor
1660
1709
 
1661
1710
  def verified_anchor(session, anchor)
1662
1711
  return unless anchor
@@ -2150,8 +2199,9 @@ module Samagotchi
2150
2199
  # Continue that starts cold (after a model change).
2151
2200
  # +wall+: the wall time it started (MessageTime.now, once): the
2152
2201
  # :turn_started event's at and its user message's (and reminders') stamp.
2202
+ # +verify+, +revises+: a Verify turn's claim, a Revise turn's draft.
2153
2203
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
2154
- :settings, :id, :limit, :resumed, :resume_from, :wall, :verify) do
2204
+ :settings, :id, :limit, :resumed, :resume_from, :wall, :verify, :revises) do
2155
2205
  def resumed? = resumed ? true : false
2156
2206
 
2157
2207
  # The turn's boundary events carry the origin only when it names a
@@ -2215,6 +2265,9 @@ module Samagotchi
2215
2265
  # @param id [String, nil] the turn's id (a new uuid when nil): a caller
2216
2266
  # that put the turn's first message in the session already (a context
2217
2267
  # wake's note) names it with this id
2268
+ # @param verify [VerifyAnchor, nil] a Verify turn's claim (its verdict)
2269
+ # @param revises [ReviseAnchor, nil] a Revise turn's draft: stamped on
2270
+ # the prompt at the turn's end, metadata only (#stamp_opening)
2218
2271
  #
2219
2272
  # An Interrupt (SIGINT) cancels the turn: what it got to is kept in the
2220
2273
  # session as after a Stop (the prompt, the loop's finished tool
@@ -2226,9 +2279,11 @@ module Samagotchi
2226
2279
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
2227
2280
  # one-line summary:.
2228
2281
  def run_turn(session, prompt, on_event: nil, max_iterations: nil, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false,
2229
- origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, input_waiting: nil)
2282
+ origin: TurnOrigin::NONE, images: [], id: nil, resumed: false, resume_from: nil, verify: nil, revises: nil,
2283
+ input_waiting: nil)
2230
2284
  turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
2231
- continue: continue, id: id, verify: verify, input_waiting: input_waiting)
2285
+ continue: continue, id: id, verify: verify, revises: revises,
2286
+ input_waiting: input_waiting)
2232
2287
  if continue
2233
2288
  turn.resumed = resumed
2234
2289
  turn.resume_from = resume_from
@@ -2276,7 +2331,8 @@ module Samagotchi
2276
2331
  # The turn's state before anything can fail it: the session running,
2277
2332
  # the turn flag, its cancel controller and sink. The cross-thread state
2278
2333
  # is set here and cleared in #release_turn only.
2279
- def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, input_waiting: nil)
2334
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:, id: nil, verify: nil, revises: nil,
2335
+ input_waiting: nil)
2280
2336
  origin = TurnOrigin.of(origin)
2281
2337
  # A participant's Verify fresh child: every turn is theirs (their
2282
2338
  # author, boundary and cap), whoever started it.
@@ -2304,7 +2360,8 @@ module Samagotchi
2304
2360
 
2305
2361
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
2306
2362
  controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
2307
- id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify))
2363
+ id: id || SecureRandom.uuid, wall: MessageTime.now, verify: VerifyAnchor.from(verify),
2364
+ revises: ReviseAnchor.from(revises))
2308
2365
  end
2309
2366
  private :begin_turn
2310
2367
 
@@ -3044,6 +3101,15 @@ module Samagotchi
3044
3101
  return if pending.nil?
3045
3102
 
3046
3103
  outcome, judged = pending.wait(turn.controller)
3104
+ if outcome == :timeout
3105
+ # The session starts on its default without a word otherwise: a
3106
+ # slow System 1 host (a local one) is the usual cause.
3107
+ timeout_ms = SystemOne::Client.configured_timeout_ms
3108
+ Log.warn(:toolkit, "toolkit_suggest_timeout", session_id: turn.session.id, timeout_ms: timeout_ms,
3109
+ reason: "System 1 gave no answer within guardrails.system_one.timeout_ms")
3110
+ @toolkit_binding.fell_back(turn.session, "System 1 took over #{timeout_ms} ms")
3111
+ return
3112
+ end
3047
3113
  unless outcome == :answered
3048
3114
  Log.info(:toolkit, "toolkit_suggest_#{outcome}", session_id: turn.session.id)
3049
3115
  return
@@ -3070,6 +3136,10 @@ module Samagotchi
3070
3136
  deferred: ToolkitSuggest.model_reply?(session.messages))
3071
3137
  switched ? "suggested" : "chosen"
3072
3138
  when "defer" then "defer"
3139
+ when "no_judgment"
3140
+ # System 1 failed (its own warn) or answered nothing usable.
3141
+ @toolkit_binding.fell_back(session, "System 1 gave no answer")
3142
+ "default"
3073
3143
  else "default"
3074
3144
  end
3075
3145
  end
@@ -330,9 +330,10 @@ module Samagotchi
330
330
  return verdict
331
331
  end
332
332
 
333
- answer = @ask.call(payload)
333
+ # The card and the session file carry the facts as the hash they were.
334
+ answer = @ask.call(payload.merge(approval: payload[:approval].to_h))
334
335
  system_one_label(judgment, answer, payload)
335
- Guardrails::Approval.settle(verdict, answer, payload[:approval][:scopes])
336
+ Guardrails::Approval.settle(verdict, answer, payload[:approval].scopes)
336
337
  end
337
338
 
338
339
  private
@@ -391,9 +392,9 @@ module Samagotchi
391
392
  decision = system_one_decision(client, auto)
392
393
 
393
394
  id = log.judgment(session_id: @session_lookup.call&.id,
394
- rule: approval[:rule], source: approval[:source], tool: approval[:tool],
395
+ rule: approval.rule, source: approval.source, tool: approval.tool,
395
396
  **SystemOne::ApprovalQuestion.call_fields(approval),
396
- repo_name: approval[:repo_name],
397
+ repo_name: approval.repo_name,
397
398
  p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
398
399
  latency_ms: latency_ms, decision: decision, origin: "live")
399
400
  # A judgment the log cannot hold is no judgment: the user is asked.
@@ -418,12 +419,13 @@ module Samagotchi
418
419
  # user opted in, the never-list and chi's own files, the probability, and
419
420
  # the rule's record. Any one that fails leaves the ask to the user. The
420
421
  # entries are the judgment log read once for this ask.
422
+ # @param approval [Guardrails::ApprovalFacts] the ask's facts
421
423
  # @param entries [Array<Hash>] SystemOne::Log#entries
422
424
  # @param targets [Guardrails::Targets, nil] the call's, for its env:
423
425
  def system_one_auto_allow?(client, approval, p, entries, targets: nil)
424
426
  return false unless client.mode == "on"
425
427
 
426
- rule = approval[:rule].to_s
428
+ rule = approval.rule.to_s
427
429
  return false if rule.empty?
428
430
  return false unless system_one_rules.include?(rule)
429
431
  return false if SystemOne::SelfProtect.refuse?(approval, targets: targets)
@@ -456,7 +458,7 @@ module Samagotchi
456
458
  id = judgment && judgment[:id]
457
459
  return nil if id.nil?
458
460
 
459
- scopes = Array(payload[:approval][:scopes])
461
+ scopes = Array(payload[:approval].scopes)
460
462
  if !answer.is_a?(Samagotchi::QuestionAnswer)
461
463
  label = "none"
462
464
  elsif answer.parent_agent?
@@ -6,6 +6,41 @@ require_relative "../question_answer"
6
6
 
7
7
  module Samagotchi
8
8
  module Guardrails
9
+ # An ask's facts, as Approval.payload builds them (its approval:) and
10
+ # the question card shows them: the tool (tool, acts_as, label), what
11
+ # it acts on (command, paths, args, memory), where (cwd, repo_root,
12
+ # repo_name, branch), who asked and why (rule, source, reason), the
13
+ # offered scopes, an edit's preview (EditPreview.for) and, for a
14
+ # participant's turn, who asks (author, author_name, asked_by).
15
+ #
16
+ # In this process it is passed as this value; it crosses to the UIs and
17
+ # the session file (PendingQuestion's approval) as #to_h, the hash it
18
+ # always was: symbol keys, in member order, unset fields left out. A
19
+ # saved or sent one (string or symbol keys, missing fields, a key we
20
+ # don't know dropped) comes back through .from_h. memory and preview
21
+ # stay as given (string keys inside after a load).
22
+ ApprovalFacts = Data.define(:tool, :acts_as, :label, :command, :paths, :args, :memory, :cwd, :repo_root,
23
+ :repo_name, :branch, :rule, :source, :reason, :scopes, :preview, :author,
24
+ :author_name, :asked_by) do
25
+ def initialize(**fields)
26
+ super(**members.to_h { |m| [m, nil] }, **fields)
27
+ end
28
+
29
+ # @param raw [ApprovalFacts, Hash, nil] facts as built, saved or sent
30
+ # @return [ApprovalFacts, nil] nil for anything that is not facts
31
+ def self.from_h(raw)
32
+ return raw if raw.is_a?(ApprovalFacts)
33
+ return nil unless raw.is_a?(Hash)
34
+
35
+ new(**members.to_h { |m| [m, raw.key?(m) ? raw[m] : raw[m.to_s]] })
36
+ end
37
+
38
+ # The card's and the session file's shape: symbol keys, in member
39
+ # order, unset fields left out.
40
+ # @return [Hash{Symbol => Object}]
41
+ def to_h = super.compact
42
+ end
43
+
9
44
  # An ask as a question for the user (Engine#open_question), and the
10
45
  # answer back as a verdict. The question text is plain and complete, so
11
46
  # a UI that doesn't know approvals still shows everything; `approval:`
@@ -42,9 +77,10 @@ module Samagotchi
42
77
  # their turn asks: it offers once only (PARTICIPANT_SCOPES), and the
43
78
  # card names them (approval author:, author_name:, the header)
44
79
  # @param author_name [String, nil] their name (participants.json)
45
- # @return [Hash] open_question fields; its caller adds user_only:
46
- # (ParentApprovals.protected_facts? on approval:, a file that
47
- # requires this one)
80
+ # @return [Hash] open_question fields, with approval: an ApprovalFacts
81
+ # (its caller hands open_question approval.to_h); the caller adds
82
+ # user_only: (ParentApprovals.protected_facts? on approval:, a file
83
+ # that requires this one)
48
84
  def payload(verdict, label: nil, preview: nil, only: nil, author: nil, author_name: nil)
49
85
  asker = asker(author, author_name)
50
86
  only = PARTICIPANT_SCOPES if asker
@@ -60,7 +96,7 @@ module Samagotchi
60
96
  allow_freeform: true,
61
97
  effects: effects(scopes),
62
98
  kind: KIND,
63
- approval: {
99
+ approval: ApprovalFacts.new(
64
100
  tool: targets&.tool || verdict.call[:name].to_s,
65
101
  # The tool the call acts as (an MCP tool behind call_tool).
66
102
  acts_as: targets&.acts_as,
@@ -86,7 +122,7 @@ module Samagotchi
86
122
  author: (author.to_s if asker),
87
123
  author_name: (author_name_for(author_name) if asker),
88
124
  asked_by: (format(ASKED_BY, asker) if asker)
89
- }.compact
125
+ )
90
126
  }
91
127
  end
92
128