samagotchi 0.139.1 → 0.141.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +2 -1
  4. data/docs/architecture.md +8 -6
  5. data/docs/cli.md +3 -1
  6. data/docs/configuration.md +33 -1
  7. data/docs/context.md +249 -46
  8. data/docs/guardrails.md +11 -0
  9. data/docs/internals/engine.md +7 -1
  10. data/docs/internals/prompt-caching.md +10 -1
  11. data/docs/internals/remote-sessions.md +4 -1
  12. data/docs/plugins.md +75 -18
  13. data/docs/sessions.md +3 -2
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  15. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +14 -0
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +15 -1
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/github-pr/manifest.yml +4 -4
  21. data/lib/samagotchi/bundles/github-pr/plugin.rb +72 -176
  22. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  23. data/lib/samagotchi/config.rb +8 -0
  24. data/lib/samagotchi/context_candidate_choice.rb +154 -0
  25. data/lib/samagotchi/context_candidates.rb +259 -0
  26. data/lib/samagotchi/context_candidates_log.rb +90 -0
  27. data/lib/samagotchi/context_command.rb +65 -17
  28. data/lib/samagotchi/context_judge.rb +194 -0
  29. data/lib/samagotchi/context_move.rb +31 -0
  30. data/lib/samagotchi/context_report_command.rb +161 -0
  31. data/lib/samagotchi/context_sources.rb +311 -29
  32. data/lib/samagotchi/engine.rb +189 -32
  33. data/lib/samagotchi/owner_prompt.rb +39 -0
  34. data/lib/samagotchi/plugin/api.rb +1 -0
  35. data/lib/samagotchi/plugin/attached_context.rb +105 -13
  36. data/lib/samagotchi/plugin/context.rb +12 -1
  37. data/lib/samagotchi/plugin/peer.rb +53 -0
  38. data/lib/samagotchi/plugin/sessions.rb +9 -2
  39. data/lib/samagotchi/prompt_warmup.rb +9 -1
  40. data/lib/samagotchi/send_command.rb +3 -1
  41. data/lib/samagotchi/session.rb +4 -0
  42. data/lib/samagotchi/session_access/contract.rb +4 -0
  43. data/lib/samagotchi/session_access/local.rb +34 -3
  44. data/lib/samagotchi/session_access/remote.rb +33 -8
  45. data/lib/samagotchi/session_access.rb +31 -6
  46. data/lib/samagotchi/session_commands.rb +28 -5
  47. data/lib/samagotchi/session_continue.rb +9 -2
  48. data/lib/samagotchi/side_judgments.rb +84 -0
  49. data/lib/samagotchi/terminal_ui.rb +15 -0
  50. data/lib/samagotchi/testing/boundary.rb +5 -1
  51. data/lib/samagotchi/testing/host.rb +4 -1
  52. data/lib/samagotchi/toolkit_report_command.rb +2 -1
  53. data/lib/samagotchi/toolkit_suggest.rb +3 -18
  54. data/lib/samagotchi/version.rb +1 -1
  55. data/lib/samagotchi/web/app.rb +24 -11
  56. data/lib/samagotchi/web/auto_update.rb +8 -1
  57. data/lib/samagotchi/web/public/app.js +8 -2
  58. data/lib/samagotchi/web/public/chains.js +10 -2
  59. data/lib/samagotchi/web/public/update_notice.js +21 -8
  60. data/lib/samagotchi/web/session_access_routes.rb +8 -3
  61. data/lib/samagotchi/web/session_create.rb +1 -0
  62. data/lib/samagotchi/web/session_hub.rb +5 -2
  63. data/lib/samagotchi/web/update_check.rb +14 -8
  64. data/lib/samagotchi/worker.rb +5 -0
  65. metadata +9 -1
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "context_candidates_log"
5
+ require_relative "log"
6
+ require_relative "system_one/question"
7
+
8
+ module Samagotchi
9
+ # System 1's judgment of a session's candidate context
10
+ # (docs/context.md, "The judge"): is this session's work about the
11
+ # candidate? One ask per candidate, its state neutral and small: the
12
+ # candidate's title (and describe), the owner's own prompts (the first
13
+ # substantive one and the latest, each bounded) and, for a delegate
14
+ # child, what its parent session has attached. Never the branch or the
15
+ # checkout (bare place facts), never the source's why, never "found for
16
+ # this checkout". The instructions are constant (one for a session, one
17
+ # for a delegate child): the candidate is in the state, so the server's
18
+ # prefix cache holds and nothing a plugin wrote is read as instructions.
19
+ #
20
+ # The same ask has a second question, "Has the user stated a task yet?":
21
+ # under TASK_MIN the candidate stays unjudged and is asked again at the
22
+ # next owner prompt. After a judged ask, one re-ask once
23
+ # context.candidates.reask_after_turns more owner prompts came, then
24
+ # done (#due?). What it asked and answered is a `judged` line in
25
+ # ContextCandidatesLog. In on mode ContextCandidateChoice acts on it
26
+ # (attach, card, or nothing); audit only logs.
27
+ module ContextJudge
28
+ MODES = %w[off audit on].freeze
29
+ ABOUT_ID = "about"
30
+ TASK_ID = "task"
31
+ # The bound on each prompt in the state.
32
+ PROMPT_MAX = 1500
33
+ # Below this the user hasn't stated a task yet: the ask is deferred.
34
+ TASK_MIN = 0.5
35
+ # A prompt of this many words or fewer is not the first substantive one
36
+ # while a longer one follows (#conversation).
37
+ SHORT_WORDS = 3
38
+ ABOUT = "A user works with a coding agent session. The state names a candidate source of context and the " \
39
+ "user's messages in the session so far (the first one with a task, and the latest). " \
40
+ "Is this session's work about the candidate? Work on something else is not."
41
+ CHILD = " This session was started by another agent session: the conversation's first message is that " \
42
+ "session's request, and started_from lists what that session has attached (the work it is doing)."
43
+ TASK = "A user works with a coding agent session; the state holds the user's messages so far. " \
44
+ "Has the user stated a task yet? A greeting or small talk alone is not one."
45
+
46
+ # One ask's snapshot, taken on the turn's (or the plugin's) thread.
47
+ # +conversation+ is the owner's prompts as the state carries them,
48
+ # +parent_attached+ nil unless the session is a delegate child,
49
+ # +owner_prompts+ how many owner prompts the session had (the re-ask
50
+ # counts them), +phase+ "turn" (a turn's start or end) or "late" (a
51
+ # proposal after the prompts).
52
+ Job = Data.define(:session_id, :name, :title, :describe, :conversation, :parent_attached, :owner_prompts, :phase)
53
+ # The thresholds a judged line's would is read with, and the mode.
54
+ Settings = Data.define(:mode, :threshold, :offer_threshold, :reask_after_turns) do
55
+ def self.from_config
56
+ new(mode: ContextJudge.mode, threshold: Config.get("context.candidates.threshold").to_f,
57
+ offer_threshold: Config.get("context.candidates.offer_threshold").to_f,
58
+ reask_after_turns: [Config.get("context.candidates.reask_after_turns").to_i, 1].max)
59
+ end
60
+ end
61
+
62
+ module_function
63
+
64
+ # context.candidates.mode: off (the default: no conversation text goes
65
+ # to a System 1 host), audit or on. An unknown value reads as off.
66
+ def mode
67
+ value = Config.get("context.candidates.mode").to_s.strip.downcase
68
+ MODES.include?(value) ? value : "off"
69
+ end
70
+
71
+ # The bounded conversation the state carries: the first substantive
72
+ # owner prompt and the latest (one when they are the same). +skip+
73
+ # owner prompts at the start are not substantive (an earlier ask said
74
+ # no task was stated in them), nor is a short one (SHORT_WORDS words or
75
+ # fewer: "hi", "ok go") while a longer one follows, as the earlier
76
+ # prompts may have come before any candidate was there to ask about. A
77
+ # delegate child (+child+) keeps its first: the parent's request.
78
+ # @param texts [Array<String>] the owner's prompts, oldest first
79
+ # @return [Array<String>]
80
+ def conversation(texts, skip: 0, child: false)
81
+ texts = Array(texts).map(&:to_s).reject { |text| text.strip.empty? }
82
+ return [] if texts.empty?
83
+
84
+ first = child ? 0 : first_substantive(texts, skip)
85
+ picked = first == texts.size - 1 ? [texts.last] : [texts[first], texts.last]
86
+ picked.map { |text| bound(text) }
87
+ end
88
+
89
+ def first_substantive(texts, skip)
90
+ from = skip < texts.size ? skip : 0
91
+ (from...texts.size).find { |index| texts[index].split.size > SHORT_WORDS } || from
92
+ end
93
+
94
+ def bound(text)
95
+ text = text.strip
96
+ text.length > PROMPT_MAX ? "#{text[0, PROMPT_MAX - 1]}…" : text
97
+ end
98
+
99
+ # The ask's state.
100
+ # @return [Hash{String => Object}]
101
+ def state(job)
102
+ candidate = job.describe.to_s.strip.empty? ? job.title : "#{job.title} — #{job.describe}"
103
+ state = { "candidate" => candidate, "conversation" => job.conversation }
104
+ state["started_from"] = { "parent_attached" => job.parent_attached } if job.parent_attached
105
+ state
106
+ end
107
+
108
+ # The two questions, constant for a session (+child+: a delegate child).
109
+ def questions(child:)
110
+ { ABOUT_ID => SystemOne::Question.new(id: ABOUT_ID, type: "noul", instructions: child ? ABOUT + CHILD : ABOUT),
111
+ TASK_ID => SystemOne::Question.new(id: TASK_ID, type: "noul", instructions: TASK) }
112
+ end
113
+
114
+ # How many owner prompts at the start no ask found a task in: the
115
+ # latest deferred ask's count (any candidate of the session).
116
+ def skip(records)
117
+ records.select { |line| line["kind"] == "judged" && line["stated"] == false }
118
+ .map { |line| line["owner_prompts"].to_i }.max || 0
119
+ end
120
+
121
+ # Whether candidate +name+ is asked now, at +owner_prompts+: never twice
122
+ # at one count; unjudged (no ask, or only deferred ones) → yes; one
123
+ # judged ask → once +reask_after_turns+ more owner prompts came; two →
124
+ # done.
125
+ def due?(records, name, owner_prompts:, reask_after_turns:)
126
+ mine = records.select { |line| line["kind"] == "judged" && line["name"] == name }
127
+ return false if mine.any? { |line| line["owner_prompts"].to_i >= owner_prompts }
128
+
129
+ judged = mine.select { |line| line["stated"] == true }
130
+ return true if judged.empty?
131
+ return false if judged.size >= 2
132
+
133
+ owner_prompts >= judged.first["owner_prompts"].to_i + reask_after_turns
134
+ end
135
+
136
+ # The proposed candidates due an ask (+rows+: ContextCandidates
137
+ # .with_status), as Jobs. +texts+ the owner's prompts, oldest first;
138
+ # +parent_attached+ the parent's sources' titles for a delegate child,
139
+ # else nil.
140
+ # @return [Array<Job>]
141
+ def jobs(rows, records, session_id:, texts:, parent_attached:, settings:, phase:)
142
+ owner_prompts = texts.size
143
+ return [] if owner_prompts.zero?
144
+
145
+ conversation = conversation(texts, skip: skip(records), child: !parent_attached.nil?)
146
+ rows.filter_map do |candidate, status|
147
+ next unless status == "proposed"
148
+ next unless due?(records, candidate.name, owner_prompts: owner_prompts,
149
+ reask_after_turns: settings.reask_after_turns)
150
+
151
+ Job.new(session_id: session_id, name: candidate.name, title: candidate.title, describe: candidate.describe,
152
+ conversation: conversation, parent_attached: parent_attached, owner_prompts: owner_prompts,
153
+ phase: phase)
154
+ end
155
+ end
156
+
157
+ # What on mode would do with +p+: attach (≥ threshold), offer (≥
158
+ # offer_threshold) or none.
159
+ def would(probability, settings)
160
+ return "attach" if probability >= settings.threshold
161
+ return "offer" if probability >= settings.offer_threshold
162
+
163
+ "none"
164
+ end
165
+
166
+ # One ask (on SideJudgments' thread) and its judged line. No answer
167
+ # (System 1 said nothing: its own warn) writes nothing, so the
168
+ # candidate is asked again at the next owner prompt.
169
+ # @return [Hash, nil] the line's fields
170
+ def ask(client:, job:, log:, settings:, cancel_controller: nil)
171
+ answers = client.ask(state: state(job), questions: questions(child: !job.parent_attached.nil?),
172
+ cancel_controller: cancel_controller)
173
+ about = answers&.dig(ABOUT_ID)&.noul
174
+ task = answers&.dig(TASK_ID)&.noul
175
+ return nil if about.nil? || task.nil?
176
+
177
+ stated = task >= TASK_MIN
178
+ fields = { "title" => job.title, "phase" => job.phase, "owner_prompts" => job.owner_prompts,
179
+ "p" => about.round(4), "task_p" => task.round(4), "stated" => stated,
180
+ "would" => stated ? would(about, settings) : nil, "mode" => settings.mode,
181
+ "threshold" => settings.threshold, "offer_threshold" => settings.offer_threshold,
182
+ "host" => client.host, "model" => client.model, "child" => job.parent_attached ? true : nil,
183
+ "conversation" => job.conversation.map { |text| ContextCandidatesLog.short(text) } }
184
+ ContextCandidatesLog.event("judged", session_id: job.session_id, name: job.name, log: log, **fields.transform_keys(&:to_sym))
185
+ fields
186
+ end
187
+
188
+ def warn(error)
189
+ Log.warn(:context, "context_judge_failed",
190
+ echo: "[samagotchi:context] candidate judge: #{error.class}: #{error.message}",
191
+ error: error.class.name, msg: error.message.to_s[0, 200])
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "context_sources"
4
+ require_relative "session_inbox"
5
+ require_relative "log"
6
+
7
+ module Samagotchi
8
+ # Movable context on its two paths, a continue (SessionContinue) and a
9
+ # plugin fork (ctx.sessions.fork): from SessionManager.spawn_session's
10
+ # before_spawn, once the new session and its first note are saved and
11
+ # before its worker starts, so its first absorb sees the sources.
12
+ module ContextMove
13
+ # What the new session's note says it is from.
14
+ NOTE_SOURCE = "attached context"
15
+
16
+ # ContextSources.move, then a second note in the new session saying what
17
+ # moved and what stayed (named by time, it sorts after the first). Best
18
+ # effort: a failure is logged and the session starts without (its start
19
+ # must not be undone for it).
20
+ # @return [ContextSources::Moved]
21
+ def self.call(from:, to:, mode:, state_dir:, seed: nil)
22
+ moved = ContextSources.move(from: from, to: to, mode: mode, seed: seed, state_dir: state_dir)
23
+ text = moved.note_text(from)
24
+ SessionInbox.write_note(to, text: text, source: NOTE_SOURCE, state_dir: state_dir) if text
25
+ moved
26
+ rescue StandardError => e
27
+ Log.warn(:context, "context_move_failed", sid: to, from: from, error: e.class.name, msg: e.message.to_s[0, 200])
28
+ ContextSources::Moved.none
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require_relative "cli/command"
6
+ require_relative "cli/exit"
7
+ require_relative "cli/flags"
8
+ require_relative "config"
9
+ require_relative "context_candidates_log"
10
+ require_relative "session"
11
+
12
+ module Samagotchi
13
+ # `chi context report [--days N]`: counts from context_candidates.jsonl
14
+ # (ContextCandidatesLog), no prompts: what was proposed and mentioned,
15
+ # the judge's asks, what it would attach at each threshold, and how that
16
+ # agrees with what the user did with the same candidates (attached,
17
+ # mentioned, or declined); on mode's attaches (and how many the user
18
+ # removed later), cards and /context attach|decline. This machine only
19
+ # (SessionAccess::Contract).
20
+ class ContextReportCommand
21
+ include CLI::Command
22
+
23
+ USAGE = <<~TEXT
24
+ Usage: chi context report [--days N]
25
+ Counts from the candidate judge's log (context_candidates.jsonl): candidates
26
+ proposed, mentioned, attached or declined by you, withdrawn; the judge's asks
27
+ (judged, or no task stated yet); how many candidates it would attach at each
28
+ threshold (each candidate's latest judged ask); and how that agrees with what
29
+ you did with them. On mode: the judge's attaches, how many you removed later,
30
+ its cards, and /context attach|decline. Default: the last 14 days.
31
+ TEXT
32
+
33
+ DEFAULT_DAYS = 14
34
+ THRESHOLDS = [0.5, 0.6, 0.7, 0.8, 0.9].freeze
35
+ FLAGS = CLI::Flags.new(args: false) { |flags| flags.value "--days" }.freeze
36
+
37
+ # @param argv [Array<String>] the arguments after "context report"
38
+ # @param state_dir [String, nil] the sessions folder (a spec seam)
39
+ # @param now [Time] the report's clock (a spec seam)
40
+ def initialize(argv, stdout: $stdout, stderr: $stderr, state_dir: nil, now: Time.now)
41
+ @argv = argv.dup
42
+ @stdout = stdout
43
+ @stderr = stderr
44
+ @state_dir = state_dir || Session.default_state_dir
45
+ @now = now
46
+ end
47
+
48
+ def run
49
+ parsed = parse_flags(FLAGS, @argv)
50
+ return parsed if parsed.is_a?(Integer)
51
+
52
+ days = parsed.options[:days]
53
+ return usage_error("--days takes a positive whole number") if days && !days.to_s.match?(/\A[1-9]\d*\z/)
54
+
55
+ report(days ? Integer(days, 10) : DEFAULT_DAYS)
56
+ CLI::Exit::OK
57
+ end
58
+
59
+ # The counts, as a Hash (the report prints these).
60
+ def self.counts(records, threshold:, offer_threshold:)
61
+ by_candidate = records.group_by { |line| [line["session_id"], line["name"]] }
62
+ kinds = ->(kind) { records.count { |line| line["kind"] == kind } }
63
+ judged = records.select { |line| line["kind"] == "judged" }
64
+ latest = by_candidate.transform_values do |lines|
65
+ lines.reverse.find { |line| line["kind"] == "judged" && line["stated"] == true }&.fetch("p", nil)
66
+ end.compact
67
+ choices = by_candidate.transform_values { |lines| user_choice(lines) }.compact
68
+ {
69
+ sessions: records.map { |line| line["session_id"] }.uniq.size,
70
+ proposed: kinds.call("proposed"), mentioned: kinds.call("mentioned"), attached: kinds.call("attached"),
71
+ declined: kinds.call("declined"), detached: kinds.call("detached"), withdrawn: kinds.call("withdrawn"),
72
+ asks: judged.size, stated: judged.count { |line| line["stated"] == true },
73
+ unstated: judged.count { |line| line["stated"] == false }, candidates_judged: latest.size,
74
+ would: THRESHOLDS.to_h { |t| [t, latest.values.count { |p| p.to_f >= t }] },
75
+ threshold: threshold, offer_threshold: offer_threshold,
76
+ agreement: agreement(latest, choices, threshold)
77
+ }.merge(on_mode(records, by_candidate))
78
+ end
79
+
80
+ # On mode's own lines: the judge's attaches, those the user removed
81
+ # later (its false positives), the cards, and /context attach|decline.
82
+ def self.on_mode(records, by_candidate)
83
+ by_command = ->(kind) { records.count { |line| line["kind"] == kind && line["by"] == "command" } }
84
+ removed = by_candidate.values.count do |lines|
85
+ index = lines.index { |line| line["kind"] == "judge_attached" }
86
+ index && lines.drop(index + 1).any? { |line| %w[declined detached].include?(line["kind"]) && line["by"] != "command" }
87
+ end
88
+ { judge_attached: records.count { |line| line["kind"] == "judge_attached" }, judge_removed: removed,
89
+ carded: records.count { |line| line["kind"] == "carded" }, command_attached: by_command.call("attached"),
90
+ command_declined: by_command.call("declined") }
91
+ end
92
+
93
+ # What the user last did with a candidate: "attached" (attached by
94
+ # them, or mentioned) or "declined"; nil when neither.
95
+ def self.user_choice(lines)
96
+ last = lines.reverse.find { |line| %w[mentioned attached declined detached].include?(line["kind"]) }
97
+ return nil if last.nil? || last["kind"] == "detached"
98
+
99
+ last["kind"] == "declined" ? "declined" : "attached"
100
+ end
101
+
102
+ # The judged candidates the user also chose for: agree (would attach
103
+ # what they attached, or not what they declined), attached what they
104
+ # declined, missed what they attached.
105
+ def self.agreement(latest, choices, threshold)
106
+ both = latest.keys & choices.keys
107
+ result = { candidates: both.size, agree: 0, wrong_attach: 0, missed: 0 }
108
+ both.each do |key|
109
+ would = latest[key].to_f >= threshold
110
+ if would == (choices[key] == "attached") then result[:agree] += 1
111
+ elsif would then result[:wrong_attach] += 1
112
+ else result[:missed] += 1
113
+ end
114
+ end
115
+ result
116
+ end
117
+
118
+ private
119
+
120
+ def command_name = "chi context report"
121
+
122
+ def report(days)
123
+ cutoff = @now - (days * 86_400)
124
+ records = ContextCandidatesLog.log_for(@state_dir).records.select { |line| after?(line["at"], cutoff) }
125
+ counts = self.class.counts(records, threshold: Config.get("context.candidates.threshold").to_f,
126
+ offer_threshold: Config.get("context.candidates.offer_threshold").to_f)
127
+ print_counts(counts, days)
128
+ end
129
+
130
+ def after?(iso, cutoff)
131
+ Time.iso8601(iso.to_s) >= cutoff
132
+ rescue ArgumentError
133
+ false
134
+ end
135
+
136
+ def print_counts(counts, days)
137
+ @stdout.puts("chi context report: the last #{days} day#{"s" unless days == 1}, #{counts[:sessions]} session" \
138
+ "#{"s" unless counts[:sessions] == 1} (mode #{Config.get("context.candidates.mode")})")
139
+ @stdout.puts(" candidates: proposed #{counts[:proposed]} · mentioned #{counts[:mentioned]} · " \
140
+ "attached by you #{counts[:attached]} · declined #{counts[:declined]} · " \
141
+ "detached #{counts[:detached]} · withdrawn #{counts[:withdrawn]}")
142
+ @stdout.puts(" on mode: judge attached #{counts[:judge_attached]} · you removed #{counts[:judge_removed]} of them " \
143
+ "later · carded #{counts[:carded]} · /context attach #{counts[:command_attached]} · " \
144
+ "/context decline #{counts[:command_declined]} (the card's buttons)")
145
+ @stdout.puts(" judge: #{counts[:asks]} asks · judged #{counts[:stated]} · no task yet #{counts[:unstated]} · " \
146
+ "#{counts[:candidates_judged]} candidates judged")
147
+ return if counts[:candidates_judged].zero?
148
+
149
+ @stdout.puts(" would attach (each candidate's latest judged ask):")
150
+ counts[:would].each do |threshold, count|
151
+ mark = { counts[:threshold] => " threshold", counts[:offer_threshold] => " offer_threshold" }[threshold]
152
+ @stdout.puts(format(" ≥ %.1f %d%s", threshold, count, mark))
153
+ end
154
+ agreement = counts[:agreement]
155
+ @stdout.puts(" against your own choice (#{agreement[:candidates]} judged candidates you attached, " \
156
+ "mentioned or declined), at #{counts[:threshold]}: agree #{agreement[:agree]} · " \
157
+ "would attach what you declined #{agreement[:wrong_attach]} · " \
158
+ "would miss what you attached #{agreement[:missed]}")
159
+ end
160
+ end
161
+ end