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.
- checksums.yaml +4 -4
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/skillsets/agent/test/test_agent_complexity_review.rb +66 -0
- data/templates/skillsets/agent/tools/agent_step.rb +9 -2
- data/templates/skillsets/llm_client/lib/llm_client/claude_code_adapter.rb +17 -12
- data/templates/skillsets/llm_client/test/test_claude_code_adapter_parse.rb +14 -12
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/build_review_bundle.rb +30 -2
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +93 -34
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/dispatcher.rb +31 -2
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/pending_state.rb +128 -16
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/persona_assembly.rb +65 -24
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/prompt_builder.rb +22 -6
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/review_serializer.rb +23 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/verdict_vocabulary.rb +27 -31
- data/templates/skillsets/multi_llm_review/skillset.json +8 -4
- data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +397 -45
- data/templates/skillsets/multi_llm_review/test/test_mutation_survivors.rb +61 -40
- data/templates/skillsets/multi_llm_review/test/test_observer_set.rb +1 -1
- data/templates/skillsets/multi_llm_review/test/test_observer_set_seams.rb +62 -34
- data/templates/skillsets/multi_llm_review/test/test_pending_state_v3.rb +122 -2
- data/templates/skillsets/multi_llm_review/test/test_tool_wiring.rb +276 -2
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +373 -113
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review_collect.rb +82 -6
- data/templates/skillsets/synoptis/lib/synoptis/attestation_engine.rb +35 -2
- data/templates/skillsets/synoptis/lib/synoptis/proof_envelope.rb +55 -3
- data/templates/skillsets/synoptis/lib/synoptis/tool_helpers.rb +9 -2
- data/templates/skillsets/synoptis/lib/synoptis/verifier.rb +32 -5
- data/templates/skillsets/synoptis/tools/attestation_verify.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8439763123a17e68d238ebc46369dcc36e6379172752809dd6736fa18f3c5ec4
|
|
4
|
+
data.tar.gz: 76fe818c7261e464411f54bee9a114bc7eb0ecc025042ef264f15bd591f2ca36
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2385335588b313d3910519459a79a1325f921fa1f6d1a2b215f0b75144c383b14b90a95f1d9edf97999df6cdc58d95155e653f13007217299f35df097891f38a
|
|
7
|
+
data.tar.gz: 160f831a399a60089d217e2fe198dfe60692797649b55c2d8b4fbd0d0649512c6f601a75dfc41de713b9bf946be83485f01c289252018c87d33dbbb06221c1ec
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
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
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
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)
|
|
7
|
-
# claude-opus-4-6
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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:
|
|
50
|
-
# carries a second model beside the main call
|
|
51
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
# it
|
|
230
|
-
#
|
|
231
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
#
|
|
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' =>
|
|
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)
|