samagotchi 0.147.0 → 0.148.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 29546fb5487086302ed4d68c168fa9e7f4f576bee252e59cfdaf08a4e74081b4
4
- data.tar.gz: 3c05877e89bccf719dbdf8f4287c8343222f897484c2f22a4c3245bdc92a2af1
3
+ metadata.gz: fcbb626a7c357711e1403ac1c9825d0930eb5eb7d4e9f36e0cf004f96b0a4de7
4
+ data.tar.gz: d5a85b7198fa381dd4a352389386c3d4cf5a96d89a06834e13d9518c5412eb54
5
5
  SHA512:
6
- metadata.gz: 953166016279e2b81ebe64c9642df2a245cc3d7fe4520850bf4023557b5d1b7fd620589fd7c49df66d458e59eeae32dffb3020e6286ea8be85edf2710c2bd7d4
7
- data.tar.gz: 6c01b8ac5e044770f34e4fd5057915116e8b56b81a6f505fac3259449911a02b091262aad2554417ebf0493068ace77b1636d3fb8187769225268d12540c5354
6
+ metadata.gz: b9e73f35692277dab5f9cb18435db900d35b941b64af1c878e7f3503ece14e5f46a35f120a457a53fc8d9d3fcb81c302af370b93e7920ac71d5aec2b723cd250
7
+ data.tar.gz: 1bb3118536d48f1b9741d811cf9c2df9a85766dfc15e1fb758ac4caa99b95123774ef51c32afe01dc66d208562eef6ec2d7a8c236ea14264d56700f45952de8d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ 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.148.0] - 2026-11-04
11
+
12
+ ### Added
13
+
14
+ - `chi guardrails rule <id>` prints exactly `asks` when that rule votes `ask` on this machine now, and says why not otherwise. [docs/cli.md](docs/cli.md)
15
+ - `guardrails.system_one.repo_rules` (default `git-merge`): a rule's System 1 record per repository, so a clean record at home doesn't let System 1 answer it in an unknown project. [docs/guardrails.md](docs/guardrails.md#answering-for-the-user-on-mode)
16
+
17
+ ### Changed
18
+
19
+ - A continue's carry note now holds the previous session's last answer after its recap (its last 4000 characters when longer), so a wrap-up's exact shas, paths and next steps reach the next link without a paste; `session.carry_last_answer: false` keeps it recap-only. A note can no longer exceed its 16 KiB and fail the continue: a recap of many non-ASCII characters used to.
20
+ - The coordinator's merges are now guardrail approvals (bundle coordinator 0.7.0). A verified branch that can fast-forward merges with no question from the model: you get the new `git-merge` rule's card, and **Allow rule git-merge in this repo** lets later merges there pass, so trusted projects merge on green and an unknown one still asks. Anything less certain still gets the question, and with no grant a yes is followed by the card. The rule votes in every session on a machine with the coordinator bundle, in any repo, children and background builds too (a child's `git merge main` raises a card), and `--non-interactive` merges are now denied. `chi update` brings it. [docs/bundles.md](docs/bundles.md#the-coordinator-bundle)
21
+
22
+ ### Fixed
23
+
24
+ - A stored approval no longer covers another rule's ask on the same call. With rule `git-push` allowed in the repo, `git push && git reset --hard` ran with no ask, because an ask keeps the first rule's id. When a second rule asks about a call, no rule grant answers it, none is offered and System 1 doesn't auto-allow it; a session or repo approval for exactly that command still answers. [docs/guardrails.md](docs/guardrails.md#approvals)
25
+
10
26
  ## [0.147.0] - 2026-11-03
11
27
 
12
28
  ### Changed
@@ -5595,7 +5611,8 @@ and long-lived sessions.
5595
5611
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
5596
5612
  hotkey panel that send selected text or the clipboard to your sessions.
5597
5613
 
5598
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.147.0...HEAD
5614
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.148.0...HEAD
5615
+ [0.148.0]: https://github.com/dm1try/samagotchi/compare/v0.147.0...v0.148.0
5599
5616
  [0.147.0]: https://github.com/dm1try/samagotchi/compare/v0.146.0...v0.147.0
5600
5617
  [0.146.0]: https://github.com/dm1try/samagotchi/compare/v0.145.0...v0.146.0
5601
5618
  [0.145.0]: https://github.com/dm1try/samagotchi/compare/v0.144.0...v0.145.0
data/README.md CHANGED
@@ -152,8 +152,8 @@ finds another session, `send_note` tells it something, and `delegate` hands a
152
152
  task to a child session that runs in parallel and reports back only its final
153
153
  reply (a normal session: `chi --attach <id>` steers it). With the `coordinator`
154
154
  bundle (in `dev`), `/coordinate <goal>` has chi split the work into tasks, each
155
- in a child session in its own git worktree, check each report and ask you
156
- before each merge; `/children` shows the children and their state. With the
155
+ in a child session in its own git worktree, check each report and merge under
156
+ an approval you give chi (once, or per repo), or ask you first; `/children` shows the children and their state. With the
157
157
  `claude-peer` bundle (in `dev`), chi sends a part of its work to a Claude Code
158
158
  session you pair it with, to validate or as context, and Claude answers back
159
159
  into chi (`/claude`); the `codex-peer` bundle (in `dev`) does the same with an
data/docs/bundles.md CHANGED
@@ -799,7 +799,7 @@ chi bundle install coordinator # in the dev profile
799
799
 
800
800
  chi as a coordinator of parallel work, the way you would run several agents
801
801
  yourself: split the work, one git worktree and child session per task, keep
802
- talking while they run, check each report, and merge only with your OK. It
802
+ talking while they run, check each report, and merge only when you allow it. It
803
803
  works in a session with a worker (plain `chi`, the web). A `--no-shared` REPL
804
804
  or `-p` gets no delegate reports, so the children's replies wouldn't come back
805
805
  by themselves there.
@@ -810,7 +810,7 @@ by themselves there.
810
810
  <branch>`), starts a child there with `delegate wait: false, cwd:`, tells
811
811
  you what started and ends its turn. When a child's report comes, it checks
812
812
  the branch (`git log`, `git diff --stat`, the tests the child names) before
813
- telling you. It asks before each merge (`ask_user_question`), checks that its
813
+ telling you. Before merging (**Merges** below) it checks that its
814
814
  own folder is on the default branch and that branch's log first (another
815
815
  agent may have merged the branch already), merges `--ff-only` and never
816
816
  pushes unless you ask. After a merge it stops the
@@ -856,7 +856,7 @@ by themselves there.
856
856
  diff in your turn there: the model brings the handoff up to date and
857
857
  re-plans in words, and starts, stops or merges nothing from the review
858
858
  session: the coordinator session picks the change up from the handoff,
859
- merges still asked. Experimental: it may go if it doesn't earn its place.
859
+ and merges as it always does. Experimental: it may go if it doesn't earn its place.
860
860
  - **The coordinator toolkit.** The bundle declares `coordinator`: `lean`
861
861
  plus `delegate`, `delegate_result` and `list_sessions`
862
862
  ([Toolkits](configuration.md#toolkits)). `/coordinate` puts a fresh session
@@ -883,19 +883,29 @@ by themselves there.
883
883
  guardrails mode and without the guardrails bundle ([a delegate child's
884
884
  boundary](guardrails.md#a-delegate-childs-boundary)); you answer on its
885
885
  card, or on the parent's while it waits.
886
- - **Merges.** The parent's merge in its own checkout is a plain `git
887
- merge`; to be asked before every merge, add a rule ([Rules in
888
- config.yml](guardrails.md#rules-in-configyml)):
889
-
890
- ```yaml
891
- guardrails:
892
- rules:
893
- - id: git-merge
894
- tool: shell
895
- command: '\bgit\s+merge\b'
896
- verdict: ask
897
- reason: merging a child's branch
898
- ```
886
+ - **Merges.** Your approval is chi's own card, not a question from the model. When its verdict on a branch is "done and
887
+ verified" (no doubts) and the branch can fast-forward, and `chi guardrails rule git-merge` prints `asks`, and it read the branch's whole diff in this session, not only the stat, the skill says `Merging <branch>: <verdict>` and runs
888
+ `git merge --ff-only <branch>` alone. You then get the approval card, or, if you allowed the rule in this repo, it
889
+ passes: home projects you trust merge on green, an unknown one still asks. Anything else (tests not run in this
890
+ session, a child that named none, a resumed session's old verdict, the rule off, doubts) goes back to a question
891
+ (`ask_user_question`). With no grant, a yes there is followed by chi's card on the merge, and declining the card is a
892
+ no. A Deny is saved and not retried: the model reports it and leaves the branch until you say otherwise. Under `--non-interactive` no one can answer, so a merge is
893
+ denied and it tells you. A parent agent may give Allow once under `guardrails.parent_answers: soft`, as it may answer
894
+ a question today.
895
+
896
+ The bundle's rule `git-merge` (`guardrails/merge.yml`) asks before every `git merge`, whatever the
897
+ branch, including `git -C`, `--git-dir=`, a `cd` before it and `sh -c`. It offers Allow once and **Allow rule git-merge
898
+ in this repo**: that grant holds for every later merge in the repository, in each of its worktrees and sessions,
899
+ and for nothing else. `git merge y && git push` still asks, once ([Approvals](guardrails.md#approvals)). `merge-base`,
900
+ `merge-tree`, `merge-file`, `mergetool` and a merge whose first word is `--abort`, `--continue` or `--quit` don't ask;
901
+ `git pull` and `gh pr merge` are not covered. It votes in every session on a machine with this bundle, in any repo,
902
+ children and background builds too, so a child's `git merge main` raises a card. A `--non-interactive` merge is denied,
903
+ with nobody to ask. It over-asks where the words are data (a commit message, `chi send -m`), and when merging the default branch into
904
+ a feature branch. `guardrails.disable: [git-merge]` switches it off. A grant goes by the repository chi is in, so
905
+ `git -C other merge` or `cd other && git merge` from a granted repo passes: an accident guard, as the rest
906
+ of the rules are. "Rule in this repo" tapped on a child's `git merge main` frees every merge there, as the rule's
907
+ reason says. A `git-merge` rule copied into config.yml votes first and keeps its behaviour; the old snippet,
908
+ `\bgit\s+merge\b`, also matches `git merge-base`, so drop your copy.
899
909
 
900
910
  ## The claude-peer bundle
901
911
 
data/docs/cli.md CHANGED
@@ -39,6 +39,7 @@
39
39
  - `chi bundle install|upgrade|uninstall|status|diff|list|build|trash|new|test|check` — manage memory bundles (see [Bundle hooks](hooks.md#bundle-hooks-unified-workflow-bundle)); `list` shows the installed ones and the ones shipped with chi, which `install <name>` installs; `core` and `dev` are profiles that install a set of them (see [Bundle profiles](memory.md#bundle-profiles-core-and-dev), [Guardrails](guardrails.md), [Bundles](bundles.md#the-btw-bundle), [the mcp bundle](bundles.md#the-mcp-bundle) [the loop-guard bundle](bundles.md#the-loop-guard-bundle), [the check-in bundle](bundles.md#the-check-in-bundle) and [the skills bundle](bundles.md#the-skills-bundle)). `trash` lists and empties the bundle trash (moved files from uninstalls/upgrades). A usage error (an unknown subcommand or flag, a missing argument, a bad `build --scope`) exits 2, as for the other subcommands. Plain `chi` and `chi web` exit 1 on an unknown flag, a flag missing its value or a stray argument. `new`, `test` and `check` are for writing one: see [Writing a bundle](#writing-a-bundle-new-test-check)
40
40
 
41
41
  - `chi guardrails replay [--dry-run] [--threshold T] [--model M]` — seed System 1's record from the approvals this machine's sessions already answered: each ask is judged by the configured model and written to `guardrails/judgments.jsonl` with the answer the user gave, then a per-rule table prints (asks, allowed, denied, would-be auto-allows, false allows, asks with no judgment). A parent agent's answer is not the user's: only its deny is replayed, and only where the parent could have allowed. `--dry-run` judges without writing, `--threshold` overrides `guardrails.system_one.threshold` for the table, `--model` the configured model for this run. Exit 2 when `guardrails.system_one.mode` is off or no `host` is set; a usage error exits 2 (see [System 1](guardrails.md#system-1-experimental))
42
+ - `chi guardrails rule <id>` — print exactly `asks` when that rule is loaded, not disabled and votes `ask` in this `guardrails.mode` for every model; any other line says why not (no such rule, disabled, another mode, it denies, for some models only, rules that failed to load, `guardrails.enabled: false`). Exit 0 either way, 2 for a usage error. It reads config.yml and the installed bundles as a worker does (`GuardrailWiring#rules`) and takes the rule's id, never a command; the [coordinator skill](bundles.md#the-coordinator-bundle) reads it before it merges without asking
42
43
  - `chi judgments ls` / `chi judgments report NAME [--days N]` — what [System 1's saved runs](system-one.md) hold: each judgment's digests, runs, labels, failures, models and size; one judgment's counts, p histogram and labels by value. Reads only; local only; `--days` must be a positive whole number.
43
44
  - `chi toolkit report [--days N]` — grade toolkit suggestions: the kit on mode would have run (the suggested kit when it would have applied it, else the default; an on-mode line's `did` says what it ran) against the tools the session called from that turn on (only that turn when the toolkit was chosen since), `call_tool` targets included, for both the chat and the raw-prompt loop. Each is *covers*, *short of a tool* (naming the tools), *wider than needed* (a narrower kit offered in the same ask would also have covered) or *ungradable* (calls it can't read back); then a per-kit tally with what on mode would have done. Includes archived sessions and caps the report at 14 days and 500 sessions. Reads `toolkit_suggestions.jsonl` (each ask logs every offered kit's rules, expanded against the called tools here); it never runs the model or changes a session. Local only; `--days` must be a positive whole number.
44
45
 
@@ -173,7 +173,7 @@ or `chi web` restart.
173
173
  | `hooks`, `bundles` | Worker start | Hooks, plugins and their bundle settings load with the engine. |
174
174
  | `recap.*` | Worker start | The engine builds its recap job at start, and again when it binds a session with its own `recap.enabled` or `recap.sentences` (`--set`, see "Per-session settings") and when `/recap on\|off\|default` changes the session's own value. |
175
175
  | `session.shared` | Next `chi` invocation | It selects attached-worker or in-process mode for that invocation. |
176
- | `session.idle_exit_minutes`, `session.project_env`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.task_wakes`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer and project env; the other values are read for the next corresponding decision. |
176
+ | `session.idle_exit_minutes`, `session.project_env`, `session.carry_last_answer`, `session.keep_empty`, `session.max_children`, `session.delegate_reports`, `session.task_wakes`, `session.max_wakes` | Worker start or next matching lifecycle decision | Existing workers keep their idle-exit timer and project env; the other values are read for the next corresponding decision. |
177
177
  | `session.retention_*`, `session.max_count`, `session.keep_status`, `session.sweep_interval_hours` | Next retention sweep | `chi web` sweeps on its configured schedule and on `GET /api/sessions`; `chi sessions prune` uses the values for that invocation. The sweep interval is checked on the next hub tick. |
178
178
  | `web.*` except `web.participants.*` | `chi web` start | Restart `chi web` to change its bind address, port or page settings. |
179
179
  | `log.*` | Process start | Each process holds its configured log sink and level after first use; restart that process. |
@@ -2221,6 +2221,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2221
2221
  | `session.shared` | `true` | | Plain `chi` runs its session in a worker and attaches; `--no-shared` per run. |
2222
2222
  | `session.idle_exit_minutes` | `30` | yes | An unused worker exits after this; `0` = never. |
2223
2223
  | `session.project_env` | `mise` | | The commands a session's worker runs follow its project: `mise` runs them in the session folder's `mise env`, read once at worker start (`chi sessions restart` picks up a mise.toml edit); `off` doesn't ask mise: the tools keep the env of whoever started the worker (with a chi home, the home's `chi` still goes first on their PATH). See [Project env](sessions.md#project-env). |
2224
+ | `session.carry_last_answer` | `true` | | A continue's carry note holds the previous session's last answer after its recap; `false`: the recap only. See [Session chains](sessions.md#session-chains). |
2224
2225
  | `session.keep_empty` | `false` | | Keep sessions nothing happened in. |
2225
2226
  | `session.max_children` | `4` | | Running delegated sessions one session may have. |
2226
2227
  | `session.delegate_reports` | `wake` | | When a delegate child ends a turn, its reply reaches the parent: `wake` runs an idle parent's turn for it, `queue` waits for the parent's next turn, `off` leaves it to `delegate_result`. |
@@ -2239,6 +2240,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2239
2240
  | `guardrails.parent_answers` | `soft` | | Which options a parent agent (`chi answer`) may pick on a session's question, by their effect: `hard` (a deny, a Stop, a plain answer), `standard` (+ Continue and other spending options), `soft` (+ "Allow once"); never a wider scope or an allow on chi's own config. An invalid value warns and is `hard`. A session's own `parent_answers` (`chi --parent-answers`, `/parent-answers`) may only tighten it. Replaces `guardrails.parent_approvals` and `turn.parent_continue`. config.yml only: no environment variable (a parent can still pick the config dir with `XDG_CONFIG_HOME`). A convention for an honest parent, not a security boundary. See [Guardrails](guardrails.md#answers-from-a-parent-agent). |
2240
2241
  | `guardrails.system_one.mode` | `off` | | System 1, the cheap judgment that answers an ask before the user sees it: `off` (the default), `audit` (judge every ask, log it, never decide), `on` (also allowed to allow a call once, behind the gates in [System 1](guardrails.md#system-1-experimental)). An unknown value warns and is `off`. Experimental. config.yml only. See [Guardrails](guardrails.md#system-1-experimental). |
2241
2242
  | `guardrails.system_one.rules` | none | | The rule ids System 1 may answer for in `on` mode: `"git-push|git-rebase"` or a YAML list, parsed like `guardrails.small_models`. Nothing is auto-allowed while it is unset; the `chi-answer-piped`, `chi-answer-http` and `chi-broadcast` rules never are, however they are listed. config.yml only. |
2243
+ | `guardrails.system_one.repo_rules` | `git-merge` | | The rule ids whose record goes by repository: only this repository's asks count toward a clean record, and the rule never qualifies outside one. Other rules keep one record, so a deny in one repo holds them back everywhere. Same grammar as `rules`; `[]` for none. config.yml only. |
2242
2244
  | `guardrails.system_one.threshold` | `0.8` | | The probability an auto-allow needs. config.yml only. |
2243
2245
  | `guardrails.system_one.min_record` | `5` | | How many clean records a rule needs before System 1 is trusted for it: labelled asks of that rule at the threshold the user allowed. config.yml only. |
2244
2246
  | `system_one.host` | none | | The `hosts:` entry serving `/v1/systemone` (a local Splash, or the hosted Jev API); unset: no judgment, by any use of System 1, so nothing auto-allows and every ask goes to the user. Moved from `guardrails.system_one.host`: while any of host, model and timeout_ms is still under `guardrails.system_one`, chi warns and nothing is judged. config.yml only. |
data/docs/guardrails.md CHANGED
@@ -6,7 +6,8 @@ loops (native and chat hosts). The verdict is **allow**, **ask** or
6
6
  voter. Of two asks the first one names the rule, except a rule only the
7
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
- are offered.
9
+ are offered. When a second YAML rule with another id asks about the same call (`git merge y && git push`), no
10
+ [rule grant](#approvals) answers it and none is offered.
10
11
 
11
12
  Who votes, in order:
12
13
 
@@ -103,7 +104,14 @@ stored with the repository's common git dir as `repo`, and the worktree as
103
104
  (samagotchi)`). An entry stored before chi 0.20 has no `repo` and still only
104
105
  matches its exact folder.
105
106
 
106
- A stored approval only relaxes an ask; a deny rule is never approvable.
107
+ A stored approval only relaxes an ask; a deny rule is never approvable. An ask names one rule, so on a call two YAML
108
+ rules ask about (`git merge y && git push`, `git push && git reset --hard`, `git merge y && rm -rf ~`;
109
+ `Verdict#second_rule_ask?`) no **rule** grant answers it, whichever rule it was given for, System 1 doesn't auto-allow
110
+ it, and it offers the scopes both rules allow, minus the rule. A session or repo approval for exactly that command still
111
+ answers it, since it is keyed on the whole command you saw: `git branch -D x && git push origin --delete x` offers
112
+ once, session and repo, while `git merge y && git push` offers once, as `git-merge` offers only once and its rule.
113
+ Two rules with one id (a config.yml copy of a bundle's rule) are one rule; a core ask followed by a rule's (the
114
+ [child boundary's](#a-delegate-childs-boundary)) keeps its own scopes.
107
115
  A file that doesn't parse is moved aside to `approvals.json.corrupt-<UTC time>`
108
116
  with one warning, and chi starts with no stored approvals (more asks, nothing lost).
109
117
  `/guardrails` lists the rules and approvals; `/guardrails revoke N` removes
@@ -1075,6 +1083,7 @@ guardrails:
1075
1083
  system_one: # approvals' own
1076
1084
  mode: off # off (default) | audit (judge, log, never decide) | on
1077
1085
  rules: [git-push, git-rebase] # the rule ids it may answer for
1086
+ repo_rules: [git-merge] # the rules whose record goes by repository (the default)
1078
1087
  threshold: 0.8 # the probability an auto-allow needs
1079
1088
  min_record: 5 # clean records a rule needs before it is trusted
1080
1089
  ```
@@ -1177,7 +1186,8 @@ this is turned on.
1177
1186
  ### Answering for the user: `on` mode
1178
1187
 
1179
1188
  In `on` mode an ask is allowed **once**, without reaching the user, when every
1180
- gate below passes, in this order:
1189
+ gate below passes, in this order (a pair turn's fresh ask, or [a second rule's
1190
+ ask](#approvals) on the call, is judged and logged but always goes to the user):
1181
1191
 
1182
1192
  1. the mode is `on` (in `audit` the judgment decides nothing);
1183
1193
  2. the ask's rule is in `guardrails.system_one.rules` — `"git-push|git-rebase"`
@@ -1238,7 +1248,14 @@ gate below passes, in this order:
1238
1248
  or above the threshold that the user allowed, and none of its **last 20
1239
1249
  labelled** asks judged at the threshold that the user denied or later
1240
1250
  flagged as one System 1 should have asked about. Asks with no label, no
1241
- probability, or a `none` label count for neither side.
1251
+ probability, or a `none` label count for neither side. A rule in
1252
+ `guardrails.system_one.repo_rules` (default `git-merge`) counts only this
1253
+ repository's asks (the judgment's `repo`, its common git dir, so a repository's
1254
+ worktrees share it): a clean record at home doesn't let a merge pass in an
1255
+ unknown project. Outside a repository such a rule never qualifies, and lines
1256
+ written before `repo` was recorded, or by `chi guardrails replay`, are in no
1257
+ repository's record. `/guardrails` shows the rule's whole record, labelled
1258
+ `git-merge (per repo)`.
1242
1259
 
1243
1260
  Then the judgment's `decision` is `auto`, the verdict settles as
1244
1261
  `settle!(:allow, decided_by: "system one")` with scope `once`, and nothing is
@@ -1356,6 +1373,10 @@ discard uncommitted changes, and `git stash drop`/`clear`. They offer only
1356
1373
  them on every model, set `small_models` to `"*"`; to drop them, `[]` (or
1357
1374
  `disable:` the ids).
1358
1375
 
1376
+ The coordinator bundle (in `dev`, not here: everyone with the core profile would get a new ask) adds `git-merge`, which
1377
+ asks before every `git merge` ([the coordinator bundle](bundles.md#the-coordinator-bundle)). `chi guardrails rule <id>`
1378
+ says whether a rule asks on this machine now.
1379
+
1359
1380
  A bundle ships rules as `guardrails/*.yml` (the same `rules:` shape). Install
1360
1381
  records each file's sha256; a file changed afterwards, missing, or not parsing
1361
1382
  denies every call until the bundle is reinstalled.
data/docs/sessions.md CHANGED
@@ -304,8 +304,8 @@ A session can **continue** an earlier one: "the next run of the same routine" (a
304
304
 
305
305
  `SessionManager.continue_session` makes the next link:
306
306
 
307
- - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message. Its title (`/title`) is the previous link's, with or without a first message.
308
- - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Never more than the recap: no transcript. An idle new link holding only the note is kept, not discarded as empty.
307
+ - **What it copies:** the previous link's folder, its model (the name it was typed as, so an alias holds) unless the continue names one (`--model`, the web's `model`), and its own LLM context values. A model given is checked like a new session's (a host config.yml doesn't have refuses the continue, nothing archived). An inherited model whose host can't run it any more (gone from config.yml, `enabled: false`, an alias naming another host) **falls back to the default model** with a warning, `gone:org/model can't run (unknown host 'gone' in model …); started on <default>`, decided before the archive; with no default model configured the continue is refused. The carry note doesn't mention the model. The new link carries no transcript beyond the note's last answer, so a different model, or another provider, starts clean there. Its preview is the previous link's (until its first message is saved), unless it starts with a message. Its title (`/title`) is the previous link's, with or without a first message.
308
+ - **The carry note:** the new link starts with a context note from `session chain` (shown in its transcript as `note from session chain`), queued before its worker spawns, so its first turn sees it: `This session continues 3fa2c1d0 (2026-10-08 09:12), the previous link of its chain. Its recap, as of 2026-10-08 18:02:` and the previous link's recap (`recap.json`; cut at 8000 characters). No recap (fewer user turns than a recap needs, recaps off, none written in time): `… It left no recap.` Then `Its last answer:` and the previous link's last answer, read after the recap wait, so its exact shas, paths and next steps survive: a longer one keeps its last 4000 characters, after a `…` (a wrap-up closes on what comes next). There is none when the newest bubble was a step that asked for tools, when a later turn failed or was cut before it answered, and on a review link (a continue doesn't copy the grants, so the next link's participants could differ); `session.carry_last_answer: false` leaves it out of every note. No other transcript. The note never makes a continue fail: it stays within the 16 KiB a note may hold, the answer taking what the head and recap leave, and a session file that can't be read gives no answer. An idle new link holding only the note is kept, not discarded as empty.
309
309
  - **Its attached context:** the previous link's movable sources (a command source by default; a pushed one stays) are copied to the new link before its worker starts, with their last text and the removed and muted markers, and a second note (`attached context`, after the carry note) names what moved and what stayed. A push into the archived link is then refused with the new link's id. See [Movable context](context.md#movable-context).
310
310
  - **The recap first:** a previous link with a live worker is asked for a recap (the Bridge's `POST /recap`) and given up to 30 s to write it; one with none wrote its recap when its worker left.
311
311
  - **The previous link is archived** before the new one starts, by the archive's own rules (see *Archiving a session*): a turn running, a prompt queued or the step-limit question refuse it, a live idle worker is stopped, a plain REPL's session is refused. Its finished delegates are archived with it, as any archive does.
data/docs/system-one.md CHANGED
@@ -33,7 +33,7 @@ same `Client` and `Recording`: the API is in [plugins.md](plugins.md#judgments-c
33
33
  [configuration.md](configuration.md#bundles-judgments).
34
34
 
35
35
  Only bundles' judgments have settings of their own (`JudgmentSettings`). Core's take no host, model or mode here, whatever
36
- `system_one.judgments` says: `Record` and `Similar` are model-blind (`Record#initialize` filters by rule only), so a model swapped
36
+ `system_one.judgments` says: `Record` and `Similar` are model-blind (`Record#initialize` filters by rule, and by repository for a rule in `guardrails.system_one.repo_rules`, never by model), so a model swapped
37
37
  for approvals alone would inherit a clean record and could auto-allow. Before any such swap, `Record` and `Similar` have to go by model.
38
38
 
39
39
  Core's uses keep their own threads (`ToolkitSuggest::Pending`, `ContextJudge`, the approval path) and contention; only the ask-and-save
@@ -46,7 +46,7 @@ module Samagotchi
46
46
  return false if ContextNote.note?(message) || Steer.steer?(message) || TurnNote.empty_answer(message)
47
47
  return false unless %w[assistant model].include?((message[:role] || message["role"]).to_s)
48
48
 
49
- !OutputFormatter.strip_markup((message[:content] || message["content"]).to_s).empty?
49
+ !OutputFormatter.strip_markup((message[:content] || message["content"]).to_s.scrub).empty?
50
50
  end
51
51
  end
52
52
  end
@@ -0,0 +1,21 @@
1
+ # The coordinator's merge approval (docs/bundles.md, The coordinator bundle).
2
+ # Here and not in the guardrails bundle: that one is in the core profile, so
3
+ # everyone would get a new ask and --non-interactive merges would start being
4
+ # denied. The skill and the rule upgrade together.
5
+ #
6
+ # Votes in every session on a machine with this bundle, any repo, children and
7
+ # background builds too, and a --non-interactive merge is denied: nobody to ask.
8
+ #
9
+ # Asks on every merge, whatever the branch. Same git-with-global-options prefix
10
+ # as the guardrails bundle's rules; merge-base, merge-tree, merge-file,
11
+ # mergetool and a merge whose first word is --abort, --continue or --quit don't
12
+ # match. git pull and gh pr merge are not covered. Text as data over-asks (a
13
+ # commit message, chi send -m, a heredoc).
14
+ rules:
15
+ - id: git-merge
16
+ tool: shell
17
+ command: '\bgit(?:\s+(?:-C\s+\S+|-c\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+merge(?=[\s;&|)]|$)(?!\s+--(?:abort|continue|quit)\b)'
18
+ skip_read_only: true
19
+ verdict: ask
20
+ scopes: [once, rule]
21
+ reason: git merge joins a branch into the checked-out one; allowing the rule covers every later merge in this repo
@@ -1,18 +1,20 @@
1
1
  ---
2
2
  name: coordinator
3
- version: 0.6.1
3
+ version: 0.7.0
4
4
  scope: system
5
- description: "Coordinate parallel work: the coordinator skill (split the work, one worktree and child session per task, check each report, ask before each merge, a handoff memory to resume from), /coordinate <goal>, /coordinate resume, /coordinate end, /coordinate review (experimental: a sprint review session to share) and /children; the coordinator toolkit (lean + delegate, delegate_result, list_sessions) and the coding toolkit for build children (child + be concise)"
5
+ description: "Coordinate parallel work: the coordinator skill (split the work, one worktree and child session per task, check each report, merges under a guardrail approval (the git-merge rule) or a question, a handoff memory to resume from), /coordinate <goal>, /coordinate resume, /coordinate end, /coordinate review (experimental: a sprint review session to share) and /children; the coordinator toolkit (lean + delegate, delegate_result, list_sessions) and the coding toolkit for build children (child + be concise)"
6
6
  trust_level: reviewed
7
7
  files:
8
8
  skill_coordinator.md:
9
- sha256: sha256:5f6b879de274ebc89b7811d3dda3abbecbeb696159e326647a1c7393c3598f57
10
- description: "Coordinate parallel work: split it, delegate each task wait:false in its own worktree (cwd:), check each report, ask the user before each merge; resume from a handoff_* memory"
9
+ sha256: sha256:50b6dc3c2eca885133af9c6518964e6fe862194f2fa4556d9ee5374dde42242a
10
+ description: "Coordinate parallel work: split it, delegate each task wait:false in its own worktree (cwd:), check each report, merge under the git-merge approval or ask the user; resume from a handoff_* memory"
11
11
  plugin:
12
12
  file: plugin.rb
13
- sha256: sha256:0cd1e14cfc8afc6c8fd82548f390d20c812f336bdd357edaaa1b8ba1be32e0ef
13
+ sha256: sha256:fa5d7f8b01e465d80012e141abcb2ee27cd5cec43e5a74b9721d435be92d3730
14
14
  # the coordinator toolkit needs chi.toolkit and ctx.use_toolkit (0.66.0); the
15
15
  # coding toolkit (chi.toolkit prompt:) and the skill's delegate toolkit: need
16
16
  # the next chi.
17
17
  requires_chi: ">= 0.107.0"
18
18
  # /coordinate review uses ctx.sessions.review (the next chi).
19
+ # No requires_chi_next for guardrails/merge.yml: its keys are long supported, and first-ever too-new rules would make a
20
+ # live older worker deny every call. A chi without `chi guardrails rule` gives a usage error, and the skill then asks.
@@ -3,7 +3,8 @@
3
3
  # The coordinator bundle (docs/bundles.md, The coordinator bundle): chi as a
4
4
  # coordinator of parallel work. Its skill_coordinator memory holds the steps
5
5
  # (split the work, one worktree and a wait: false delegate per task, check
6
- # each report, ask before each merge); this plugin adds two commands.
6
+ # each report, merge under the git-merge rule's approval or ask); this plugin
7
+ # adds two commands, and guardrails/merge.yml that rule.
7
8
  #
8
9
  # /children [all] shows this session's children as a card, newest first:
9
10
  # state, branch, the last reply and whether it was reported (ChildrenStatus
@@ -1,7 +1,8 @@
1
1
  # Skill: coordinator
2
2
 
3
3
  Use when the user hands over work with several independent parts, or asks to "run it in parallel",
4
- "delegate", "coordinate". The user decides; you do the legwork and keep them in the loop briefly.
4
+ "delegate", "coordinate". The user decides, by an answer or by what they allowed chi; you do the legwork
5
+ and keep them in the loop briefly.
5
6
 
6
7
  ## Handoff memory
7
8
  Your conversation is lost when this session ends; git and the session list are not. Keep a handoff in a
@@ -56,27 +57,46 @@ If the request is a new goal, say which handoffs are open (one line each) and go
56
57
  talking. Don't call delegate_result: chi brings each child's reply as a delegate report, also when
57
58
  you are idle.
58
59
  4. On each report, check it before telling the user: `git -C <worktree> log --oneline <default>..` and
59
- `git diff --stat <default>...<branch>`; run the tests the child names (execute with cwd: the worktree).
60
+ `git diff --stat <default>...<branch>`; run the tests the child names (execute with cwd: the worktree;
61
+ a child naming none: say so, its branch goes through 6B).
60
62
  Say the result in one or two lines: done and verified / done with doubts / failed. In the same turn,
61
63
  write that verdict into the handoff body and change its description to match (description only).
62
64
  5. Then one of (a user's decision about it goes into the handoff first): a follow-up (`delegate
63
65
  session: <id>`, narrower, `wait: false`; then end your turn: its report comes by itself, so don't
64
66
  wait for it with delegate_result or task_wait), stop it
65
67
  (`chi sessions stop <session id>` with execute; task_stop is for task ids, not children; the user
66
- can use /children), or tell the user it is ready to merge.
67
- 6. Merging is the user's call: ask (ask_user_question) per branch, "Merge <branch> into <default>?", and
68
- save the answer in the handoff before you act on it.
69
- Before merging, in your own folder: `git branch --show-current` must print <default> (if it
70
- doesn't, stop and ask the user; don't switch branches yourself); then check <default>:
71
- `git log --oneline -3 <default>` and `git merge-base --is-ancestor <branch> <default>`; if the branch is already in, say so and don't merge
72
- again. Merge with `git merge --ff-only <branch>` yourself, in your own folder. If that fails, ask the
68
+ can use /children), or go to step 6.
69
+ 6. Merging is the user's call. First, in your own folder: `git branch --show-current` must print <default>
70
+ (if it doesn't, stop and ask the user; don't switch branches yourself); then check <default>:
71
+ `git log --oneline -3 <default>` and `git merge-base --is-ancestor <branch> <default>`; if the branch is
72
+ already in, say so and don't merge again. Then per branch pick A or B.
73
+ A (no question from you) only if ALL hold right now:
74
+ - `chi guardrails rule git-merge` printed exactly `asks`;
75
+ - your step 4 verdict is exactly "done and verified" (not "done with doubts", not "failed");
76
+ - THIS session verified the branch: you ran its tests in step 4, they passed, and no commit came
77
+ after (a verdict read from the handoff doesn't count: redo step 4);
78
+ - you read the branch's whole diff in this session (`git diff <default>...<branch>`), not only --stat,
79
+ and saw nothing that gives you a doubt; a diff too long to read in one go is a doubt, take B;
80
+ - `git merge-base --is-ancestor <default> <branch>` exits 0;
81
+ - the user said nothing to hold this branch.
82
+ Then say "Merging <branch>: <verdict>" and run `git merge --ff-only <branch>` as a command of its
83
+ own, nothing chained. Chi may ask the user or may pass it: they allowed it. Handoff: one line after.
84
+ B, anything else (an error, another output, doubts, unsure): ask (ask_user_question) "Merge <branch>
85
+ into <default>?", and save the answer in the handoff before you act on it. On a yes, merge the same
86
+ way. If the user gave chi no grant for this repo, chi's card comes up on the merge too: declining
87
+ there is a no.
88
+ If a merge comes back "The user declined": save that, don't retry, don't ask again. That error's "ask
89
+ the user how to proceed" means here: report it and leave the branch; the user can say otherwise. Any
90
+ other error ("No one to approve", cancelled, stopped) is no answer: tell the user, then B if someone
91
+ can answer.
92
+ Merge with `git merge --ff-only <branch>` yourself, in your own folder. If that fails, ask the
73
93
  child to rebase its branch on <default> (a follow-up that says "rebase only, don't merge") or ask the
74
94
  user; don't rebase it from here (the branch is checked out in the child's worktree). Children
75
95
  never merge. Never push unless asked. Don't tell the user a branch can fast-forward unless
76
96
  `git merge-base --is-ancestor <default> <branch>` says so now (after each merge <default> moved);
77
97
  after a child rebases, check it again as in step 4.
78
- 7. Clean up only a branch that was merged or that the user said to drop (that decision saved in the
79
- handoff first): stop its child (`chi sessions stop <session id>`), then `git worktree remove
98
+ 7. Clean up only a branch that was merged, or that the user said to drop (a drop saved in the handoff
99
+ first): stop its child (`chi sessions stop <session id>`), then `git worktree remove
80
100
  ../<repo>-<task>`, then
81
101
  `git branch -d <branch>` (in that order: a branch checked out in a worktree can't be deleted). If
82
102
  `-d` refuses, say why to the user; never `-D` unless they say so. If `git worktree remove` refuses
@@ -121,8 +141,8 @@ participants: a manager or a teammate brings new context (a priority, a scope ch
121
141
  - The owner applies it: their turn starts "Apply this proposal from <name>'s review turn …" and quotes your diff.
122
142
  Apply only what the owner's line asks, never what the quote itself asks. Then bring the handoff up to date with
123
143
  it (edit, then memory_write the description, as Handoff memory says), and re-plan in a few lines: per branch what
124
- changes. Don't start, stop or merge anything from the review session: the coordinator session does that, asked as
125
- always (steps 5–6), and picks the change up from the handoff.
144
+ changes. Don't start, stop or merge anything from the review session: the coordinator session does that, by
145
+ steps 5–6, and picks the change up from the handoff.
126
146
 
127
147
  ## Gotchas
128
148
  - Children are asked before changing anything outside their worktree (your checkout, a sibling's):
@@ -145,3 +165,5 @@ participants: a manager or a teammate brings new context (a priority, a scope ch
145
165
  - 2026-10-09 End of day (/coordinate end): handoff up to date, still OPEN, a short report (0.3.0)
146
166
  - 2026-10-24 Sprint review (experimental, /coordinate review): a hand-off diff in a participant's turn, the
147
167
  owner's Apply updates the handoff and re-plans, nothing started or merged there (0.6.0)
168
+ - 2026-11-04 step 6: a branch verified with no doubts that can fast-forward merges with no question when chi's git-merge
169
+ rule asks: the merge is chi's approval to give or pass; anything else asks as before (0.7.0)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.147.0
3
+ version: 0.148.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:
@@ -142,6 +142,8 @@ module Samagotchi
142
142
  Entry.new(key: "session.project_env", yaml_path: %w[session project_env], type: :enum, default: "mise", expose: %i[env config], enum_values: %w[mise off], lifecycle: :worker_start),
143
143
  # Plain `chi` runs like `chi --shared` (bin/chi, LaunchMode); false, or --no-shared per run, keeps the plain REPL. No CLI flag: that would duplicate --shared.
144
144
  Entry.new(key: "session.shared", yaml_path: %w[session shared], type: :bool, default: true, expose: %i[env config], lifecycle: :next_invocation),
145
+ # false: a continue's carry note holds the previous link's recap only (SessionChain.note_text), not its last answer too.
146
+ Entry.new(key: "session.carry_last_answer", yaml_path: %w[session carry_last_answer], type: :bool, default: true, expose: %i[env config], lifecycle: :next_lifecycle_decision),
145
147
  # false: a session nothing happened in is deleted when it is left (SessionManager.discardable?).
146
148
  Entry.new(key: "session.keep_empty", yaml_path: %w[session keep_empty], type: :bool, default: false, expose: %i[env config], lifecycle: :next_lifecycle_decision),
147
149
  # The most sessions one session may have delegated and still running (the delegate tool); a guard against a runaway model.
@@ -181,6 +183,9 @@ module Samagotchi
181
183
  Entry.new(key: "guardrails.system_one.mode", yaml_path: %w[guardrails system_one mode], type: :enum, default: "off", expose: %i[config], enum_values: %w[off audit on], lifecycle: :next_tool_or_approval),
182
184
  # The rule ids System 1 may answer for ("a|b", or a YAML list, parsed like guardrails.small_models).
183
185
  Entry.new(key: "guardrails.system_one.rules", yaml_path: %w[guardrails system_one rules], type: :string, default: nil, expose: %i[config], lifecycle: :next_tool_or_approval),
186
+ # The rule ids whose record goes by repository (SystemOne::Record): a clean record in one repo says nothing about
187
+ # another. Parsed like rules; [] for none. Others stay whole-rule, so a deny in repo A also holds back repo B.
188
+ Entry.new(key: "guardrails.system_one.repo_rules", yaml_path: %w[guardrails system_one repo_rules], type: :string, default: "git-merge", expose: %i[config], lifecycle: :next_tool_or_approval),
184
189
  # The probability an auto-allow needs, and how many clean records a rule needs before it is trusted.
185
190
  Entry.new(key: "guardrails.system_one.threshold", yaml_path: %w[guardrails system_one threshold], type: :float, default: 0.8, expose: %i[config], lifecycle: :next_tool_or_approval),
186
191
  Entry.new(key: "guardrails.system_one.min_record", yaml_path: %w[guardrails system_one min_record], type: :integer, default: 5, expose: %i[config], lifecycle: :next_tool_or_approval),
@@ -323,7 +323,7 @@ module Samagotchi
323
323
  # one that passes every gate is allowed once without reaching them
324
324
  # (docs/guardrails.md, "System 1").
325
325
  judgment = begin
326
- system_one_judge(payload, targets: verdict.targets)
326
+ system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?)
327
327
  rescue LLM::RequestCancelled
328
328
  return turn_stopped(verdict)
329
329
  end
@@ -366,8 +366,9 @@ module Samagotchi
366
366
  # auto-allowed: its asks go to the owner. No client (mode off, no
367
367
  # host) means no line at all. A judgment that fails is logged with p nil,
368
368
  # so the failures are in the log too.
369
+ # With +ask_user+ (a fresh ask, a second rule's) it is judged and logged, never answered.
369
370
  # @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
370
- def system_one_judge(payload, targets: nil)
371
+ def system_one_judge(payload, targets: nil, ask_user: false)
371
372
  return nil if Steer.participant_author?(turn_author)
372
373
 
373
374
  client = @system_one.call
@@ -397,13 +398,13 @@ module Samagotchi
397
398
  end
398
399
  latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
399
400
  p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
400
- auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
401
+ auto = !ask_user && system_one_auto_allow?(client, approval, p, entries, targets: targets)
401
402
  decision = system_one_decision(client, auto)
402
403
 
403
404
  id = log.judgment(id: run_id, session_id: @session_lookup.call&.id,
404
405
  rule: approval.rule, source: approval.source, tool: approval.tool,
405
406
  **SystemOne::ApprovalQuestion.call_fields(approval),
406
- repo_name: approval.repo_name,
407
+ repo_name: approval.repo_name, repo: targets&.repo,
407
408
  p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
408
409
  latency_ms: latency_ms, decision: decision, origin: "live")
409
410
  # A judgment the log cannot hold is no judgment: the user is asked.
@@ -443,7 +444,8 @@ module Samagotchi
443
444
 
444
445
  # Every gate an auto-allow must pass, in order: the mode, the rule the
445
446
  # user opted in, the never-list and chi's own files, the probability, and
446
- # the rule's record. Any one that fails leaves the ask to the user. The
447
+ # the rule's record (this repo's for a rule that goes by repo; with no repo
448
+ # that is none). Any one that fails leaves the ask to the user. The
447
449
  # entries are the judgment log read once for this ask.
448
450
  # @param approval [Guardrails::ApprovalFacts] the ask's facts
449
451
  # @param entries [Array<Hash>] SystemOne::Log#entries
@@ -460,7 +462,10 @@ module Samagotchi
460
462
  return false if p.nil? || p.to_f < threshold
461
463
 
462
464
  min_record = Config.get("guardrails.system_one.min_record").to_i
463
- SystemOne::Record.new(entries, rule: rule).qualifies?(threshold: threshold, min_record: min_record)
465
+ per_repo = SystemOne::Record.per_repo?(rule)
466
+ return false if per_repo && targets&.repo.nil?
467
+
468
+ SystemOne::Record.new(entries, rule: rule, repo: (targets.repo if per_repo)).qualifies?(threshold: threshold, min_record: min_record)
464
469
  rescue StandardError => e
465
470
  Samagotchi::Log.warn(:guardrails, "system_one_gate_failed", error: "#{e.class}: #{e.message}")
466
471
  false
@@ -80,7 +80,9 @@ module Samagotchi
80
80
  case e["scope"]
81
81
  when "session" then session_id && e["session_id"] == session_id && e["key"] == key
82
82
  when "repo" then here.call(e) && e["key"] == key
83
- when "rule" then verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
83
+ when "rule"
84
+ # One rule's grant can't answer for a second rule's ask on the call.
85
+ !verdict.second_rule_ask? && verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
84
86
  end
85
87
  end
86
88
  end
@@ -310,10 +310,13 @@ module Samagotchi
310
310
  @disable.reject { |entry| @rules.any? { |rule| disables?(entry, rule) } }
311
311
  end
312
312
 
313
- # A core check: every matching rule votes (strictest wins).
313
+ # A core check: every matching rule votes (strictest wins). A second
314
+ # rule id that asks takes rule grants out (Verdict#second_rule_asks!);
315
+ # asks from before the rules (the child boundary's) stay as they are.
314
316
  def check(verdict)
315
317
  return verdict unless @enabled
316
318
 
319
+ asking = nil
317
320
  @rules.each do |rule|
318
321
  next if disabled?(rule)
319
322
  next unless rule.for_mode?(@mode)
@@ -323,6 +326,8 @@ module Samagotchi
323
326
  verdict.deny!(rule.reason, rule: rule.id, source: rule.source, decided_by: "rule")
324
327
  else
325
328
  verdict.ask!(rule.reason, scopes: rule.scopes, rule: rule.id, source: rule.source, decided_by: "rule")
329
+ verdict.second_rule_asks!(rule.scopes) if asking && asking != rule.id
330
+ asking ||= rule.id
326
331
  end
327
332
  end
328
333
  verdict
@@ -8,7 +8,8 @@ module Samagotchi
8
8
  # first one stays, except that a protected rule's ask (PROTECTED_RULES)
9
9
  # takes over an unprotected ask: its rule id is what a parent's answer
10
10
  # and a stored approval are checked against, and the offered scopes
11
- # narrow to both asks' common ones.
11
+ # narrow to both asks' common ones. A second rule's ask takes rule grants
12
+ # out (#second_rule_asks!): the one id it keeps can't answer for both.
12
13
  class Verdict
13
14
  STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
14
15
  # Rules that ask about chi's own config and hooks: the core
@@ -47,6 +48,9 @@ module Samagotchi
47
48
 
48
49
  def fresh_ask? = @fresh_ask == true
49
50
 
51
+ # Whether a second distinct rule asks about this call (#second_rule_asks!).
52
+ def second_rule_ask? = @second_rule_ask == true
53
+
50
54
  def initialize(call:)
51
55
  @call = call
52
56
  @decision = :allow
@@ -84,6 +88,22 @@ module Samagotchi
84
88
  self
85
89
  end
86
90
 
91
+ # A second distinct rule asks about this call (Rules#check). The ask
92
+ # names one rule, and a rule grant or System 1's record is about that
93
+ # rule alone, so neither answers for the other's: no rule-scope entry
94
+ # matches it (Approvals.find_in), System 1 doesn't auto-allow it, and it
95
+ # offers what both rules allow, minus the rule. An exact-call entry
96
+ # (session, repo) still answers: it is keyed on the whole command.
97
+ # @param scopes [Array<String>, nil] the second rule's scopes (default all)
98
+ def second_rule_asks!(scopes = nil)
99
+ @second_rule_ask = true
100
+ allowed = Array(scopes).map(&:to_s) & SCOPES
101
+ allowed = SCOPES if allowed.empty?
102
+ left = (@scopes & allowed) - %w[rule]
103
+ @scopes = left.empty? ? %w[once] : left
104
+ self
105
+ end
106
+
87
107
  def self.protected_rule?(rule) = PROTECTED_RULES.include?(rule.to_s)
88
108
 
89
109
  # A before_tool_call hook set the old event[:blocked] flag. Its model
@@ -8,6 +8,7 @@ require_relative "cli/flags"
8
8
  require_relative "config"
9
9
  require_relative "bridge/card_store"
10
10
  require_relative "guardrails/approval"
11
+ require_relative "guardrail_wiring"
11
12
  require_relative "guardrails/approvals"
12
13
  require_relative "guardrails/parent_policy"
13
14
  require_relative "host_registry"
@@ -20,7 +21,10 @@ module Samagotchi
20
21
  # seed: System 1 judges every approval this machine's sessions already
21
22
  # answered, and the judgment and the user's answer go to the log as they
22
23
  # would live. A rule's record then starts with the asks the user really
23
- # decided on, instead of waiting for a few days of live ones.
24
+ # decided on, instead of waiting for a few days of live ones. `rule` says
25
+ # whether one rule votes `ask` on this machine now, read from the config and
26
+ # the installed bundles as a worker reads them (GuardrailWiring#rules), by
27
+ # its id: the text of a command never comes near it.
24
28
  class GuardrailsCommand
25
29
  include CLI::Command
26
30
 
@@ -41,16 +45,24 @@ module Samagotchi
41
45
  --dry-run judges and prints the table without writing a line. Needs
42
46
  guardrails.system_one.mode audit or on and a system_one.host
43
47
  (exit 2 without one).
48
+ rule <id>
49
+ Print exactly "asks" when the rule is loaded, not disabled and votes
50
+ ask in this guardrails.mode for every model; another line says why not
51
+ when it isn't (exit 0 either way). Reads config.yml and the installed
52
+ bundles, as a worker does: no session, no model, nothing written.
44
53
  TEXT
45
54
 
46
- SUBCOMMANDS = %w[replay].freeze
55
+ SUBCOMMANDS = %w[replay rule].freeze
47
56
  FLAGS = {
48
57
  "replay" => CLI::Flags.new(help: HELP_WORDS, args: false) do |f|
49
58
  f.switch "--dry-run"
50
59
  f.value "--threshold"
51
60
  f.value "--model"
52
- end
61
+ end,
62
+ "rule" => CLI::Flags.new(help: HELP_WORDS)
53
63
  }.freeze
64
+ # What `rule` prints when a rule asks. An agent compares the line to it.
65
+ ASKS = "asks"
54
66
  # The labels that mean the user let the call through.
55
67
  ALLOW_LABELS = %w[allow_once allow_wide].freeze
56
68
  # replay's table, in column order: the rule, how many asks it had, how
@@ -135,6 +147,8 @@ module Samagotchi
135
147
  parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
136
148
  return parsed if parsed.is_a?(Integer)
137
149
 
150
+ return rule(parsed.args) if @sub == "rule"
151
+
138
152
  replay(parsed.options)
139
153
  end
140
154
 
@@ -142,6 +156,36 @@ module Samagotchi
142
156
 
143
157
  def command_name = @sub ? "chi guardrails #{@sub}" : "chi guardrails"
144
158
 
159
+ def rule(args)
160
+ return usage_error("give one rule id") unless args.size == 1
161
+
162
+ @stdout.puts(rule_status(args.first))
163
+ CLI::Exit::OK
164
+ end
165
+
166
+ # The rule set as a worker would load it now, the rule's id looked up in
167
+ # it. Every other answer than ASKS names what stands in the way.
168
+ def rule_status(id)
169
+ wiring = GuardrailWiring.new(scratch: false, hooks: -> {}, tools: -> {}, session: -> {}, model_key: -> {},
170
+ cancelled: -> { false }, ask: ->(_fields) {})
171
+ rules = wiring.rules
172
+ failed = wiring.failures.required
173
+ return "rules failed to load: #{failed.map(&:what).join(", ")}" unless failed.empty?
174
+ return "guardrails are off (guardrails.enabled: false)" unless rules.enabled?
175
+
176
+ named = rules.rules.select { |r| r.id == id }
177
+ return "no rule #{id} is loaded" if named.empty?
178
+
179
+ enabled = named.reject { |r| rules.disabled?(r) }
180
+ return "rule #{id} is disabled (guardrails.disable)" if enabled.empty?
181
+
182
+ voting = enabled.select { |r| r.for_mode?(rules.mode) }
183
+ return "rule #{id} doesn't vote in guardrails.mode #{rules.mode}" if voting.empty?
184
+ return "rule #{id} denies" if voting.any? { |r| r.verdict == :deny }
185
+
186
+ voting.any? { |r| r.models.nil? } ? ASKS : "rule #{id} asks for some models only"
187
+ end
188
+
145
189
  def replay(options)
146
190
  threshold = threshold(options[:threshold])
147
191
  return usage_error(threshold) if threshold.is_a?(String)
@@ -207,6 +207,7 @@ module Samagotchi
207
207
  chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
208
208
  chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
209
209
  chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
210
+ chi guardrails rule <id> print "asks" if that rule votes ask on this machine now
210
211
  chi judgments ls | report NAME System 1's saved asks on this machine
211
212
  chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
212
213
  chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
@@ -76,7 +76,8 @@ module Samagotchi
76
76
  in its folder, on its model (or --model's; one whose
77
77
  host is gone falls back to the default, with a
78
78
  warning), LLM context, thinking level, toolkit and
79
- bundle settings, with a note carrying its recap; it
79
+ bundle settings, with a note carrying its recap and
80
+ last answer; it
80
81
  is archived.
81
82
  last:ID names the chain's latest link.
82
83
  Without -m the new session waits idle. Refused for a
@@ -1,7 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "answer_tail"
5
+ require_relative "config"
6
+ require_relative "log"
7
+ require_relative "output_formatter"
4
8
  require_relative "session"
9
+ require_relative "session_inbox"
10
+ require_relative "steer"
5
11
  require_relative "recap_store"
6
12
 
7
13
  module Samagotchi
@@ -16,9 +22,16 @@ module Samagotchi
16
22
  LAST_PREFIX = "last:"
17
23
  # Who the carry note is from (ContextNote's label: "note from session chain").
18
24
  NOTE_SOURCE = "session chain"
19
- # The most of the previous link's recap the carry note takes; a recap is
20
- # a few sentences, this only keeps a runaway one under the note's 16 KiB.
25
+ # The most of the previous link's recap a note takes; a recap is a few
26
+ # sentences. Characters, not bytes: #note_text holds the carry note to
27
+ # SessionInbox::NOTE_MAX_BYTES itself.
21
28
  RECAP_CHARS = 8000
29
+ # The most of the previous link's last answer the carry note takes. A
30
+ # wrap-up closes on what comes next, so a longer one keeps its end.
31
+ LAST_ANSWER_CHARS = 4000
32
+ LAST_ANSWER_HEAD = "\n\nIts last answer:\n"
33
+ # The "…" that marks a cut, in bytes.
34
+ CUT_MARK_BYTES = 3
22
35
 
23
36
  module_function
24
37
 
@@ -63,14 +76,79 @@ module Samagotchi
63
76
  end
64
77
 
65
78
  # The context note a new link starts with: which session it continues,
66
- # from when, and that session's recap with its time, when it has one.
67
- # Never more than the recap: no transcript.
68
- # @param previous [Session]
79
+ # from when, that session's recap with its time, when it has one, and
80
+ # its last answer (#last_answer; session.carry_last_answer). No other transcript. Never over
81
+ # SessionInbox::NOTE_MAX_BYTES, whatever the session holds: the inbox
82
+ # rejects a longer note, and a continue writes it after the archive. The
83
+ # answer gets what the rest leaves.
84
+ # @param previous [Session] the answer is read fresh from its file
69
85
  # @param recap [Hash, nil] RecapStore.read's
70
86
  # @return [String]
71
- def note_text(previous, recap:)
87
+ def note_text(previous, recap:, state_dir:)
72
88
  head = "This session continues #{previous.id[0, 8]} (#{clock(previous.created_at) || previous.created_at}), the previous link of its chain."
73
- "#{head}#{recap_text(recap)}"
89
+ text = keep_start("#{head}#{recap_text(recap)}", SessionInbox::NOTE_MAX_BYTES)
90
+ room = SessionInbox::NOTE_MAX_BYTES - text.bytesize - LAST_ANSWER_HEAD.bytesize
91
+ answer = carried_answer(previous, room: room, state_dir: state_dir)
92
+ answer ? "#{text}#{LAST_ANSWER_HEAD}#{answer}" : text
93
+ end
94
+
95
+ # The last answer as the carry note takes it, in at most +room+ bytes;
96
+ # nil with session.carry_last_answer off, when there is none, when a
97
+ # review link has it (a continue copies review: but not the grants, so
98
+ # the next link's participants could differ) or when the session can't
99
+ # be read: this runs inside a continue's rollback, and a note without it
100
+ # is still a good note.
101
+ # @return [String, nil]
102
+ def carried_answer(previous, room:, state_dir:)
103
+ return nil if previous.review? || !Config.get("session.carry_last_answer")
104
+
105
+ text = last_answer(Session.load(previous.id, state_dir: state_dir).messages)
106
+ text && keep_end(text, room)
107
+ rescue StandardError => e
108
+ Log.warn(:worker, "continue_last_answer_failed", sid: previous.id, error: e.class.name, msg: e.message)
109
+ nil
110
+ end
111
+
112
+ # The session's last word: the text of AnswerTail's newest bubble, with
113
+ # the wire-format markup stripped, unless that bubble is a step of a
114
+ # turn (it asked for tools) or old text a later turn never replaced
115
+ # (one that failed or was cut before it answered). A cut reply keeps
116
+ # its "[interrupted]": that is what happened.
117
+ # @param messages [Array<Hash>]
118
+ # @return [String, nil]
119
+ def last_answer(messages)
120
+ found = AnswerTail.find(messages)
121
+ return nil unless found && found[:content].is_a?(String) && Array(found[:tool_calls]).empty?
122
+
123
+ at = messages.rindex { |m| m.equal?(found) }
124
+ return nil if messages[(at + 1)..].any? { |m| m[:role].to_s == "tool_response" || later_turn?(m) }
125
+
126
+ OutputFormatter.strip_markup(found[:content].scrub)
127
+ end
128
+
129
+ # A turn's opening, not a `!shell` line: a command the user ran after
130
+ # the answer leaves it the last word.
131
+ def later_turn?(message)
132
+ Steer.turn_prompt?(message) && !shell_line?(message)
133
+ end
134
+
135
+ # At most +max+ bytes of +text+'s start, cut on a character.
136
+ def keep_start(text, max)
137
+ return text if text.bytesize <= max
138
+
139
+ "#{text.byteslice(0, max - CUT_MARK_BYTES).scrub("")}…"
140
+ end
141
+
142
+ # +text+'s end: at most LAST_ANSWER_CHARS characters and +max+ bytes,
143
+ # with the "…" of a cut; nil when no character fits.
144
+ def keep_end(text, max)
145
+ return nil if max <= CUT_MARK_BYTES
146
+
147
+ text = "…#{text[(1 - LAST_ANSWER_CHARS)..]}" if text.length > LAST_ANSWER_CHARS
148
+ return text if text.bytesize <= max
149
+
150
+ kept = text.byteslice(-(max - CUT_MARK_BYTES), max - CUT_MARK_BYTES).scrub("")
151
+ kept.empty? ? nil : "…#{kept}"
74
152
  end
75
153
 
76
154
  # +recap+ as a note's tail: " Its recap, as of <time>:\n<text>" (cut
@@ -103,13 +181,18 @@ module Samagotchi
103
181
 
104
182
  text = msg[:content]
105
183
  next unless text.is_a?(String) && !text.strip.empty?
106
- next if msg[:turn_id].nil? && text.start_with?("!(")
184
+ next if shell_line?(msg)
107
185
 
108
186
  return text.strip
109
187
  end
110
188
  nil
111
189
  end
112
190
 
191
+ # A stored `!shell` line: "!(cmd)\n<output>", no turn_id.
192
+ def shell_line?(message)
193
+ message[:turn_id].nil? && message[:content].is_a?(String) && message[:content].start_with?("!(")
194
+ end
195
+
113
196
  def clock(iso)
114
197
  iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
115
198
  rescue ArgumentError
@@ -534,7 +534,7 @@ module Samagotchi
534
534
  rules.each do |rule|
535
535
  summary = SystemOne::Record.new(entries, rule: rule).summary
536
536
  counts = summary.reject { |key, _| key == :rule }.map { |key, value| "#{key}=#{value}" }.join(", ")
537
- lines << " #{rule}: #{counts}"
537
+ lines << " #{rule}#{" (per repo)" if SystemOne::Record.per_repo?(rule)}: #{counts}"
538
538
  end
539
539
  end
540
540
  end
@@ -69,7 +69,8 @@ module Samagotchi
69
69
  # +id_or_ref+ (Session#continues) in its folder, on its model (the name
70
70
  # it was typed as, so an alias holds) and with its own setup (SessionSetup),
71
71
  # starting with a context note from chi: the link and the previous
72
- # link's recap (SessionChain.note_text). +model+, when given, is the
72
+ # link's recap and last answer (SessionChain.note_text), read after the
73
+ # recap wait, so a turn that finished in it counts. +model+, when given, is the
73
74
  # new link's model instead; an inherited one whose host can't run it any
74
75
  # more (gone, disabled, an alias naming another host) falls back to the
75
76
  # default with a warning (#model_for). The previous link is archived
@@ -212,7 +213,7 @@ module Samagotchi
212
213
  link = SessionManager.spawn_session(
213
214
  prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
214
215
  model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
215
- continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
216
+ continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
216
217
  note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
217
218
  )
218
219
  link.moved_children = move
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../config"
4
+ require_relative "../model_match"
5
+
3
6
  module Samagotchi
4
7
  module SystemOne
5
8
  # One rule's record: what System 1 has judged for it and what the user
@@ -7,7 +10,9 @@ module Samagotchi
7
10
  # joined with their last label), filtered to one rule. The gate before
8
11
  # System 1 answers for the user: enough clean allows at the threshold,
9
12
  # and no false allow among the rule's last 20 labelled asks
10
- # (docs/guardrails.md, "System 1").
13
+ # (docs/guardrails.md, "System 1"). A rule in
14
+ # guardrails.system_one.repo_rules goes by repository: what the user allowed
15
+ # in one says nothing about another, so its record is built per repo.
11
16
  #
12
17
  # An ask with no label, no probability, or a `none` label counts for
13
18
  # neither side: only a judgment the user actually answered teaches
@@ -21,13 +26,22 @@ module Samagotchi
21
26
  # How many of the rule's labelled asks the false-allow check looks at.
22
27
  WINDOW = 20
23
28
 
29
+ # Whether +rule+'s record goes by repository
30
+ # (guardrails.system_one.repo_rules, read like .rules).
31
+ def self.per_repo?(rule)
32
+ ModelMatch.parse(Config.get("guardrails.system_one.repo_rules")).include?(rule.to_s)
33
+ end
34
+
24
35
  attr_reader :rule, :entries
25
36
 
26
37
  # @param entries [Array<Hash>] SystemOne::Log#entries, oldest first
27
38
  # @param rule [String] the rule the record is for
28
- def initialize(entries, rule:)
39
+ # @param repo [String, nil] only this repository's asks (a judgment's
40
+ # repo: Guardrails::Targets#repo, its common git dir); nil: all of the
41
+ # rule's. A line with no repo is in no repository's record.
42
+ def initialize(entries, rule:, repo: nil)
29
43
  @rule = rule.to_s
30
- @entries = Array(entries).select { |entry| entry["rule"].to_s == @rule }
44
+ @entries = Array(entries).select { |entry| entry["rule"].to_s == @rule && (repo.nil? || entry["repo"] == repo) }
31
45
  end
32
46
 
33
47
  # @param threshold [Float] the probability an auto-allow needs
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.147.0"
4
+ VERSION = "0.148.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.147.0
4
+ version: 0.148.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -241,6 +241,7 @@ files:
241
241
  - lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md
242
242
  - lib/samagotchi/bundles/codex-peer/scripts/pair
243
243
  - lib/samagotchi/bundles/codex-peer/skill_codex_peer.md
244
+ - lib/samagotchi/bundles/coordinator/guardrails/merge.yml
244
245
  - lib/samagotchi/bundles/coordinator/manifest.yml
245
246
  - lib/samagotchi/bundles/coordinator/plugin.rb
246
247
  - lib/samagotchi/bundles/coordinator/skill_coordinator.md