samagotchi 0.35.0 → 0.37.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/README.md +2 -2
  4. data/bin/chi +1 -1
  5. data/docs/broadcast.md +91 -12
  6. data/docs/configuration.md +8 -1
  7. data/docs/context.md +37 -0
  8. data/docs/guardrails.md +26 -3
  9. data/docs/hooks.md +1 -1
  10. data/docs/memory.md +3 -0
  11. data/docs/plugins.md +51 -4
  12. data/lib/samagotchi/broadcast/triage.rb +267 -0
  13. data/lib/samagotchi/broadcast/triage_log.rb +165 -0
  14. data/lib/samagotchi/broadcast/triage_model.rb +56 -0
  15. data/lib/samagotchi/broadcast_command.rb +273 -69
  16. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  17. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  18. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +11 -0
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  23. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/mcp/plugin.rb +72 -27
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  27. data/lib/samagotchi/cli/command.rb +27 -3
  28. data/lib/samagotchi/config.rb +9 -0
  29. data/lib/samagotchi/context_command.rb +0 -17
  30. data/lib/samagotchi/engine.rb +4 -6
  31. data/lib/samagotchi/guardrail_wiring.rb +7 -2
  32. data/lib/samagotchi/guardrails/approval.rb +5 -2
  33. data/lib/samagotchi/guardrails/approvals.rb +9 -5
  34. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  35. data/lib/samagotchi/guardrails/rules.rb +19 -6
  36. data/lib/samagotchi/guardrails/targets.rb +83 -11
  37. data/lib/samagotchi/idle_client.rb +48 -28
  38. data/lib/samagotchi/idle_recap.rb +6 -5
  39. data/lib/samagotchi/idle_target.rb +95 -0
  40. data/lib/samagotchi/llm/openai_chat.rb +29 -3
  41. data/lib/samagotchi/log_line.rb +1 -1
  42. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  43. data/lib/samagotchi/note_command.rb +0 -21
  44. data/lib/samagotchi/recap_setup.rb +38 -52
  45. data/lib/samagotchi/send_command.rb +0 -22
  46. data/lib/samagotchi/session_commands.rb +1 -0
  47. data/lib/samagotchi/tool_activity.rb +14 -3
  48. data/lib/samagotchi/tool_declarations.rb +8 -4
  49. data/lib/samagotchi/tools/builtins.rb +1 -1
  50. data/lib/samagotchi/tools/memory.rb +103 -2
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/public/question_card.js +4 -3
  53. metadata +5 -1
@@ -3,6 +3,43 @@
3
3
  Use when the user hands over work with several independent parts, or asks to "run it in parallel",
4
4
  "delegate", "coordinate". The user decides; you do the legwork and keep them in the loop briefly.
5
5
 
6
+ ## Handoff memory
7
+ Your conversation is lost when this session ends; git and the session list are not. Keep a handoff in a
8
+ project-scope memory named `handoff_<epic-slug>` (the epic or goal in a few lowercase words joined by
9
+ `-`, e.g. `handoff_calc-v2`). Create it with memory_write (scope: project). Its description is the status:
10
+ one line under 200 characters that names its owner and says where things stand, never what to do (every
11
+ session in this repo sees it, children too): "OPEN coordinator handoff calc-v2 (session <your id>): 2/3
12
+ merged, docs kept (user)", later "DONE: …". Change the body with `edit` on the memory's file (in the
13
+ Project memories folder), and after every change of state change the status with memory_write name,
14
+ scope and description only (no content). It holds ONLY what git and the session list can't rebuild:
15
+ - Goal: the goal in one line and the split (task → branch, worktree, child session id).
16
+ - Decisions: what the user said per branch (merge / keep / drop / "don't touch X"), their words short.
17
+ - Verdicts: your verdict per report (verified / doubts / failed) and why, in one line.
18
+ - Follow-ups: found but not fixed.
19
+ Not commit lists, diffs, test output or anything a git command shows again. Update it right after:
20
+ the split started (step 3), each verdict (step 4), each user decision (an ask_user_question answer or a
21
+ plain message: save it before you act on it), each merge or cleanup (steps 6, 7). Before you end a turn
22
+ in which any of these changed, check the memory and its description have it. Short: under 40 lines.
23
+ One handoff per epic: never write another epic's handoff_* memory.
24
+
25
+ ## Resume (step 0)
26
+ Before step 1, look at the project memory index for `handoff_*` entries not marked DONE (`/coordinate
27
+ resume` picks one for the user). If one is open and the user's request continues it, or doesn't name new
28
+ work ("hi", "what's next?", "where were we?"), do all of this before you answer:
29
+ 1. Read this skill if you haven't in this session, and memory_read the handoff: its body is the state,
30
+ not the index line (that line is from when this session started). Then rebuild the truth:
31
+ `git worktree list`, `git branch --list`, for each branch in it `git log --oneline <default>..<branch>`
32
+ and `git merge-base --is-ancestor <branch> <default>`, and the list_sessions tool for the child ids
33
+ on record (not listed is not proof a child is gone).
34
+ 2. Git and the sessions are the truth; the memory is your notes. Tell the user, per branch: its state
35
+ now, the decision and verdict on record, and every mismatch (merged by hand, worktree gone, branch
36
+ deleted, a child that ended without a verdict on record), in one short list.
37
+ 3. Update the memory and its description to match the truth (keep the decisions; mark the mismatches;
38
+ the owner is now you), then go on from the first open step. Don't redo a merge or cleanup git shows
39
+ done.
40
+ If the request is a new goal, say which handoffs are open (one line each) and go on with a new
41
+ `handoff_<slug>` of its own; don't touch the others.
42
+
6
43
  ## Steps
7
44
  1. Find the repository's default branch, <default> below: `git symbolic-ref --short
8
45
  refs/remotes/origin/HEAD` (drop the `origin/`), else `main` or `master`, whichever exists; ask the
@@ -13,34 +50,43 @@ Use when the user hands over work with several independent parts, or asks to "ru
13
50
  self-contained task. The task starts with "Work only in <absolute worktree path>: every cd, read,
14
51
  write, test and commit goes there, never the project root" and says what to report (the result,
15
52
  evidence: commands and what they printed, what isn't done) and "commit on the branch, don't merge,
16
- don't push".
53
+ don't push, don't write or edit any handoff_* memory".
17
54
  3. Tell the user what started (one line per child: its id, branch and task) and end your turn or keep
18
55
  talking. Don't call delegate_result: chi brings each child's reply as a delegate report, also when
19
56
  you are idle.
20
57
  4. On each report, check it before telling the user: `git -C <worktree> log --oneline <default>..` and
21
58
  `git diff --stat <default>...<branch>`; run the tests the child names (execute with cwd: the worktree).
22
- Say the result in one or two lines: done and verified / done with doubts / failed.
23
- 5. Then one of: a follow-up (`delegate session: <id>`, narrower, `wait: false`; then end your turn:
24
- its report comes by itself, so don't wait for it with delegate_result or task_wait), stop it
59
+ Say the result in one or two lines: done and verified / done with doubts / failed. In the same turn,
60
+ write that verdict into the handoff body and change its description to match (description only).
61
+ 5. Then one of (a user's decision about it goes into the handoff first): a follow-up (`delegate
62
+ session: <id>`, narrower, `wait: false`; then end your turn: its report comes by itself, so don't
63
+ wait for it with delegate_result or task_wait), stop it
25
64
  (`chi sessions stop <session id>` with execute; task_stop is for task ids, not children; the user
26
65
  can use /children), or tell the user it is ready to merge.
27
- 6. Merging is the user's call: ask (ask_user_question) per branch, "Merge <branch> into <default>?".
66
+ 6. Merging is the user's call: ask (ask_user_question) per branch, "Merge <branch> into <default>?", and
67
+ save the answer in the handoff before you act on it.
28
68
  Before merging, in your own folder: `git branch --show-current` must print <default> (if it
29
69
  doesn't, stop and ask the user; don't switch branches yourself); then check <default>:
30
70
  `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
31
71
  again. Merge with `git merge --ff-only <branch>` yourself, in your own folder. If that fails, ask the
32
72
  child to rebase its branch on <default> (a follow-up that says "rebase only, don't merge") or ask the
33
73
  user; don't rebase it from here (the branch is checked out in the child's worktree). Children
34
- never merge. Never push unless asked.
35
- 7. Clean up only a branch that was merged or that the user said to drop: stop its child
36
- (`chi sessions stop <session id>`), then `git worktree remove ../<repo>-<task>`, then
74
+ never merge. Never push unless asked. Don't tell the user a branch can fast-forward unless
75
+ `git merge-base --is-ancestor <default> <branch>` says so now (after each merge <default> moved);
76
+ after a child rebases, check it again as in step 4.
77
+ 7. Clean up only a branch that was merged or that the user said to drop (that decision saved in the
78
+ handoff first): stop its child (`chi sessions stop <session id>`), then `git worktree remove
79
+ ../<repo>-<task>`, then
37
80
  `git branch -d <branch>` (in that order: a branch checked out in a worktree can't be deleted). If
38
- `-d` refuses, say why to the user; never `-D` unless they say so. Remove only worktrees and
39
- branches you created in this session. Every other branch stays as it is, its child, worktree and
81
+ `-d` refuses, say why to the user; never `-D` unless they say so. If `git worktree remove` refuses
82
+ over untracked or ignored files, show `git -C <worktree> status --short --ignored` and ask; never
83
+ `rm -rf`. Remove only worktrees and branches you created in this session. Every other branch stays as it is, its child, worktree and
40
84
  branch too (the user said "keep it", or didn't say): leave them and tell the user they remain.
41
85
  8. When all are done, report: what merged, what didn't and why, what you cleaned up and what remains,
42
86
  follow-ups found but not fixed. Report only what the commands' output showed: a step you planned
43
- but didn't run, or whose output you didn't see, is not done; say so.
87
+ but didn't run, or whose output you didn't see, is not done; say so. Then set the handoff's
88
+ description to "DONE: …" and ask the user whether to remove it (memory_write name, scope and
89
+ remove: true).
44
90
 
45
91
  ## Gotchas
46
92
  - Children are asked before changing anything outside their worktree (your checkout, a sibling's):
@@ -58,3 +104,5 @@ Use when the user hands over work with several independent parts, or asks to "ru
58
104
  ## Changelog
59
105
  - 2026-10-06 created (coordinator bundle 0.1.0)
60
106
  - 2026-10-06 clean up only merged or dropped branches; report only what commands showed (0.1.2)
107
+ - 2026-10-07 handoff memory with its status in the description, resume (step 0, /coordinate resume),
108
+ save decisions first, fast-forward and worktree-remove checks, remove the handoff when done (0.2.0)
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: github-pr
3
- version: 0.1.0
3
+ version: 0.2.0
4
4
  scope: system
5
- description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change
5
+ description: Attaches the branch's open GitHub pull request to a session as context (and any PR URL with chi context add or the web's "+ URL"); the agent gets a note when reviews, comments, checks or its state change; in the web, a path:line in an answer links to that line of the PR
6
6
  trust_level: reviewed
7
7
  requires_chi: ">= 0.32.0"
8
8
  needs:
@@ -11,7 +11,7 @@ needs:
11
11
  hint: brew install gh, then gh auth login
12
12
  plugin:
13
13
  file: plugin.rb
14
- sha256: sha256:b825731983a402d0cd006a17236794ef03e31ab1b160e853756f4d63b295f664
14
+ sha256: sha256:f8b9794f84681f3a16a4d49da3b857a59fec3ccdc2d0a2b16d1192fd2a26bdf3
15
15
  scripts:
16
16
  pr_context.rb: sha256:a883d2780e89c68c0d7b6d5e0fcb22c273f17bb167e79064e0e99db6bd2ee6f0
17
17
  context_providers:
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "json"
4
5
  require "open3"
6
+ require "tmpdir"
5
7
 
6
8
  # github-pr: a session on a branch with an open GitHub PR gets the PR
7
9
  # attached as context (docs/context.md) when its worker starts: a quiet
@@ -10,12 +12,46 @@ require "open3"
10
12
  # serves `chi context add <PR URL>` and the web's "+ URL"). Nothing for a
11
13
  # scratch session or a delegate child (D12), and nothing, quietly, without
12
14
  # gh, its login, a repo or an open PR.
15
+ #
16
+ # Line links: in the web, a `lib/foo.rb:28` (or `lib/foo.rb:28-34`, in
17
+ # inline code too) in the model's answer links to that line of the PR the
18
+ # session reviews: the PR's Files changed view when the lines are in a
19
+ # hunk, else the file at the PR's head. The PRs are the session's attached
20
+ # github-pr sources and the PR URLs in the user's messages (a delegate
21
+ # child's task is its first one). Display only (event[:present]): the
22
+ # model's text stays as it was. The after_turn hook links from a per-PR
23
+ # cache and never runs gh (the web holds the answer until the hooks are
24
+ # done); before_turn, before_generation and the init task fill the cache
25
+ # off the turn's thread. `bundles: github-pr: line_links: false` turns it
26
+ # off.
13
27
  class Plugin
14
28
  # gh and git each get this long (a network hang must not keep a thread).
15
29
  COMMAND_SECONDS = 20
30
+ # before_generation looks for a newly attached PR at most this often.
31
+ GENERATION_CHECK_SECONDS = 30
32
+
33
+ def initialize
34
+ @cache = PrCache.new
35
+ @generation_checked_at = nil
36
+ end
16
37
 
17
38
  def register(chi)
18
- chi.init("Looking for this branch's pull request", quiet: true) { |ctx| attach_branch_pr(ctx) }
39
+ chi.init("Looking for this branch's pull request", quiet: true) do |ctx|
40
+ summary = attach_branch_pr(ctx)
41
+ # Inline (an init task has its own thread), whatever the attach
42
+ # returned (attached already, a child, a scratch session), and after
43
+ # it, so a PR it just attached is warm for the first answer.
44
+ refresh(ctx, attached_prs(ctx)) if line_links?(ctx)
45
+ summary
46
+ end
47
+ chi.on(:before_turn) do |event, ctx|
48
+ # The prompt too: messages is the history before this turn, and a
49
+ # delegate child's task is its first prompt.
50
+ kick(ctx, attached_prs(ctx) + PrRef.from_messages(event[:messages], prompt: event[:prompt])) if line_links?(ctx)
51
+ end
52
+ chi.on(:before_generation) { |_event, ctx| check_attached(ctx) }
53
+ # After source-links (90): its links are in the display this one gets.
54
+ chi.on(:after_turn, priority: 100) { |event, ctx| link_answer(event, ctx) }
19
55
  end
20
56
 
21
57
  # @return [String] what it did (the init task's summary)
@@ -41,11 +77,102 @@ class Plugin
41
77
  "no pull request attached"
42
78
  end
43
79
 
80
+ # The after_turn handler: links the answer's `path:line` refs from the
81
+ # PRs' cached data. Never runs gh: nothing cached yet links nothing.
82
+ def link_answer(event, ctx)
83
+ return unless line_links?(ctx) && event[:status].to_s == "completed" && event[:present]
84
+
85
+ text = LineLinker.last_model_text(event[:messages])
86
+ return if text.nil? || !text.match?(LineLinker::QUICK)
87
+
88
+ prs = (attached_prs(ctx) + PrRef.from_messages(event[:messages])).uniq
89
+ cached = prs.filter_map { |pr| (data = @cache[pr]) && [pr, data] }
90
+ return if cached.empty?
91
+
92
+ event[:present].call { |display| LineLinker.new(cached).link(display) }
93
+ end
94
+
95
+ # Fetches the PRs in +prs+ whose cache entry is missing or old, on this
96
+ # thread, unless a refresh runs already (the init task; #kick's thread).
97
+ def refresh(ctx, prs)
98
+ stale = @cache.stale(prs.uniq, now)
99
+ return if stale.empty? || !@cache.claim
100
+
101
+ begin
102
+ stale.each { |pr| fetch(ctx, pr) }
103
+ ensure
104
+ @cache.release
105
+ end
106
+ end
107
+
44
108
  private
45
109
 
110
+ def line_links?(ctx) = ctx.settings["line_links"] != false
111
+
112
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+
114
+ # #refresh on its own thread (a raw one, docs/plugins.md), when there is
115
+ # something to fetch and no refresh runs. Process exit ends it; a gh
116
+ # still running then finishes alone.
117
+ def kick(ctx, prs)
118
+ prs = prs.uniq
119
+ return if prs.empty? || @cache.refreshing? || @cache.stale(prs, now).empty?
120
+
121
+ thread = Thread.new { refresh(ctx, prs) }
122
+ thread.report_on_exception = false
123
+ end
124
+
125
+ # A PR attached while the turn runs (a parent attaching it right after
126
+ # `delegate wait: false`), checked at most every GENERATION_CHECK_SECONDS.
127
+ def check_attached(ctx)
128
+ return unless line_links?(ctx)
129
+ return if @generation_checked_at && now - @generation_checked_at < GENERATION_CHECK_SECONDS
130
+
131
+ @generation_checked_at = now
132
+ kick(ctx, attached_prs(ctx))
133
+ end
134
+
135
+ # The session's attached github-pr sources, as PrRefs.
136
+ def attached_prs(ctx)
137
+ ctx.context.list.filter_map { |source| PrRef.parse(source[:hint]) if source[:provider] == "github-pr" }
138
+ rescue StandardError => e
139
+ ctx.log.debug(:pr_lines_no_sources, error: e.class.name)
140
+ []
141
+ end
142
+
143
+ # The PR's head and base, and its files when the head moved (or none
144
+ # are cached). A failure is remembered, so the next kicks wait.
145
+ def fetch(ctx, pr)
146
+ shas = run(ctx, "gh", "api", "repos/#{pr.owner}/#{pr.repo}/pulls/#{pr.number}", "--jq",
147
+ '.head.sha + " " + .base.sha', chdir: Dir.tmpdir).to_s.split
148
+ return failed(ctx, pr, "no pull request data") unless shas.size == 2
149
+
150
+ cached = @cache[pr]
151
+ files = cached.files if cached && cached.head_sha == shas[0]
152
+ files ||= fetch_files(ctx, pr) or return failed(ctx, pr, "no file list")
153
+ @cache.store(pr, PrData.new(head_sha: shas[0], base_sha: shas[1], files: files, fetched_at: now))
154
+ ctx.log.debug(:pr_lines_fetched, pr: pr.url, files: files.size)
155
+ rescue StandardError => e
156
+ failed(ctx, pr, "#{e.class.name}: #{e.message.to_s[0, 200]}")
157
+ end
158
+
159
+ # The PR's files, one JSON object a line (--jq), every page.
160
+ def fetch_files(ctx, pr)
161
+ out = run(ctx, "gh", "api", "--paginate", "repos/#{pr.owner}/#{pr.repo}/pulls/#{pr.number}/files?per_page=100",
162
+ "--jq", ".[] | {filename, status, previous_filename, patch}", chdir: Dir.tmpdir) or return nil
163
+ out.each_line.filter_map { |line| PrFile.from_api(JSON.parse(line)) unless line.strip.empty? }
164
+ end
165
+
166
+ # Logged once per PR until a fetch of it succeeds (no gh, no login, no
167
+ # network: every kick would say it again).
168
+ def failed(ctx, pr, why)
169
+ ctx.log.info(:pr_lines_not_fetched, pr: pr.url, why: why) if @cache.fail(pr, now)
170
+ nil
171
+ end
172
+
46
173
  # stdout of a command that succeeded within COMMAND_SECONDS, else nil.
47
- def run(ctx, *command)
48
- Open3.popen2(*command, chdir: ctx.cwd, err: File::NULL, pgroup: true) do |stdin, stdout, wait|
174
+ def run(ctx, *command, chdir: ctx.cwd)
175
+ Open3.popen2(*command, chdir: chdir, err: File::NULL, pgroup: true) do |stdin, stdout, wait|
49
176
  stdin.close
50
177
  reader = Thread.new { stdout.read }
51
178
  unless wait.join(COMMAND_SECONDS)
@@ -63,3 +190,317 @@ class Plugin
63
190
  nil
64
191
  end
65
192
  end
193
+
194
+ # A GitHub pull request, by its URL.
195
+ PrRef = Data.define(:owner, :repo, :number)
196
+
197
+ class PrRef
198
+ # A PR URL anywhere in a text (trailing text allowed, like the provider's
199
+ # match: `…/pull/42/files`).
200
+ URL = %r{https://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)}
201
+ # PRs taken from the user's messages, newest first.
202
+ FROM_MESSAGES = 3
203
+
204
+ # @return [PrRef, nil]
205
+ def self.parse(text)
206
+ match = text.to_s.match(URL) or return nil
207
+ new(owner: match[1], repo: match[2], number: match[3].to_i)
208
+ end
209
+
210
+ # The PRs the user's messages (and +prompt+, the turn's own) name, newest
211
+ # first, up to FROM_MESSAGES distinct. Not tool results or answers: `gh pr
212
+ # list` output, logs or a CHANGELOG name unrelated PRs.
213
+ def self.from_messages(messages, prompt: nil)
214
+ texts = Array(messages).filter_map do |message|
215
+ next unless message.is_a?(Hash)
216
+
217
+ role = message.key?(:role) ? message[:role] : message["role"]
218
+ (message.key?(:content) ? message[:content] : message["content"]).to_s if role.to_s == "user"
219
+ end
220
+ texts << prompt.to_s if prompt
221
+ found = []
222
+ texts.reverse_each do |text|
223
+ text.scan(URL).reverse_each do |owner, repo, number|
224
+ pr = new(owner: owner, repo: repo, number: number.to_i)
225
+ found << pr unless found.include?(pr)
226
+ return found if found.size >= FROM_MESSAGES
227
+ end
228
+ end
229
+ found
230
+ end
231
+
232
+ def url = "https://github.com/#{owner}/#{repo}/pull/#{number}"
233
+ end
234
+
235
+ # One file of a PR: its path (the new name), a rename's old one, its status
236
+ # ("added", "modified", "removed", "renamed", …) and its hunks as line
237
+ # ranges on each side (none: a binary file or too large a diff).
238
+ PrFile = Data.define(:path, :previous_path, :status, :new_hunks, :old_hunks)
239
+
240
+ class PrFile
241
+ # A hunk header: `@@ -a,b +c,d @@` (a count left out is 1).
242
+ HUNK = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
243
+
244
+ # @param file [Hash] one entry of the PR files API
245
+ # @return [PrFile, nil]
246
+ def self.from_api(file)
247
+ return nil unless file.is_a?(Hash) && file["filename"].is_a?(String)
248
+
249
+ old_hunks = []
250
+ new_hunks = []
251
+ file["patch"].to_s.scan(HUNK) do |old_start, old_count, new_start, new_count|
252
+ old_hunks << span(old_start, old_count)
253
+ new_hunks << span(new_start, new_count)
254
+ end
255
+ new(path: file["filename"], previous_path: file["previous_filename"], status: file["status"].to_s,
256
+ new_hunks: new_hunks.compact, old_hunks: old_hunks.compact)
257
+ end
258
+
259
+ # A hunk side's lines, or nil for none (`+0,0`: a removed file).
260
+ def self.span(start, count)
261
+ count = (count || 1).to_i
262
+ count.zero? ? nil : start.to_i..(start.to_i + count - 1)
263
+ end
264
+
265
+ def removed? = status == "removed"
266
+ end
267
+
268
+ # What the cache holds of a PR.
269
+ PrData = Data.define(:head_sha, :base_sha, :files, :fetched_at)
270
+
271
+ # The PRs' data, per PR, shared by the hooks' threads and the refresh's.
272
+ class PrCache
273
+ # An entry (or a failed fetch) younger than this isn't fetched again.
274
+ FRESH_SECONDS = 60
275
+
276
+ def initialize
277
+ @mutex = Mutex.new
278
+ @entries = {}
279
+ @failed = {}
280
+ @refreshing = false
281
+ end
282
+
283
+ # @return [PrData, nil]
284
+ def [](pr) = @mutex.synchronize { @entries[pr] }
285
+
286
+ def store(pr, data)
287
+ @mutex.synchronize do
288
+ @entries[pr] = data
289
+ @failed.delete(pr)
290
+ end
291
+ end
292
+
293
+ # Remembers a failed fetch; true the first time since the last success.
294
+ def fail(pr, time)
295
+ @mutex.synchronize do
296
+ first = !@failed.key?(pr)
297
+ @failed[pr] = time
298
+ first
299
+ end
300
+ end
301
+
302
+ # The PRs whose entry is missing or older than FRESH_SECONDS (a recent
303
+ # failure counts as fresh).
304
+ def stale(prs, time)
305
+ @mutex.synchronize do
306
+ prs.reject do |pr|
307
+ fetched = [@entries[pr]&.fetched_at, @failed[pr]].compact.max
308
+ fetched && time - fetched < FRESH_SECONDS
309
+ end
310
+ end
311
+ end
312
+
313
+ # Takes the one refresh slot: false when a refresh runs.
314
+ def claim
315
+ @mutex.synchronize do
316
+ next false if @refreshing
317
+
318
+ @refreshing = true
319
+ end
320
+ end
321
+
322
+ def release = @mutex.synchronize { @refreshing = false }
323
+
324
+ def refreshing? = @mutex.synchronize { @refreshing }
325
+ end
326
+
327
+ # Links an answer's `path:line` and `path:line-line` refs to the PRs'
328
+ # lines. A ref in inline code links when the code is the ref alone
329
+ # (`` `foo.rb:28` `` becomes ``[`foo.rb:28`](url)``); fenced blocks,
330
+ # markdown links and bare URLs are left alone.
331
+ class LineLinker
332
+ # The answer is scanned only up to this many characters.
333
+ MAX_SCAN = 20_000
334
+ # A path (no spaces), a line, an optional end line and an optional
335
+ # column (kept in the link's text, not used).
336
+ REF = %r{(?<![\w./@+~-])(?<path>[\w./@+~-]*[\w@+~-]):(?<from>\d+)(?:-(?<to>\d+))?(?::\d+)?(?![\w/])}
337
+ REF_ALONE = /\A#{REF}\z/
338
+ # Whether an answer may hold a ref at all.
339
+ QUICK = /\S:\d/
340
+ URL_SPAN = %r{[a-z][a-z0-9+.-]*://\S+}i
341
+ MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/
342
+ # A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
343
+ FENCE_OPEN = /\A {0,3}(`{3,}|~{3,})/
344
+
345
+ # The content of the last model message, or nil (string or symbol keys).
346
+ def self.last_model_text(messages)
347
+ Array(messages).reverse_each do |message|
348
+ next unless message.is_a?(Hash)
349
+ next unless (message.key?(:role) ? message[:role] : message["role"]).to_s == "model"
350
+
351
+ return (message.key?(:content) ? message[:content] : message["content"]).to_s
352
+ end
353
+ nil
354
+ end
355
+
356
+ # @param prs [Array<Array(PrRef, PrData)>]
357
+ def initialize(prs)
358
+ @prs = prs
359
+ end
360
+
361
+ # +text+ with each ref that names exactly one PR's file as a markdown
362
+ # link; the part beyond MAX_SCAN stays as it is.
363
+ def link(text)
364
+ head = text[0, MAX_SCAN]
365
+ out = +""
366
+ pos = 0
367
+ hits(head).each do |start, finish, url|
368
+ out << head[pos...start] << "[#{head[start...finish]}](#{url})"
369
+ pos = finish
370
+ end
371
+ return text if pos.zero?
372
+
373
+ out << head[pos..] << text[MAX_SCAN..].to_s
374
+ end
375
+
376
+ # The URL for +path+'s lines +from+..+to+ (+to+ nil: one line), or nil:
377
+ # no PR has the file, or more than one does.
378
+ def url_for(path, from, to = nil)
379
+ found = @prs.filter_map do |pr, data|
380
+ file = resolve(data.files, path)
381
+ [pr, data, file] if file
382
+ end
383
+ return nil unless found.size == 1
384
+
385
+ line_url(*found.first, from, to)
386
+ end
387
+
388
+ private
389
+
390
+ # [start, finish, url] for each ref to link, by offset.
391
+ def hits(text)
392
+ fences = fenced_blocks(text)
393
+ skipped = fences + spans(text, URL_SPAN) + spans(text, MARKDOWN_LINK)
394
+ code = inline_code(text, fences)
395
+ found = []
396
+ code.each do |start, finish, inner|
397
+ next if inside_any?(skipped, start, finish)
398
+
399
+ match = inner.strip.match(REF_ALONE) or next
400
+ url = ref_url(match) and found << [start, finish, url]
401
+ end
402
+ text.scan(REF) do
403
+ match = Regexp.last_match
404
+ start = match.begin(0)
405
+ finish = match.end(0)
406
+ next if inside_any?(skipped, start, finish) || inside_any?(code, start, finish)
407
+
408
+ url = ref_url(match) and found << [start, finish, url]
409
+ end
410
+ found.sort_by(&:first)
411
+ end
412
+
413
+ # A path has a `/` or an extension (`10:30` is a time, `host:8080` a port).
414
+ def ref_url(match)
415
+ path = match[:path]
416
+ return nil unless path.include?("/") || path.match?(/\.[A-Za-z0-9]+\z/)
417
+
418
+ from = match[:from].to_i
419
+ to = match[:to]&.to_i
420
+ to = nil if to && to <= from
421
+ from.zero? ? nil : url_for(path, from, to)
422
+ end
423
+
424
+ # The PR file +path+ names: the path (or a rename's old one) exactly,
425
+ # after a leading `./`; else the longest PR path +path+ ends with on a
426
+ # `/` boundary (an absolute path into a worktree); else the one PR path
427
+ # that ends with +path+ (a bare `foo.rb`). nil: none, or several.
428
+ def resolve(files, path)
429
+ path = path.sub(%r{\A(?:\./)+}, "")
430
+ exact = files.find { |file| file.path == path } || files.find { |file| file.previous_path == path }
431
+ return exact if exact
432
+
433
+ longer = files.select { |file| path.end_with?("/#{file.path}") }.max_by { |file| file.path.length }
434
+ return longer if longer
435
+
436
+ shorter = files.select { |file| file.path.end_with?("/#{path}") }
437
+ shorter.one? ? shorter.first : nil
438
+ end
439
+
440
+ # The PR's Files changed anchor when the lines are inside one hunk (the
441
+ # new side; a removed file's old side), else the file at the PR's head
442
+ # (a removed one: at its base), which has every line.
443
+ def line_url(pr, data, file, from, to)
444
+ last = to || from
445
+ hunks, side = file.removed? ? [file.old_hunks, "L"] : [file.new_hunks, "R"]
446
+ if hunks.any? { |hunk| hunk.cover?(from) && hunk.cover?(last) }
447
+ anchor = "#{side}#{from}#{"-#{side}#{to}" if to}"
448
+ return "#{pr.url}/files#diff-#{Digest::SHA256.hexdigest(file.path)}#{anchor}"
449
+ end
450
+
451
+ sha = file.removed? ? data.base_sha : data.head_sha
452
+ path = file.path.split("/").map { |segment| segment.gsub(/[^\w.~-]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join } }
453
+ "https://github.com/#{pr.owner}/#{pr.repo}/blob/#{sha}/#{path.join("/")}#L#{from}#{"-L#{to}" if to}"
454
+ end
455
+
456
+ # [start, finish, inner] for each inline code span outside +fences+: a
457
+ # run of backticks to the next run of the same length.
458
+ def inline_code(text, fences)
459
+ runs = []
460
+ text.scan(/`+/) { runs << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
461
+ runs.reject! { |start, finish| inside_any?(fences, start, finish) }
462
+ by_length = Hash.new { |hash, key| hash[key] = [] }
463
+ runs.each_with_index { |(start, finish), index| by_length[finish - start] << index }
464
+ found = []
465
+ index = 0
466
+ while index < runs.size
467
+ start, finish = runs[index]
468
+ closing = by_length[finish - start].bsearch { |other| other > index }
469
+ if closing
470
+ found << [start, runs[closing][1], text[finish...runs[closing][0]]]
471
+ index = closing + 1
472
+ else
473
+ index += 1
474
+ end
475
+ end
476
+ found
477
+ end
478
+
479
+ def fenced_blocks(text)
480
+ blocks = []
481
+ open = nil
482
+ offset = 0
483
+ text.each_line do |line|
484
+ marker = line[FENCE_OPEN, 1]
485
+ if open.nil? && marker
486
+ open = [offset, marker]
487
+ elsif open && marker && marker[0] == open[1][0] && marker.length >= open[1].length && line.strip == marker
488
+ blocks << [open[0], offset + line.length]
489
+ open = nil
490
+ end
491
+ offset += line.length
492
+ end
493
+ blocks << [open[0], text.length] if open
494
+ blocks
495
+ end
496
+
497
+ def spans(text, regex)
498
+ found = []
499
+ text.scan(regex) { found << [Regexp.last_match.begin(0), Regexp.last_match.end(0)] }
500
+ found
501
+ end
502
+
503
+ def inside_any?(spans, start, finish)
504
+ spans.any? { |span_start, span_end| start >= span_start && finish <= span_end }
505
+ end
506
+ end
@@ -60,6 +60,17 @@ rules:
60
60
  verdict: ask
61
61
  reason: rm -rf on a path outside the working tree
62
62
 
63
+ # memory_write remove: true moves a memory into the bundle trash. Memory
64
+ # files don't count as outside the repo, so nothing else asks; a delegate
65
+ # child could otherwise take its parent's handoff away. Every removal
66
+ # asks, once at a time.
67
+ - id: memory-remove
68
+ tool: memory_write
69
+ memory: remove
70
+ verdict: ask
71
+ scopes: [once]
72
+ reason: removes a memory (it goes to the bundle trash, which chi bundle trash lists)
73
+
63
74
  # ── running code from the network ──────────────────────────────────────
64
75
  - id: pipe-to-shell
65
76
  tool: shell
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  name: guardrails
3
- version: 0.7.0
3
+ version: 0.8.0
4
4
  scope: system
5
- description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
5
+ description: Optional default guardrail rules (git push/rewrites, rm -rf wide, curl | sh, git hooks, chi's own config and attached context from the shell, chi context add --cmd, chi broadcast, answering chi around chi answer, removing a memory; in strict mode also git rebase and writes and git in another checkout outside the session's repo), asks only small models get (git checkout --/restore, git stash drop), and a note for the model
6
6
  trust_level: reviewed
7
7
  # models: in small-models.yml (guardrails.small_models) needs 0.8.1; git: in rules.yml needs 0.17.0;
8
- # skip_read_only:, touches:, rm: and modes: need 0.20.0
9
- requires_chi: ">= 0.20.0"
8
+ # a rule's memory: key (memory-remove) needs 0.36.0: an older chi refuses the rule set (unknown key), so every
9
+ # guarded call would be refused
10
+ requires_chi: ">= 0.36.0"
10
11
  files:
11
12
  guardrails.md: sha256:04207fd1738aeeeef3b7a8053fc57597565bddd7862f01acaba784419cbd909b
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: mcp
3
- version: 0.4.5
3
+ version: 0.5.1
4
4
  scope: system
5
5
  description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session, its tools are mcp_<server>_<tool>; /mcp lists them"
6
6
  trust_level: reviewed
7
7
  files: {}
8
8
  plugin:
9
9
  file: plugin.rb
10
- sha256: sha256:1ebedc86cd21c918766c794015b6bd16c397c0f7e2734d939d8310d446b8a9a1
10
+ sha256: sha256:e41b880d70d2574f2be7d64e3ddee209ec77ce203f77d347ccdc6f9df1b358e1
11
11
  requires_chi: ">= 0.33.0"