kairos-chain 3.56.0 → 3.57.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/lib/kairos_mcp/version.rb +1 -1
  3. data/templates/skillsets/agent/test/test_agent_complexity_review.rb +66 -0
  4. data/templates/skillsets/agent/tools/agent_step.rb +9 -2
  5. data/templates/skillsets/llm_client/lib/llm_client/claude_code_adapter.rb +17 -12
  6. data/templates/skillsets/llm_client/test/test_claude_code_adapter_parse.rb +14 -12
  7. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/build_review_bundle.rb +30 -2
  8. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +93 -34
  9. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/dispatcher.rb +31 -2
  10. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/pending_state.rb +128 -16
  11. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/persona_assembly.rb +65 -24
  12. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/prompt_builder.rb +22 -6
  13. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/review_serializer.rb +23 -0
  14. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/verdict_vocabulary.rb +27 -31
  15. data/templates/skillsets/multi_llm_review/skillset.json +8 -4
  16. data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +397 -45
  17. data/templates/skillsets/multi_llm_review/test/test_mutation_survivors.rb +61 -40
  18. data/templates/skillsets/multi_llm_review/test/test_observer_set.rb +1 -1
  19. data/templates/skillsets/multi_llm_review/test/test_observer_set_seams.rb +62 -34
  20. data/templates/skillsets/multi_llm_review/test/test_pending_state_v3.rb +122 -2
  21. data/templates/skillsets/multi_llm_review/test/test_tool_wiring.rb +276 -2
  22. data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +373 -113
  23. data/templates/skillsets/multi_llm_review/tools/multi_llm_review_collect.rb +82 -6
  24. data/templates/skillsets/synoptis/lib/synoptis/attestation_engine.rb +35 -2
  25. data/templates/skillsets/synoptis/lib/synoptis/proof_envelope.rb +55 -3
  26. data/templates/skillsets/synoptis/lib/synoptis/tool_helpers.rb +9 -2
  27. data/templates/skillsets/synoptis/lib/synoptis/verifier.rb +32 -5
  28. data/templates/skillsets/synoptis/tools/attestation_verify.rb +1 -1
  29. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 185d6af51fd316124bc72eabbfc3f6b280fee6a2beeda91ed2b690781bb6c26f
4
- data.tar.gz: 83951da8550d67d22b67a62153c71a923c4d016a59e94c86d07062a5aac4fedb
3
+ metadata.gz: 8439763123a17e68d238ebc46369dcc36e6379172752809dd6736fa18f3c5ec4
4
+ data.tar.gz: 76fe818c7261e464411f54bee9a114bc7eb0ecc025042ef264f15bd591f2ca36
5
5
  SHA512:
6
- metadata.gz: 14288ed7e74a7a276f68c94292935804830f1d302272ac5ff2e1ba11865ac00cf99acb3d8d93382a2fa0c878e3c0df1ddb4f2b9dfbc742f387386d32c0a6e1da
7
- data.tar.gz: cdc967af9cd5962d175787d22cdfd63c881be35aefd6d2dcfba0afd14714482674465cf11df138f646214c37b2800163b982546da5381367c8826d9a74b3b7f4
6
+ metadata.gz: 2385335588b313d3910519459a79a1325f921fa1f6d1a2b215f0b75144c383b14b90a95f1d9edf97999df6cdc58d95155e653f13007217299f35df097891f38a
7
+ data.tar.gz: 160f831a399a60089d217e2fe198dfe60692797649b55c2d8b4fbd0d0649512c6f601a75dfc41de713b9bf946be83485f01c289252018c87d33dbbb06221c1ec
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.56.0"
2
+ VERSION = "3.57.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -689,6 +689,65 @@ assert "missing feedback_text_schema_version → reject" do
689
689
  msg && msg.include?('feedback_text_schema_version missing')
690
690
  end
691
691
 
692
+ # v0.7 record schema: the consumer reads reference_verdict from v2 records
693
+ # and still reads verdict from v1 records. Held here because reverting the
694
+ # read (parsed['verdict'] only) would leave every v2 review verdict-less
695
+ # with the rest of the suite green.
696
+ section "v0.7 reference_verdict consumption"
697
+
698
+ def drive_review_with(step, record)
699
+ fake_session = Object.new
700
+ def fake_session.cycle_number = 1
701
+ def fake_session.session_id = 'testsess'
702
+ ctx = Object.new
703
+ def ctx.derive(**_k) = nil
704
+ fake_session.define_singleton_method(:invocation_context) { ctx }
705
+ step.define_singleton_method(:invoke_tool) do |*_a, **_k|
706
+ [{ text: JSON.generate(record) }]
707
+ end
708
+ step.send(:run_multi_llm_review, fake_session,
709
+ { 'summary' => 's', 'task_json' => { 'steps' => [] } },
710
+ { level: 'high', signals: [] }, {})
711
+ end
712
+
713
+ assert "v2 record (reference_verdict, no top-level verdict) → verdict read" do
714
+ out = drive_review_with(step.dup, {
715
+ 'status' => 'ok', 'verdict_schema_version' => 2,
716
+ 'feedback_text_schema_version' => 1,
717
+ 'reference_verdict' => 'REVISE', 'convergence' => {},
718
+ 'aggregated_findings' => [], 'llm_calls' => 3, 'reviews' => [],
719
+ 'feedback_text' => 'x'
720
+ })
721
+ out[:verdict] == 'REVISE'
722
+ end
723
+
724
+ assert "v1 record (verdict) → still read" do
725
+ out = drive_review_with(step.dup, {
726
+ 'status' => 'ok', 'verdict_schema_version' => 1,
727
+ 'feedback_text_schema_version' => 1,
728
+ 'verdict' => 'APPROVE', 'convergence' => {},
729
+ 'aggregated_findings' => [], 'llm_calls' => 3, 'reviews' => [],
730
+ 'feedback_text' => nil
731
+ })
732
+ out[:verdict] == 'APPROVE'
733
+ end
734
+
735
+ # v0.7 record schema (2026-08-01): the boundary is max and max+1, not max
736
+ # and 99 — a probe of 99 cannot tell SUPPORTED=1 from SUPPORTED=2, which is
737
+ # exactly the revert these tests exist to catch.
738
+ assert "v0.7 schema (v=2) → accepted at the boundary" do
739
+ step.send(:schema_version_check, {
740
+ 'verdict_schema_version' => 2, 'feedback_text_schema_version' => 1
741
+ }).nil?
742
+ end
743
+
744
+ assert "v=3 (max+1) → reject (fail-closed at the boundary)" do
745
+ msg = step.send(:schema_version_check, {
746
+ 'verdict_schema_version' => 3, 'feedback_text_schema_version' => 1
747
+ })
748
+ msg && msg.include?('newer than supported')
749
+ end
750
+
692
751
  assert "newer verdict_schema_version → reject (fail-closed)" do
693
752
  msg = step.send(:schema_version_check, {
694
753
  'verdict_schema_version' => 99, 'feedback_text_schema_version' => 1
@@ -696,6 +755,13 @@ assert "newer verdict_schema_version → reject (fail-closed)" do
696
755
  msg && msg.include?('newer than supported')
697
756
  end
698
757
 
758
+ assert "feedback_text v=2 (max+1) → reject (boundary, not v99)" do
759
+ msg = step.send(:schema_version_check, {
760
+ 'verdict_schema_version' => 2, 'feedback_text_schema_version' => 2
761
+ })
762
+ msg && msg.include?('newer than supported')
763
+ end
764
+
699
765
  assert "newer feedback_text_schema_version → reject" do
700
766
  msg = step.send(:schema_version_check, {
701
767
  'verdict_schema_version' => 1, 'feedback_text_schema_version' => 99
@@ -1696,7 +1696,12 @@ module KairosMcp
1696
1696
  aggregated_findings: [], feedback_text: nil }
1697
1697
  else
1698
1698
  {
1699
- verdict: parsed['verdict'],
1699
+ # v0.7 record schema (verdict_schema_version 2) renamed the
1700
+ # top-level conclusion column to reference_verdict (INV-R2: a
1701
+ # recorded reference value, not the run's conclusion). v1
1702
+ # records still carry 'verdict'; read whichever the record
1703
+ # speaks.
1704
+ verdict: parsed['reference_verdict'] || parsed['verdict'],
1700
1705
  convergence: parsed['convergence'],
1701
1706
  aggregated_findings: (parsed['aggregated_findings'] || []).map { |f|
1702
1707
  f.transform_keys(&:to_sym)
@@ -1718,7 +1723,9 @@ module KairosMcp
1718
1723
 
1719
1724
  # Phase 12 §3.10 fail-closed schema versioning.
1720
1725
  # Returns nil if response schema is acceptable, else a string reason.
1721
- SUPPORTED_VERDICT_SCHEMA_VERSION = 1
1726
+ # 2 = v0.7 record schema (2026-08-01): top-level verdict became
1727
+ # reference_verdict; the read above handles both generations.
1728
+ SUPPORTED_VERDICT_SCHEMA_VERSION = 2
1722
1729
  SUPPORTED_FEEDBACK_TEXT_SCHEMA_VERSION = 1
1723
1730
 
1724
1731
  def schema_version_check(parsed)
@@ -186,12 +186,15 @@ module KairosMcp
186
186
  model_usage = data['modelUsage'] || {}
187
187
 
188
188
  # The answering model is the entry that produced the output tokens,
189
- # not the first hash key. `keys.first` read whichever entry the CLI
190
- # inserted first, so if the envelope ever carries a second model
191
- # beside the main call, the reply could be attributed to the wrong
192
- # one. Every envelope probed so far carries exactly one key, so the
193
- # two readings have not yet disagreed this is defence, not a
194
- # reproduced defect.
189
+ # not the first hash key. The CLI places a small internal call
190
+ # (claude-haiku, ~18 output tokens) beside the main call unless
191
+ # CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC suppresses it, so under
192
+ # the worker's stripped environment the envelope carries two keys,
193
+ # and `keys.first` attributed the reply to whichever entry the CLI
194
+ # inserted first. Reproduced 2/2 with byte-identical prompts
195
+ # (2026-07-31); this misread was the root cause of every model
196
+ # divergence this transport had recorded. See L2
197
+ # mlr_v07_design_inputs_and_haiku_root_cause_20260731.
195
198
  observed = model_usage.max_by { |_m, u| (u || {})['outputTokens'].to_i }&.first
196
199
 
197
200
  {
@@ -202,12 +205,14 @@ module KairosMcp
202
205
  # What the CLI reports as having answered, when it reports it.
203
206
  # Kept separate from 'model' (which echoes the request) so callers
204
207
  # can tell a request from an observation and notice when the two
205
- # disagree the CLI may serve a different model than the one
206
- # asked for. That is not hypothetical: four times in the
207
- # multi_llm_review loop (R6/R8/R10/R13, 2026-07) a slot requesting
208
- # claude-opus-4-6 was answered by claude-haiku-4-5, and the R13
209
- # reply's content confirmed the observation (it cited identifiers
210
- # that exist nowhere in the reviewed code).
208
+ # disagree. The four divergences recorded before the fix above
209
+ # (R6/R8/R10/R13, 2026-07) were all keys.first misreads the main
210
+ # call was claude-opus-4-6 every time, and the R13 reply's odd
211
+ # content (identifiers that exist nowhere in the reviewed code) is
212
+ # explained by the sandboxed slot reading no repository, not by a
213
+ # different model answering. A divergence observed after the
214
+ # 2026-07-31 fix has no known benign explanation and is worth
215
+ # investigating.
211
216
  'model_observed' => observed,
212
217
  # Diagnostic envelope, previously discarded. All four divergence
213
218
  # incidents above were undiagnosable from the record because the
@@ -3,15 +3,16 @@
3
3
  # What parse_response reads from the CLI envelope, pinned after the
4
4
  # claude_cli_opus4.6 divergence investigation (2026-07-31).
5
5
  #
6
- # Four times in the multi_llm_review loop (R6/R8/R10/R13) a slot requesting
7
- # claude-opus-4-6 was answered by claude-haiku-4-5. The record could not say
8
- # why, because this method discarded every diagnostic field the envelope
9
- # carries (modelUsage breakdown, api_error_status, fast_mode_state,
10
- # terminal_reason). The R13 reply's own content settled the observation side
11
- # it cited identifiers that exist nowhere in the reviewed code — but the
12
- # cause remains open precisely because nothing was kept. These tests hold the
13
- # two fixes: the answering model is chosen by output tokens rather than hash
14
- # order, and the diagnostic fields survive into the response.
6
+ # Four times in the multi_llm_review loop (R6/R8/R10/R13) the record showed a
7
+ # slot requesting claude-opus-4-6 answered by claude-haiku-4-5. The 2026-07-31
8
+ # investigation reproduced the cause 2/2: the CLI places a small internal call
9
+ # beside the main one, the envelope carries two keys, and the old `keys.first`
10
+ # read attributed the reply to the internal call — the main call was
11
+ # claude-opus-4-6 every time. The record could not say so, because this method
12
+ # also discarded every diagnostic field the envelope carries (modelUsage
13
+ # breakdown, api_error_status, fast_mode_state, terminal_reason). These tests
14
+ # hold the two fixes: the answering model is chosen by output tokens rather
15
+ # than hash order, and the diagnostic fields survive into the response.
15
16
 
16
17
  require 'minitest/autorun'
17
18
  require_relative '../lib/llm_client/adapter'
@@ -46,9 +47,10 @@ module KairosMcp
46
47
  assert_equal 'claude-opus-4-6', out['model_observed']
47
48
  end
48
49
 
49
- # The regression the old `keys.first` invited: if the envelope ever
50
- # carries a second model beside the main call, insertion order must
51
- # not decide which one "answered". The output tokens do.
50
+ # The regression the old `keys.first` invited: when the envelope
51
+ # carries a second model beside the main call (reproduced 2/2 under
52
+ # the worker's environment), insertion order must not decide which
53
+ # one "answered". The output tokens do.
52
54
  def test_the_answering_model_is_the_one_that_wrote_the_output
53
55
  out = parse(envelope(model_usage: {
54
56
  'claude-haiku-4-5-20251001' => { 'outputTokens' => 12 },
@@ -23,7 +23,14 @@ module KairosMcp
23
23
  # the verdict shape itself (separate from feedback_text_schema_version,
24
24
  # which lives in FeedbackFormatter). Bumped independently when verdict
25
25
  # JSON contract changes (e.g., new field, semantic redefinition).
26
- VERDICT_SCHEMA_VERSION = 1
26
+ #
27
+ # 2 = v0.7 record schema (design frozen 2026-08-01): the top-level
28
+ # conclusion column is gone — `verdict` became `reference_verdict`
29
+ # (INV-R2), the composition rows carry seat marks, refusals and
30
+ # transport diagnostics travel with the record. Readers tell old
31
+ # records from new by this number; old records are not rewritten
32
+ # (design §4, no retroactivity).
33
+ VERDICT_SCHEMA_VERSION = 2
27
34
 
28
35
  # @param artifact_content [String] sanitized at boundary; raw passthrough is caller responsibility
29
36
  # @param artifact_name [String]
@@ -161,11 +168,32 @@ module KairosMcp
161
168
  }
162
169
  end
163
170
 
171
+ # INV-R7 by_reference delivery: the same canonical framing with a
172
+ # reference manifest where the artifact body would be. The system
173
+ # prompt is identical to the inline one — only the user message
174
+ # differs, and only in the artifact block. sha256 is computed by the
175
+ # caller over the raw submitted content, so a seat that reads the file
176
+ # can check it is reviewing what was submitted.
177
+ def self.build_reference_prompts(artifact_path:, artifact_sha256:,
178
+ artifact_name:, review_type:,
179
+ review_context: 'independent',
180
+ review_round: 1, prior_findings: nil)
181
+ system_prompt = PromptBuilder.build_system_prompt(review_type, review_context: review_context)
182
+ messages = PromptBuilder.build_messages(
183
+ artifact_name: artifact_name,
184
+ review_type: review_type,
185
+ review_round: review_round,
186
+ prior_findings: prior_findings,
187
+ artifact_reference: { path: artifact_path, sha256: artifact_sha256 }
188
+ )
189
+ { system_prompt: system_prompt, messages: messages }
190
+ end
191
+
164
192
  def self.aggregation_instructions(review_type, review_round)
165
193
  <<~INST.strip
166
194
  After collecting all reviewer responses, aggregate as follows:
167
195
  1. Parse each response for verdict {APPROVE, REVISE, REJECT}.
168
- 2. Apply convergence rule (e.g., 3/N APPROVE → APPROVE; otherwise REVISE).
196
+ 2. Compute the reference tally (e.g., 3/N APPROVE → reference APPROVE; any REJECT → REVISE; below quorum → INSUFFICIENT). It is a recorded reference value, not the run's conclusion.
169
197
  3. Merge findings, sorted by severity (P0 first), de-dup by issue text.
170
198
  4. For round #{review_round} #{review_type} reviews, prior findings should be verified as CLOSED/NEEDS_MORE_WORK/REOPENED.
171
199
  INST
@@ -59,11 +59,14 @@ module KairosMcp
59
59
  # reason of its own. Distinct from `transport`, which asserts a call
60
60
  # was attempted and failed.
61
61
  SKIP_REASON_NOT_DISPATCHED = 'not_dispatched'
62
- # A submission that stated SKIP for itself and gave no reason.
63
- SKIP_REASON_DECLINED = 'declined'
64
62
 
65
63
  # Verdicts a submission may state for itself, bypassing text parsing.
66
- PARSED_VERDICTS = %w[APPROVE REVISE REJECT SKIP].freeze
64
+ # v0.7 INV-R1: SKIP is not in the vocabulary — no word is special. A
65
+ # row leaves the denominator through what happened to it (:status,
66
+ # substance, no verdict), never by declaring a verdict-shaped word.
67
+ # The declared-SKIP branch this list used to admit was unreachable
68
+ # through shipping writers (measured in round 12) and is gone.
69
+ PARSED_VERDICTS = %w[APPROVE REVISE REJECT].freeze
67
70
  # Phase 12 §3.7.2 / PR3 hardening: hard cap on aggregated_findings.
68
71
  # FeedbackFormatter also caps the *displayed* slice at 50, but the API
69
72
  # contract returns aggregated_findings as a separate array which would
@@ -125,7 +128,7 @@ module KairosMcp
125
128
  # @param reviews [Array<Hash>] from Dispatcher, each with :status, :raw_text, :role_label, etc.
126
129
  # @param rule_str [String] e.g., "3/4 APPROVE"
127
130
  # @param min_quorum [Integer] minimum successful reviews needed
128
- # @return [Hash] with :verdict, :convergence, :reviews, :aggregated_findings
131
+ # @return [Hash] with :reference_verdict, :convergence, :reviews, :aggregated_findings
129
132
  def self.aggregate(reviews, rule_str = '3/4 APPROVE', min_quorum: 2,
130
133
  excluded_slots: [], escalation: nil)
131
134
  # Parsing a verdict and deciding whether the reply belongs in the
@@ -141,6 +144,10 @@ module KairosMcp
141
144
 
142
145
  threshold = parse_threshold(rule_str, successful.size)
143
146
 
147
+ # v0.7 INV-R2: this value is a recorded reference, not the run's
148
+ # conclusion. The run is closed by the operator's declaration, which
149
+ # lives outside this record (L2 / handoff); the record presents the
150
+ # observation. The computation is unchanged — only its seat moved.
144
151
  overall = if successful.size < min_quorum
145
152
  'INSUFFICIENT'
146
153
  elsif reject_n > 0
@@ -151,9 +158,16 @@ module KairosMcp
151
158
  'REVISE'
152
159
  end
153
160
 
161
+ # INV-R5: a seat whose transport reported a different model than the
162
+ # slot asked for stays in the denominator — removing it would tangle
163
+ # the detection record with a vanished vote — and the tally without
164
+ # those seats is carried beside the main one. Both are reference
165
+ # values.
166
+ non_divergent = successful.reject { |p| p[:model_divergence] }
167
+
154
168
  findings = aggregate_findings(parsed)
155
169
  {
156
- verdict: overall,
170
+ reference_verdict: overall,
157
171
  convergence: {
158
172
  approve_count: approve_n,
159
173
  reject_count: reject_n,
@@ -171,6 +185,12 @@ module KairosMcp
171
185
  threshold: threshold,
172
186
  min_quorum: min_quorum,
173
187
  rule: rule_str,
188
+ excluding_divergent: {
189
+ approve_count: non_divergent.count { |p| p[:verdict] == 'APPROVE' },
190
+ reject_count: non_divergent.count { |p| p[:verdict] == 'REJECT' },
191
+ successful_count: non_divergent.size,
192
+ threshold: parse_threshold(rule_str, non_divergent.size)
193
+ },
174
194
  # INV-E4: how this denominator came to be, readable from the
175
195
  # record alone. Without it a later reader cannot tell a 3/4 that
176
196
  # lost a slot to transport failure from one that lost it to an
@@ -222,25 +242,13 @@ module KairosMcp
222
242
  # spelling. Without the merge, a declared `approve` passes this
223
243
  # gate unaltered and then misses every `== 'APPROVE'` count
224
244
  # downstream — a row in the denominator that cannot contribute to
225
- # consensus, the shape INV-E2 exists to remove and a declared
226
- # `skip` is worse: not 'SKIP', so it stays in the denominator its
227
- # author asked to leave. Round 12 measured the approve shape on a
228
- # hand-built row (successful_count 1, approve_count 0) and found
229
- # it unreachable through shipping writers, since
230
- # PersonaAssembly.assemble is the only writer of this field and
231
- # always emits canonical case; the round-13 review re-verified
232
- # that bound. The normalization is defensive: it keeps the gate
233
- # and the row from drifting apart if a second writer ever
234
- # appears.
235
- #
236
- # A submission may declare SKIP for itself. Why it did is its own
237
- # business and this system does not know it, so the row says that
238
- # rather than defaulting — as it used to — to `transport`, which
239
- # asserts a call was attempted and failed.
240
- return review.merge(verdict: declared) if declared != 'SKIP'
241
-
242
- return review.merge(verdict: declared,
243
- skip_reason: review[:skip_reason] || SKIP_REASON_DECLINED)
245
+ # consensus, the shape INV-E2 exists to remove. Round 12 measured
246
+ # that shape on a hand-built row and found it unreachable through
247
+ # shipping writers, since PersonaAssembly.assemble is the only
248
+ # writer of this field and always emits canonical case. The
249
+ # normalization is defensive: it keeps the gate and the row from
250
+ # drifting apart if a second writer ever appears.
251
+ return review.merge(verdict: declared)
244
252
  end
245
253
 
246
254
  text = review[:raw_text].to_s
@@ -250,8 +258,17 @@ module KairosMcp
250
258
  # containing one. A header further down is not read at all: it may be
251
259
  # the reviewer's, or it may be a sample it is discussing, and nothing
252
260
  # in the text says which.
253
- if (m = text.match(VERDICT_HEADER_RE)) && (stated = VerdictVocabulary.stated(m[1]))
254
- return review.merge(verdict: stated)
261
+ #
262
+ # v0.7 INV-R1: when the header offered a value the vocabulary
263
+ # refuses, the written word itself goes into the record (stated_text)
264
+ # beside the closed no_verdict token. The reason column stays a
265
+ # token; the word form is its own column.
266
+ offered = nil
267
+ if (m = text.match(VERDICT_HEADER_RE))
268
+ offered = m[1]
269
+ if (stated = VerdictVocabulary.stated(offered))
270
+ return review.merge(verdict: stated)
271
+ end
255
272
  end
256
273
 
257
274
  # Same, for a reply that is a JSON document rather than markdown.
@@ -259,9 +276,11 @@ module KairosMcp
259
276
  # part of the prose, and only a reply that *is* the document states
260
277
  # a verdict through it.
261
278
  structured = parse_structured(text)
262
- if structured && structured['overall_verdict'] &&
263
- (stated = VerdictVocabulary.stated(structured['overall_verdict']))
264
- return review.merge(verdict: stated)
279
+ if structured && structured['overall_verdict']
280
+ if (stated = VerdictVocabulary.stated(structured['overall_verdict']))
281
+ return review.merge(verdict: stated)
282
+ end
283
+ offered ||= structured['overall_verdict'].to_s
265
284
  end
266
285
 
267
286
  # There is no fourth path. A reply that did not state its verdict in
@@ -293,7 +312,9 @@ module KairosMcp
293
312
  # was worse than it looked: an opening sentence with no judgement in
294
313
  # it — the exact shape that retired one roster occupant — blocked
295
314
  # convergence on a verdict its author never gave.
296
- review.merge(verdict: 'SKIP', skip_reason: SKIP_REASON_NO_VERDICT)
315
+ skip = { verdict: 'SKIP', skip_reason: SKIP_REASON_NO_VERDICT }
316
+ skip[:stated_text] = offered.to_s[0, 120] unless offered.to_s.strip.empty?
317
+ review.merge(skip)
297
318
  end
298
319
 
299
320
  # Why a slot that never produced a reply left the denominator. Two
@@ -326,16 +347,38 @@ module KairosMcp
326
347
  end
327
348
 
328
349
 
350
+ # A duplicated key makes a JSON document say two things under one name,
351
+ # and JSON.parse keeps whichever came last — silently. A reply whose
352
+ # `overall_verdict` appears twice would then state whichever verdict
353
+ # was written second, which is not a statement anyone can be held to.
354
+ # Raised at insertion so the whole document is refused, at any depth:
355
+ # fail-closed, like every other reading rule here (R12 P1).
356
+ class DuplicateKeyError < StandardError; end
357
+
358
+ class DuplicateRefusingHash < Hash
359
+ def []=(key, value)
360
+ raise DuplicateKeyError, "duplicate key: #{key}" if key?(key)
361
+
362
+ super
363
+ end
364
+ end
365
+
329
366
  # A reply that is itself a JSON document, or nil when it is not.
330
367
  # Deliberately strict: no scanning for an object embedded in prose,
331
- # because prose quoting an object is prose.
368
+ # because prose quoting an object is prose — and no document with a
369
+ # repeated key, because such a document does not state one thing.
332
370
  def self.parse_structured(text)
333
371
  stripped = text.to_s.strip
334
372
  return nil unless stripped.start_with?('{')
335
373
 
336
- parsed = JSON.parse(stripped)
337
- parsed.is_a?(Hash) ? parsed : nil
338
- rescue JSON::ParserError
374
+ parsed = JSON.parse(stripped, object_class: DuplicateRefusingHash)
375
+ # The TOP LEVEL is converted to a plain Hash; nested values keep the
376
+ # refusing subclass. That is enough for every current reader — only
377
+ # overall_verdict is read, and structural_substance reads without
378
+ # writing — but writing into a nested value would raise. A consumer
379
+ # that needs to mutate nested values must deep-convert first.
380
+ parsed.is_a?(Hash) ? {}.merge(parsed) : nil
381
+ rescue JSON::ParserError, DuplicateKeyError
339
382
  nil
340
383
  end
341
384
 
@@ -440,6 +483,9 @@ module KairosMcp
440
483
 
441
484
  # INV-E4. Every observer that could have counted appears here exactly
442
485
  # once, whether it counted or not, with the reason it did not.
486
+ # v0.7 INV-R3/R4: the unit of this list is the seat, and each row says
487
+ # so (`seat: true` — one boolean column, so that if row-level records
488
+ # are ever interleaved here, the seat column stays reconstructible).
443
489
  def self.denominator_composition(parsed, excluded_slots, escalation)
444
490
  counted = parsed.map do |p|
445
491
  {
@@ -457,12 +503,20 @@ module KairosMcp
457
503
  # separate them: an executed slot on a silent transport carries
458
504
  # exactly that value too.
459
505
  synthetic: p[:synthetic] || false,
506
+ seat: true,
460
507
  counted: p[:verdict] != 'SKIP',
461
508
  # Every route to SKIP now names its own reason, so there is
462
509
  # nothing left to default to. A row that somehow arrives without
463
510
  # one omits the field — silence, which a reader can see, rather
464
511
  # than `transport`, which a reader cannot tell from a fact.
465
512
  reason: (p[:skip_reason] if p[:verdict] == 'SKIP'),
513
+ # INV-R1: the word a final submission offered as its verdict and
514
+ # the vocabulary refused. The reason stays a closed token; the
515
+ # written form is its own column.
516
+ stated_text: p[:stated_text],
517
+ # INV-R7: how the artifact reached this seat, when distribution
518
+ # was an act the system performed for it.
519
+ artifact_delivery: p[:artifact_delivery],
466
520
  model_divergence: p[:model_divergence]
467
521
  }.compact
468
522
  end
@@ -471,8 +525,13 @@ module KairosMcp
471
525
  {
472
526
  role_label: slot[:role_label] || slot['role_label'],
473
527
  model: slot[:model] || slot['model'],
528
+ seat: true,
474
529
  counted: false,
475
530
  reason: slot[:reason] || slot['reason'],
531
+ # INV-R7: an undelivered seat's delivery form is a per-seat
532
+ # attribute of the record, not something to dig out of the
533
+ # reason string.
534
+ artifact_delivery: slot[:artifact_delivery] || slot['artifact_delivery'],
476
535
  replaced_by: slot[:replaced_by] || slot['replaced_by']
477
536
  }.compact
478
537
  end
@@ -23,7 +23,13 @@ module KairosMcp
23
23
  # Dispatch review prompts to all configured reviewers in parallel.
24
24
  #
25
25
  # @param reviewers [Array<Hash>] each with :provider, :model, :role_label
26
- # @param messages [Array<Hash>] prompt messages for llm_call
26
+ # and optionally :artifact_delivery ('inline' | 'by_reference')
27
+ # @param messages [Array<Hash>, Hash] prompt messages for llm_call.
28
+ # An Array is one set of messages for every reviewer (every seat
29
+ # inline — the pre-v0.7 shape, still valid). A Hash maps a delivery
30
+ # form to its messages ({'inline' => [...], 'by_reference' => [...]})
31
+ # and each reviewer receives the set its :artifact_delivery names
32
+ # (INV-R7); a form absent from the map falls back to 'inline'.
27
33
  # @param system_prompt [String] system prompt for llm_call
28
34
  # @param context [InvocationContext] for invoke_tool
29
35
  # @param review_context [String] 'independent' or 'project_aware'
@@ -149,8 +155,16 @@ module KairosMcp
149
155
  provider = reviewer[:provider] || reviewer['provider']
150
156
  model = reviewer[:model] || reviewer['model']
151
157
 
158
+ delivery = reviewer[:artifact_delivery] || reviewer['artifact_delivery']
159
+ per_seat_messages =
160
+ if messages.is_a?(Hash)
161
+ messages[delivery || 'inline'] || messages['inline']
162
+ else
163
+ messages
164
+ end
165
+
152
166
  args = {
153
- 'messages' => messages,
167
+ 'messages' => per_seat_messages,
154
168
  'system' => system_prompt,
155
169
  'provider_override' => provider
156
170
  }
@@ -192,6 +206,12 @@ module KairosMcp
192
206
  model_observed: observed,
193
207
  model_source: observed ? 'observed' : 'declared',
194
208
  model_divergence: observed && declared && observed != declared,
209
+ # INV-R6: the transport's own account of the call, as state tags.
210
+ # The adapter has returned these since the 2026-07-31 divergence
211
+ # diagnosis; this is where they used to be dropped.
212
+ api_error_status: llm_response.dig('response', 'api_error_status'),
213
+ fast_mode_state: llm_response.dig('response', 'fast_mode_state'),
214
+ artifact_delivery: delivery_of(reviewer),
195
215
  raw_text: llm_response.dig('response', 'content') || '',
196
216
  elapsed_seconds: elapsed.round(1),
197
217
  error: nil,
@@ -214,6 +234,7 @@ module KairosMcp
214
234
  model: reviewer[:model] || reviewer['model'],
215
235
  model_declared: reviewer[:model] || reviewer['model'],
216
236
  model_source: 'declared',
237
+ artifact_delivery: delivery_of(reviewer),
217
238
  elapsed_seconds: elapsed.round(1),
218
239
  error: err,
219
240
  status: :error
@@ -227,12 +248,20 @@ module KairosMcp
227
248
  model: reviewer[:model] || reviewer['model'],
228
249
  model_declared: reviewer[:model] || reviewer['model'],
229
250
  model_source: 'declared',
251
+ artifact_delivery: delivery_of(reviewer),
230
252
  elapsed_seconds: 0,
231
253
  error: { 'type' => 'skip', 'message' => reason },
232
254
  status: :skip
233
255
  }
234
256
  end
235
257
 
258
+ # INV-R7: what a dispatched seat's record says about its delivery. A
259
+ # seat with no declaration was delivered inline — that has been the
260
+ # only form there was, and it is the default the roster inherits.
261
+ def delivery_of(reviewer)
262
+ reviewer[:artifact_delivery] || reviewer['artifact_delivery'] || 'inline'
263
+ end
264
+
236
265
  def kill_dispatch_pids(dispatch_id)
237
266
  if defined?(KairosMcp::SkillSets::LlmClient::SafeSubprocess)
238
267
  KairosMcp::SkillSets::LlmClient::SafeSubprocess.kill_pids_for_dispatch(dispatch_id)