kairos-chain 3.55.0 → 3.56.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/CHANGELOG.md +35 -0
- data/lib/kairos_mcp/version.rb +1 -1
- data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +196 -11
- data/templates/skillsets/llm_client/lib/llm_client/claude_code_adapter.rb +25 -3
- data/templates/skillsets/llm_client/skillset.json +1 -1
- data/templates/skillsets/llm_client/test/test_claude_code_adapter_parse.rb +104 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +265 -74
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/observer_set.rb +43 -3
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/persona_assembly.rb +60 -28
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/prompt_builder.rb +18 -2
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/review_serializer.rb +39 -0
- data/templates/skillsets/multi_llm_review/lib/multi_llm_review/verdict_vocabulary.rb +171 -0
- data/templates/skillsets/multi_llm_review/skillset.json +2 -2
- data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +165 -50
- data/templates/skillsets/multi_llm_review/test/test_mutation_survivors.rb +1103 -0
- data/templates/skillsets/multi_llm_review/test/test_observer_set.rb +146 -8
- data/templates/skillsets/multi_llm_review/test/test_observer_set_seams.rb +745 -76
- data/templates/skillsets/multi_llm_review/test/test_tool_wiring.rb +155 -8
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +86 -52
- data/templates/skillsets/multi_llm_review/tools/multi_llm_review_collect.rb +27 -27
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 185d6af51fd316124bc72eabbfc3f6b280fee6a2beeda91ed2b690781bb6c26f
|
|
4
|
+
data.tar.gz: 83951da8550d67d22b67a62153c71a923c4d016a59e94c86d07062a5aac4fedb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 14288ed7e74a7a276f68c94292935804830f1d302272ac5ff2e1ba11865ac00cf99acb3d8d93382a2fa0c878e3c0df1ddb4f2b9dfbc742f387386d32c0a6e1da
|
|
7
|
+
data.tar.gz: cdc967af9cd5962d175787d22cdfd63c881be35aefd6d2dcfba0afd14714482674465cf11df138f646214c37b2800163b982546da5381367c8826d9a74b3b7f4
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [3.56.0] - 2026-07-31
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`multi_llm_review` SkillSet 0.7.0 — verdict reading hardened across both
|
|
12
|
+
observer paths** (review rounds R10–R15, frozen by operator declaration
|
|
13
|
+
2026-07-31 after three consecutive rounds with zero deployment-grounded
|
|
14
|
+
findings).
|
|
15
|
+
- The verdict vocabulary is one table (`VerdictVocabulary::WORDS`); the
|
|
16
|
+
prose-search and whole-value patterns are built from it, differing only
|
|
17
|
+
in anchoring and separator strictness. The rebuilt regexes are
|
|
18
|
+
source-identical to the previous literals.
|
|
19
|
+
- Verdict *reading* is `stated` (whole-value, anchored) on every path.
|
|
20
|
+
`VerdictVocabulary.classify`, `PersonaAssembly.normalize_verdict`, and
|
|
21
|
+
the dead constants `VERDICT_PATTERNS` / `ALLOWED_VERDICTS` /
|
|
22
|
+
`*_ALIASES` are deleted; their absence is pinned by tests.
|
|
23
|
+
- A persona verdict field that is not a verdict is **refused at
|
|
24
|
+
submission** (`PersonaAssembly.validate!` raises ArgumentError) instead
|
|
25
|
+
of being word-searched (≤ R12) or defaulted to REVISE (R13). The collect
|
|
26
|
+
tool validates before consuming, so a refused submission leaves the
|
|
27
|
+
pending token collectable and the corrected submission carries the vote.
|
|
28
|
+
- Declared verdicts are carried in canonical case (`extract_verdict`
|
|
29
|
+
merges the admitted form), so a lower-case declaration can no longer sit
|
|
30
|
+
in the denominator without counting.
|
|
31
|
+
- Test suite grew 401 → 478 runs / 1676 assertions, including a
|
|
32
|
+
mutation-survivor file distilled from three generative sweeps.
|
|
33
|
+
- **`llm_client` SkillSet 0.2.0 — CLI envelope diagnostics preserved.**
|
|
34
|
+
`claude_code_adapter` now picks `model_observed` by output tokens rather
|
|
35
|
+
than hash order, and passes `model_usage`, `api_error_status`,
|
|
36
|
+
`fast_mode_state` and `terminal_reason` through to the response. Four
|
|
37
|
+
model-divergence incidents (a slot requesting opus-4-6 answered by
|
|
38
|
+
haiku-4-5) were undiagnosable because these fields were discarded; the
|
|
39
|
+
divergence itself was confirmed real (the divergent reply cited
|
|
40
|
+
identifiers that exist nowhere in the reviewed code).
|
|
41
|
+
|
|
7
42
|
## [3.55.0] - 2026-07-27
|
|
8
43
|
|
|
9
44
|
### Added
|
data/lib/kairos_mcp/version.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: multi_llm_review_workflow
|
|
3
3
|
description: "Multi-LLM review methodology and execution — workflow pattern, CLI tooling, consensus analysis, Persona Assembly. Applicable to design, implementation, documentation, or any artifact."
|
|
4
|
-
version: "3.
|
|
4
|
+
version: "3.8.0"
|
|
5
5
|
tags:
|
|
6
6
|
- workflow
|
|
7
7
|
- review
|
|
@@ -29,6 +29,51 @@ This skill covers:
|
|
|
29
29
|
For **WHO** (which LLM is good at what), see: `multi_llm_reviewer_evaluation`
|
|
30
30
|
For **development lifecycle** (design → implement → verify), see: `design_to_implementation_workflow`
|
|
31
31
|
|
|
32
|
+
## Step -1 — Pre-declared review spec (loop hygiene)
|
|
33
|
+
|
|
34
|
+
> Validation scope: these rules were derived from one non-converging loop
|
|
35
|
+
> (multi_llm_review R10–R15, 2026-07) where they took the P0 count from 10 to
|
|
36
|
+
> 2 in two rounds and closed the loop in three. They are instance practice
|
|
37
|
+
> until reproduced on a second, non-self-referential subject; treat the
|
|
38
|
+
> numbers below as one loop's evidence, not a law.
|
|
39
|
+
|
|
40
|
+
Before dispatching round 1 — and again whenever the review TARGET changes —
|
|
41
|
+
write a review spec and declare it frozen for the round:
|
|
42
|
+
|
|
43
|
+
1. **Pre-declare the pass condition** (per `loop_validation`: spec before
|
|
44
|
+
judgement, fail-closed). State what APPROVE requires. A loop whose target
|
|
45
|
+
drifted (e.g. from an implementation to the instrument that measures it)
|
|
46
|
+
without a re-declared spec is structurally non-converging: a 300-claim
|
|
47
|
+
artifact at any realistic per-claim error rate yields double-digit
|
|
48
|
+
findings every round regardless of quality.
|
|
49
|
+
2. **Split target from appendix.** Only the shipping deliverable is
|
|
50
|
+
P0-eligible. Instruments, sweep logs, classification tables are appendix:
|
|
51
|
+
findings against them are advisory and go to the queue. This is what
|
|
52
|
+
collapsed the claim surface from ~350 to ~30.
|
|
53
|
+
3. **Cap fixes per round (≤5)** and write one line per fix: *what this fix
|
|
54
|
+
newly claims* (values pinned, ranges narrowed, failure visibility
|
|
55
|
+
changed). A fix that cannot state its new claims is doing more than the
|
|
56
|
+
finding asked.
|
|
57
|
+
4. **Pre-flight falsifier.** Before dispatch, one agent whose only job is to
|
|
58
|
+
refute every factual claim in the spec and artifact — especially numbers
|
|
59
|
+
and "X does not exist" claims. In this loop it caught real errors before
|
|
60
|
+
every single dispatch (3 + 1 + 0 refuted across R13–R15); rounds without
|
|
61
|
+
it had returned the same errors as P0s.
|
|
62
|
+
5. **Check threshold reachability before dispatching.** Compute the maximum
|
|
63
|
+
achievable approve count from live slots; if the threshold is
|
|
64
|
+
unreachable, declare the exhaustion path up front (the frozen design's
|
|
65
|
+
own closing: findings exhausted → operator freeze declaration) instead of
|
|
66
|
+
discovering it at collect.
|
|
67
|
+
6. **Reference originals by path + sha256; do not transcribe.** Reviewers
|
|
68
|
+
read the repository; the artifact carries the manifest. Transcription
|
|
69
|
+
errors are undetectable and 100KB+ pastes rot.
|
|
70
|
+
|
|
71
|
+
Corollaries observed in the same loop: fix the *class*, and fix every copy —
|
|
72
|
+
a corrected lib comment whose refuted twin survives in a test file costs a
|
|
73
|
+
full round. When an author writes history into comments, the falsifier must
|
|
74
|
+
check the cited records; two of the loop's P0s were numbers copied from the
|
|
75
|
+
wrong document.
|
|
76
|
+
|
|
32
77
|
## Step 0 — Load reviewer characteristics (mandatory)
|
|
33
78
|
|
|
34
79
|
**Before invoking any reviewer**, fetch `multi_llm_reviewer_evaluation` via
|
|
@@ -878,26 +923,102 @@ after five consecutive non-substantive returns.
|
|
|
878
923
|
|
|
879
924
|
### Substance and the denominator
|
|
880
925
|
|
|
881
|
-
A reply
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
926
|
+
A reply counts toward the denominator when it **carries a verdict** and **says
|
|
927
|
+
something beyond it**. Both conditions, separately checked; a reply failing
|
|
928
|
+
either leaves the denominator rather than being counted, because otherwise it
|
|
929
|
+
raises the agreement everyone else must reach while contributing none of its own.
|
|
930
|
+
|
|
931
|
+
The two failures look nothing alike, and conflating them is why this rule was
|
|
932
|
+
rebuilt four times:
|
|
933
|
+
|
|
934
|
+
| Reply | Carries a verdict? | Says something? | Outcome |
|
|
935
|
+
|---|---|---|---|
|
|
936
|
+
| `APPROVE` | yes | no | `insubstantial` |
|
|
937
|
+
| `APPROVE. P0` | yes | no (a severity names no defect) | `insubstantial` |
|
|
938
|
+
| `I'll review this and give my assessment.` | **no** | yes | `no_verdict` |
|
|
939
|
+
| `競合状態あり` | no (no verdict word) | yes | `no_verdict` |
|
|
940
|
+
| `REJECT: P0 key logged in plaintext` | yes | yes | counts |
|
|
941
|
+
| `NO-GO` + findings | yes (see the vocabulary below) | yes | counts as REJECT |
|
|
942
|
+
|
|
943
|
+
The second row is the shape that retired Fable 5 — long enough to pass any
|
|
944
|
+
substance rule, and stating no judgement. Three rebuilds of the substance rule
|
|
945
|
+
missed it because they were all looking at the wrong half. A reply that states
|
|
946
|
+
no verdict used to be counted as a conservative `REVISE`, which blocked
|
|
947
|
+
convergence on a judgement its author never gave.
|
|
885
948
|
|
|
886
949
|
- The test is mechanical and has no setting: **does anything remain once the
|
|
887
950
|
verdict word is removed?** No model is asked to judge whether a review is good.
|
|
888
951
|
- It applies to every observer equally, the persona team included. A persona
|
|
889
952
|
submission whose findings are blank leaves the denominator exactly as a
|
|
890
|
-
subprocess reply would.
|
|
953
|
+
subprocess reply would — and so does a structured reply from any other slot.
|
|
954
|
+
A JSON reply is judged by **the words it carries, whatever keys they live
|
|
955
|
+
under**, not by the text it renders to and not by keys named in advance.
|
|
956
|
+
Judging it by rendered text counted its own key names as content: the residue
|
|
957
|
+
of `{"overall_verdict": "APPROVE", "findings": [], "reasoning": ""}` is three
|
|
958
|
+
words of schema and no review. Naming the keys instead threw away real
|
|
959
|
+
reviews: a REJECT whose findings used `description` was recorded as an empty
|
|
960
|
+
submission. **Answer in whatever shape you like** — nothing here requires a
|
|
961
|
+
particular key.
|
|
962
|
+
- **Open your reply with your verdict.** A structured submission states its
|
|
963
|
+
verdict as a field; a free-text reply states it in an `**Overall Verdict**`
|
|
964
|
+
header **on the first line, before anything else**. A header anywhere else is
|
|
965
|
+
not read as yours — it may be a sample you are discussing — so quoting verdict
|
|
966
|
+
headers further down is safe and needs no escaping.
|
|
967
|
+
|
|
968
|
+
Three rounds tried to tell a stated header from a displayed one by reading the
|
|
969
|
+
text more cleverly, and each attempt opened a hole in the direction that
|
|
970
|
+
passes: round 4 anchored the search to the start of a line, and a line inside
|
|
971
|
+
a fence starts a line; round 6 excluded fenced and blockquoted regions, and a
|
|
972
|
+
four-backtick fence closes on the inner three-backtick line, taking the rest
|
|
973
|
+
of the reply — including the real verdict — with it. A quotation and a
|
|
974
|
+
statement are the same characters, so no amount of reading decides between
|
|
975
|
+
them. Position does, because a quotation cannot be first unless the reviewer
|
|
976
|
+
chose to open with one.
|
|
977
|
+
|
|
978
|
+
A reply that opens with a preamble falls to a last-resort word scan over the
|
|
979
|
+
whole text. That is a guess, and it reads quotations along with everything
|
|
980
|
+
else — but it checks REJECT first, so it cannot turn a stated rejection into
|
|
981
|
+
a recorded approval.
|
|
982
|
+
- **The words that count as a verdict are one list, the same for every
|
|
983
|
+
observer.** `APPROVE / PASS / ACCEPT / LGTM / SHIP IT`,
|
|
984
|
+
`REJECT / FAIL / BLOCK / NO-GO / NACK / DENY / VETO`,
|
|
985
|
+
`REVISE / CHANGES REQUIRED / NEEDS WORK / NEEDS REVISION / REWORK`. Where a
|
|
986
|
+
reply carries more than one, the blocking reading wins (REJECT, then REVISE,
|
|
987
|
+
then APPROVE) — a reply saying two of them has qualified one judgement, not
|
|
988
|
+
stated two. Japanese verdict words are deliberately **not** in this list
|
|
989
|
+
(author decision, 2026-07-28): a Japanese review counts on its findings, but
|
|
990
|
+
its judgement must be stated in one of the words above.
|
|
891
991
|
- It is **not** a quality bar, and must not be tuned into one. `競合状態あり` is a
|
|
892
992
|
review. So is `REJECT: P0 private key logged in plaintext`. Earlier versions of
|
|
893
993
|
this rule measured length and, at one setting, discarded Japanese reviews
|
|
894
994
|
entirely — reviewers answer in the artifact's language, and this project's
|
|
895
995
|
artifacts are largely Japanese.
|
|
896
|
-
- What leaves the denominator is recorded with the reason
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
996
|
+
- What leaves the denominator is recorded with the reason, per reviewer and
|
|
997
|
+
again in `denominator_composition`. A slot that answered emptily and a slot
|
|
998
|
+
that never answered are both absent from the numerator and must never be
|
|
999
|
+
confused in the record, so `skip_reason` distinguishes: `insubstantial` (a
|
|
1000
|
+
reply that said nothing beyond its verdict), `no_verdict` (a reply that stated
|
|
1001
|
+
no judgement), `transport` (a call that was attempted and failed),
|
|
1002
|
+
`not_dispatched` or the dispatcher's own wording such as `dispatch_timeout`
|
|
1003
|
+
(a slot this system declined to run), and `declined` (a submission that stated
|
|
1004
|
+
SKIP for itself — reachable only by a caller that declares it, which nothing
|
|
1005
|
+
in this repository currently does). Only a token-shaped reason is carried
|
|
1006
|
+
through; anything else becomes `not_dispatched`, so a traceback or a sentence
|
|
1007
|
+
cannot land in a field documented as a small vocabulary. Nothing is defaulted:
|
|
1008
|
+
a row with no reason omits the field, because a default here states a cause
|
|
1009
|
+
the record does not know.
|
|
1010
|
+
|
|
1011
|
+
- **The escalation record is filled in only where it is wholly absent.** Pending
|
|
1012
|
+
state written before escalation existed carries no such field, and that one
|
|
1013
|
+
case is answered truthfully — a version with no escalation to offer cannot
|
|
1014
|
+
have been asked for it. A record that exists is carried as recorded, gaps and
|
|
1015
|
+
all: completing a partial one wrote `requested: false` beside
|
|
1016
|
+
`escalated: true`, a pair the producing code cannot emit. Silence about a key
|
|
1017
|
+
is legible to a reader; a value asserted where none was recorded is not.
|
|
1018
|
+
- The count beside these is `observers_reporting` — the observers that answered,
|
|
1019
|
+
which is not the number of slots the configuration named. That question is
|
|
1020
|
+
answered by `denominator_composition`, which lists every observer including
|
|
1021
|
+
the ones that never ran and why.
|
|
901
1022
|
|
|
902
1023
|
**What this rule cannot do.** It cannot tell a terse honest approval from a lazy
|
|
903
1024
|
one. `**Overall Verdict**: APPROVE / No findings` is the exact form the reviewer
|
|
@@ -1247,5 +1368,69 @@ Compression ratio: parallel agent raw → Assembly ≈ 2:1
|
|
|
1247
1368
|
the human, is the primary close — a reached ratio is neither necessary nor
|
|
1248
1369
|
sufficient on its own
|
|
1249
1370
|
|
|
1371
|
+
- Verdict determination hardened after a live failure (v3.7.1, 2026-07-27):
|
|
1372
|
+
round 4 of the escalation/persona implementation review recorded the persona
|
|
1373
|
+
team's REVISE as an APPROVE. The team's verdict was being re-derived by
|
|
1374
|
+
searching the assembled text, and a persona had quoted
|
|
1375
|
+
`{"overall_verdict": "APPROVE", ...}` inside a finding as an example of a
|
|
1376
|
+
defect; the search found the quotation before the `**Overall Verdict**:
|
|
1377
|
+
REVISE` on line 1. Three fixes: a structured submission now states its verdict
|
|
1378
|
+
as a field and nothing in its prose overrules it; the header is recognised
|
|
1379
|
+
only at the start of a line; and a JSON verdict is read by parsing a reply
|
|
1380
|
+
that *is* a JSON document rather than by scanning prose for an object. The
|
|
1381
|
+
residual limitation is recorded in § Substance and the denominator — the
|
|
1382
|
+
last-resort word heuristic still sees verdict words inside quotations, so
|
|
1383
|
+
state your verdict in the header when discussing reply shapes. Separately,
|
|
1384
|
+
INV-E2 was found to have been implemented by half: it asks that a counted
|
|
1385
|
+
reply *carry a verdict* and *have substance*, and only substance was checked,
|
|
1386
|
+
so an opening sentence with no judgement in it entered the denominator as a
|
|
1387
|
+
conservative REVISE — the exact shape that retired Fable 5, blocking
|
|
1388
|
+
convergence on nobody's verdict
|
|
1389
|
+
|
|
1390
|
+
- Three of round 4's own fixes reopened what they closed (v3.7.2, 2026-07-28):
|
|
1391
|
+
round 5 of the same implementation review found that each of the three
|
|
1392
|
+
verdict fixes recorded above had left a hole, and all three in the direction
|
|
1393
|
+
that passes. Start-of-line header matching does not exclude a quotation,
|
|
1394
|
+
because a line inside a fenced block starts a line — the header is now read
|
|
1395
|
+
from what the reviewer said, with fences and blockquotes excluded, and a
|
|
1396
|
+
reply that is entirely fenced is read whole because it is quoting nothing.
|
|
1397
|
+
Judging a structured reply by named keys (`reasoning`, `issue`) threw away a
|
|
1398
|
+
REJECT whose findings used `description` — the rule now asks whether anything
|
|
1399
|
+
was said in words, under any key. And the words that count as a verdict were
|
|
1400
|
+
two different lists, so `NO-GO` from a persona was a REJECT while `NO-GO`
|
|
1401
|
+
from an external slot was no judgement at all; there is now one list, with
|
|
1402
|
+
one precedence. Alongside these: `skip_reason` distinguishes five outcomes
|
|
1403
|
+
where it previously flattened three into `transport`; `total_configured` was
|
|
1404
|
+
renamed `observers_reporting` because it never counted what it claimed; the
|
|
1405
|
+
eased convergence rule after an exclusion now asks whether the denominator
|
|
1406
|
+
actually shrank rather than which reason fired; and a synchronous delegation
|
|
1407
|
+
writes the same storage layout as the parallel one, so the lock serialising
|
|
1408
|
+
two concurrent collects is no longer silently skipped
|
|
1409
|
+
|
|
1410
|
+
- Position replaces cleverness in verdict reading (v3.7.3, 2026-07-28): round 6
|
|
1411
|
+
was reviewed and rejected by five of six observers, all of them naming the
|
|
1412
|
+
same shape — the fixes of round 6 had reopened what they closed, in the
|
|
1413
|
+
direction that passes. Two were shipped defects: the header capture was
|
|
1414
|
+
written with `\s`, which includes the newline in Ruby, so it ran past the
|
|
1415
|
+
header into the following prose and recorded a stated APPROVE followed by
|
|
1416
|
+
"No blocking issues" as a REJECT; and the fence regex tracked neither fence
|
|
1417
|
+
length nor delimiter, so a four-backtick block quoting a three-backtick
|
|
1418
|
+
sample gave the quoted verdict the reviewer's vote. Rather than a fourth
|
|
1419
|
+
attempt at reading free text more carefully, the rule is now positional: the
|
|
1420
|
+
reviewer's verdict is the header the reply opens with, and the fence and
|
|
1421
|
+
blockquote machinery is deleted. The prompt asks for it there, and this test
|
|
1422
|
+
now pins that it does. Alongside: a partial escalation record is carried as
|
|
1423
|
+
recorded rather than completed with values the producing code cannot emit;
|
|
1424
|
+
only a token-shaped skip reason is carried into the record; and a row with no
|
|
1425
|
+
reason omits the field in the per-reviewer list as it already did in the
|
|
1426
|
+
composition.
|
|
1427
|
+
|
|
1428
|
+
The round's other lesson was about testing, and it is recorded here because
|
|
1429
|
+
it generalises past this SkillSet: **a regex pinned by one literal example is
|
|
1430
|
+
free everywhere else**. Round 6's own mutation pass replaced whole functions
|
|
1431
|
+
and killed 21 of 21; the review's mutation pass went after regex internals —
|
|
1432
|
+
fence markers, character classes, digit ranges, word boundaries — and 18 of
|
|
1433
|
+
27 survived. Mutate the inside of a pattern, not only the pattern.
|
|
1434
|
+
|
|
1250
1435
|
**Key insight**: Design reviews and implementation reviews find
|
|
1251
1436
|
**categorically different bugs**. Both phases are necessary.
|
|
@@ -183,18 +183,40 @@ module KairosMcp
|
|
|
183
183
|
result_text = data['result'] || ''
|
|
184
184
|
tool_use = extract_tool_use(result_text)
|
|
185
185
|
usage = data['usage'] || {}
|
|
186
|
+
model_usage = data['modelUsage'] || {}
|
|
187
|
+
|
|
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.
|
|
195
|
+
observed = model_usage.max_by { |_m, u| (u || {})['outputTokens'].to_i }&.first
|
|
186
196
|
|
|
187
197
|
{
|
|
188
198
|
'content' => tool_use ? nil : result_text,
|
|
189
199
|
'tool_use' => tool_use,
|
|
190
200
|
'stop_reason' => tool_use ? 'tool_use' : map_stop_reason(data['stop_reason']),
|
|
191
|
-
'model' => requested_model ||
|
|
201
|
+
'model' => requested_model || observed || 'claude_code',
|
|
192
202
|
# What the CLI reports as having answered, when it reports it.
|
|
193
203
|
# Kept separate from 'model' (which echoes the request) so callers
|
|
194
204
|
# can tell a request from an observation and notice when the two
|
|
195
205
|
# disagree — the CLI may serve a different model than the one
|
|
196
|
-
# asked for.
|
|
197
|
-
|
|
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).
|
|
211
|
+
'model_observed' => observed,
|
|
212
|
+
# Diagnostic envelope, previously discarded. All four divergence
|
|
213
|
+
# incidents above were undiagnosable from the record because the
|
|
214
|
+
# fields that say what happened did not survive this method.
|
|
215
|
+
# Consumers that persist reviews should carry these through.
|
|
216
|
+
'model_usage' => model_usage.empty? ? nil : model_usage,
|
|
217
|
+
'api_error_status' => data['api_error_status'],
|
|
218
|
+
'fast_mode_state' => data['fast_mode_state'],
|
|
219
|
+
'terminal_reason' => data['terminal_reason'],
|
|
198
220
|
'input_tokens' => usage['input_tokens'],
|
|
199
221
|
'output_tokens' => usage['output_tokens']
|
|
200
222
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What parse_response reads from the CLI envelope, pinned after the
|
|
4
|
+
# claude_cli_opus4.6 divergence investigation (2026-07-31).
|
|
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.
|
|
15
|
+
|
|
16
|
+
require 'minitest/autorun'
|
|
17
|
+
require_relative '../lib/llm_client/adapter'
|
|
18
|
+
require_relative '../lib/llm_client/claude_code_adapter'
|
|
19
|
+
|
|
20
|
+
module KairosMcp
|
|
21
|
+
module SkillSets
|
|
22
|
+
module LlmClient
|
|
23
|
+
class TestClaudeCodeAdapterParse < Minitest::Test
|
|
24
|
+
def setup
|
|
25
|
+
@adapter = ClaudeCodeAdapter.new({ 'timeout_seconds' => 30 })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse(payload, requested_model: 'claude-opus-4-6')
|
|
29
|
+
@adapter.send(:parse_response, JSON.generate(payload), requested_model: requested_model)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def envelope(model_usage:, **extra)
|
|
33
|
+
{
|
|
34
|
+
'type' => 'result', 'is_error' => false, 'result' => 'ok',
|
|
35
|
+
'stop_reason' => 'end_turn',
|
|
36
|
+
'usage' => { 'input_tokens' => 10, 'output_tokens' => 20 },
|
|
37
|
+
'modelUsage' => model_usage
|
|
38
|
+
}.merge(extra)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_a_single_model_envelope_is_that_model
|
|
42
|
+
out = parse(envelope(model_usage: {
|
|
43
|
+
'claude-opus-4-6' => { 'outputTokens' => 116 }
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
assert_equal 'claude-opus-4-6', out['model_observed']
|
|
47
|
+
end
|
|
48
|
+
|
|
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.
|
|
52
|
+
def test_the_answering_model_is_the_one_that_wrote_the_output
|
|
53
|
+
out = parse(envelope(model_usage: {
|
|
54
|
+
'claude-haiku-4-5-20251001' => { 'outputTokens' => 12 },
|
|
55
|
+
'claude-opus-4-6' => { 'outputTokens' => 2048 }
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
assert_equal 'claude-opus-4-6', out['model_observed']
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_a_divergent_answer_is_observed_as_itself
|
|
62
|
+
out = parse(envelope(model_usage: {
|
|
63
|
+
'claude-haiku-4-5-20251001' => { 'outputTokens' => 900 }
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
assert_equal 'claude-haiku-4-5-20251001', out['model_observed']
|
|
67
|
+
assert_equal 'claude-opus-4-6', out['model']
|
|
68
|
+
refute_equal out['model'], out['model_observed']
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_an_envelope_without_model_usage_observes_nothing
|
|
72
|
+
out = parse(envelope(model_usage: nil))
|
|
73
|
+
|
|
74
|
+
assert_nil out['model_observed']
|
|
75
|
+
assert_nil out['model_usage']
|
|
76
|
+
assert_equal 'claude-opus-4-6', out['model']
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_the_diagnostic_fields_survive_into_the_response
|
|
80
|
+
out = parse(envelope(
|
|
81
|
+
model_usage: { 'claude-haiku-4-5-20251001' => { 'outputTokens' => 900 } },
|
|
82
|
+
'api_error_status' => 429,
|
|
83
|
+
'fast_mode_state' => 'off',
|
|
84
|
+
'terminal_reason' => 'completed'
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
assert_equal({ 'claude-haiku-4-5-20251001' => { 'outputTokens' => 900 } }, out['model_usage'])
|
|
88
|
+
assert_equal 429, out['api_error_status']
|
|
89
|
+
assert_equal 'off', out['fast_mode_state']
|
|
90
|
+
assert_equal 'completed', out['terminal_reason']
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_a_missing_output_tokens_entry_does_not_crash_the_choice
|
|
94
|
+
out = parse(envelope(model_usage: {
|
|
95
|
+
'claude-opus-4-6' => nil,
|
|
96
|
+
'claude-haiku-4-5-20251001' => { 'outputTokens' => 5 }
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
assert_equal 'claude-haiku-4-5-20251001', out['model_observed']
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|