kairos-chain 3.96.0 → 3.96.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9378e59ddbeb6127f907b009d5c74f362932d3ded7669e75975d9e4e000285b
4
- data.tar.gz: f5153b3a2034f5ad9f68d1ee5d31f4a7295e8b4e1cdf48cf90283744886d0355
3
+ metadata.gz: faa4327412fbbe72c412e3e4cccd41173a346e488366b0d4963023cb389ce97e
4
+ data.tar.gz: 13767304ee185463ac1a1de1293873762c3658f81e8a5c6d007023ca29970377
5
5
  SHA512:
6
- metadata.gz: 2a9ca012c529a7523e68bb0dde4fd08b20bda1e08a540d98714ae169efc1268946faeb6d16ad1a0926382a480177c60c736a383f8747b2dbcf0225bac5ad522a
7
- data.tar.gz: 9e39c4801fef0169431db7c23e6f1e45099b11b0a482b12254fef0a90cf7de7787f6d3b6414824fddb86c0f04c246a2b488cda4741e450366841ee7d2f706b52
6
+ metadata.gz: 657b3094d640bd4876964d3ffd88f47fd57fda12147c77d882293c68662d66a791c306d8eaae4fd4238041f641c318dc7825bb0ff6c314b2c963086146a7eb17
7
+ data.tar.gz: 2f765af6b6122a2a3627678f3481a410be0bc0f359bb7b479a6e726fe6f1891264c023d42007840d1fd75e03d175109b78a164647d6402f771322fff8a3783d6
data/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.96.1] - 2026-10-22
10
+
11
+ ### Fixed — agent runs convene the persona seat their reviews declared none for (agent 1.0.2)
12
+
13
+ A run review dispatched multi_llm_review with `persona_count_declared: 0` and collected with no persona reviews,
14
+ so every round counted four seats where the operator's roster has five, and the report said so in one line.
15
+
16
+ - Each review round declares three personas (design: architect, security, operability; implementation:
17
+ correctness, performance, api-design; document: ontologist, skeptic, integration) and stores their prompts —
18
+ multi_llm_review's own canonical prompt for the same bundle, under a persona header — in run storage.
19
+ - The actor launches each seat with a one-line marker that the PreToolUse hook replaces with the seat's prompt.
20
+ From then on the subagent is a seat for its whole life, recognised by the host's meta file naming that launch;
21
+ it may read only its prompt file and hand back. Messages and stops aimed at a seat are refused.
22
+ - Each seat is settled at its SubagentStop from the hand-back the hooks kept, before the hazard and question
23
+ readers; agent_advance settles launches the host never started or never stopped. A seat whose transcript
24
+ shows another prompt is not submitted.
25
+ - agent_advance waits about 540 s per call for running seats, submits each settled reply with the verdict and
26
+ findings multi_llm_review's own parser reads from it, one per line the seat wrote, so a finding's severity
27
+ and consequence come from the same line, and gives the stand-in each persona's own reply. A finding whose
28
+ consequence clause (or location) lies past the point where multi_llm_review cuts a persona finding is
29
+ rebuilt with the clause and its location after a shortened body, so the cut does not record a P0 as one
30
+ that stated no consequence. A seat never launched holds the round only until the first reminder; missing
31
+ seats become a shortfall named in the report, and seats launched late or never are named on the review's
32
+ closure item. Until the round collects or its token expires the Stop hook sends the actor back to
33
+ agent_advance (three times without an advance between, and once more after a turn in which a seat started or
34
+ ended, then the run rests), so the round collects before its token expires. From ten minutes before the
35
+ token's end no seat is launched or asked for. Collect closes the round's seats under the lock that admits
36
+ launches and settles its remaining launches, so no launch outlives the round and the seat refusals end with
37
+ it; agent_stop closes the seats of a round that never collected. A close that fails because the seat lock
38
+ cannot be taken is journaled as such (`seats_close_failed`) and the settled replies are still submitted. A settled seat may be stopped; it is
39
+ never messaged.
40
+ - The seats run on the session's own plan; no policy spend counter counts them.
41
+ - `depends_on` pins multi_llm_review >= 0.12.0. L1 multi_llm_review_workflow 3.17.1.
42
+ - Design: `log/agent_persona_seats_design_v0.3_20261022.md` (two design review rounds, four implementation
43
+ review rounds and one falsification check; the P1s of each fixed). Probed live on one host: three seats
44
+ launched through the rewrite, all three submitted, five seats counted.
45
+
9
46
  ## [3.96.0] - 2026-10-21
10
47
 
11
48
  ### Changed — multi_llm_review says what the roster was when a result is read (multi_llm_review 0.12.0, llm_client 0.4.0)
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.96.0"
2
+ VERSION = "3.96.1"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -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.17.0"
4
+ version: "3.17.1"
5
5
  tags:
6
6
  - workflow
7
7
  - review
@@ -896,9 +896,11 @@ A counted seat's maker is observed (the seat reported its model), configured (th
896
896
  roster pins the model and the seat ran on its own provider — this holds while the
897
897
  CLI's own configuration does not route that name to another maker), or
898
898
  caller-declared (a persona seat that was not observed). Only the first two
899
- establish diversity. Two kinds of round always carry the line: an `exclude` round
900
- whose caller names a slot (that slot does not count), and an agent SkillSet round
901
- (it submits no personas). None of this changes the verdict or the quorum
899
+ establish diversity. One kind of round always carries the line: an `exclude` round
900
+ whose caller names a slot (that slot does not count). An agent SkillSet round no
901
+ longer always carries it: agent 1.0.2 convenes three personas on the host and
902
+ submits the replies the host handed back, where 1.0.1 submitted none, so its line
903
+ follows the same rule as any other round. None of this changes the verdict or the quorum
902
904
  (`min_quorum`, 2 shipped). An INSUFFICIENT result also says how many seats counted
903
905
  and how many were needed, that a seat can be added in config, and the restoring
904
906
  action for each reason in the table above.
@@ -2117,6 +2119,13 @@ Compression ratio: parallel agent raw → Assembly ≈ 2:1
2117
2119
  multi_llm_review 0.12.0 and llm_client 0.4.0. Design
2118
2120
  `log/mlr_non_claude_host_design_v0.3.3_20261021.md`, frozen after four rounds.
2119
2121
 
2122
+ - Agent runs convene persona seats (v3.17.1, 2026-10-22, operator request:
2123
+ 「Agent SkillSet1.0.1でreview時にpersona数が0に決め打ちされてるそうですが、MLRの設定と異なるので
2124
+ 修正して下さい」). § Reduced rosters no longer says an agent SkillSet round always
2125
+ carries the roster line: agent 1.0.2 declares and convenes three personas, launched
2126
+ by the run's actor through a marker the SkillSet's hook rewrites, and submits the
2127
+ replies the host handed back. Design `log/agent_persona_seats_design_v0.3_20261022.md`.
2128
+
2120
2129
  **Key insight**: Design reviews and implementation reviews find
2121
2130
  **categorically different bugs**. Both phases are necessary. The corollary that
2122
2131
  cost eight rounds to learn: **which of the two you are in is readable from the
@@ -10,6 +10,7 @@ require_relative 'stand_in'
10
10
  require_relative 'precedents'
11
11
  require_relative 'policies'
12
12
  require_relative 'transcript'
13
+ require_relative 'seats'
13
14
 
14
15
  module KairosMcp
15
16
  module SkillSets
@@ -32,6 +33,13 @@ module KairosMcp
32
33
  "(#{run.target_repo}) as a new file — each version is a new file. Then call " \
33
34
  "agent_advance(run_id: '#{id}', phase: '#{phase.name}')."
34
35
  when :review
36
+ seats = Seats.instruction(run, phase.name.to_s)
37
+ return seats if seats
38
+ if run.in_flight_review
39
+ return "Phase #{phase.name}: a review round is running. Call agent_advance(run_id: '#{id}', " \
40
+ "phase: '#{phase.name}') to collect it."
41
+ end
42
+
35
43
  answer = plan.phase(phase.args[:of])&.kind == :produce ? 'writing the next version as a new file' : 'new commits on the run branch'
36
44
  "Phase #{phase.name}: call agent_advance(run_id: '#{id}', phase: '#{phase.name}'). It dispatches the next " \
37
45
  "review round of the latest #{phase.args[:of]} artifact and, on the next call, returns the seats' findings " \
@@ -136,6 +144,11 @@ module KairosMcp
136
144
  sid = input['session_id']
137
145
  touch_guard_seen(sid)
138
146
  tool = input['tool_name'].to_s
147
+ # A review seat's own calls are placed before anything else, the
148
+ # agent-tool shortcut included (design v0.3 §3.5).
149
+ if input['agent_id'] && (out = seat_tool_gate(input, tool))
150
+ return out
151
+ end
139
152
  if Hooks.agent_tool?(tool)
140
153
  # The operator's words after an ending release the guard before the
141
154
  # agent tool runs (agent_start right after them is not refused).
@@ -150,6 +163,9 @@ module KairosMcp
150
163
  return nil unless run
151
164
  return nil if release_if_typed(run, sid)
152
165
 
166
+ seat = seat_launch_gate(run, input, tool)
167
+ return seat if seat
168
+
153
169
  policy = policy_for(run)
154
170
  unless policy
155
171
  return deny("the policy pinned by run #{run.id} changed or is missing; the guard cannot decide. " \
@@ -379,6 +395,9 @@ module KairosMcp
379
395
  def stop(input, subagent:)
380
396
  run = Run.bound_to(@paths, input['session_id'])
381
397
  return nil unless run
398
+ # A review seat's end settles it, before any other Stop-hook path:
399
+ # its reply is a review, not a hazard report or a question.
400
+ return nil if subagent && seat_stop(run, input) == :seat
382
401
  return nil if release_if_typed(run, input['session_id'])
383
402
  return nil if run.status == 'finished'
384
403
 
@@ -401,7 +420,10 @@ module KairosMcp
401
420
  end
402
421
  return out if out
403
422
  end
404
- return nil if !subagent && Array(input['background_tasks']).any?
423
+ # Background tasks let the actor rest, except while persona seats of
424
+ # a dispatch are out: then the round must reach agent_advance before
425
+ # its token expires.
426
+ return nil if !subagent && Array(input['background_tasks']).any? && !Seats.holding_dispatch(run.journal.entries)
405
427
  if live && QUESTION_END.match?(tail.strip)
406
428
  answer = prose_question(run, policy, tail)
407
429
  return block("Stand-in answer (provisional, journaled): #{answer}. Continue the run.") if answer
@@ -430,11 +452,28 @@ module KairosMcp
430
452
  progressed = run.last_block_seq.nil? || run.last_progress_seq.to_i > run.last_block_seq.to_i ||
431
453
  (last_block && last_block['fingerprint'] && last_block['fingerprint'] != fingerprint)
432
454
  if run.in_flight_review
455
+ # While persona seats are out, each agent_advance call (which waits
456
+ # for them) earns one more block, until the round collects.
457
+ entries = run.journal.entries
458
+ if Seats.holding_dispatch(entries)
459
+ # Blocked every time until the round collects, so the call that
460
+ # collects comes before the token expires; three blocks with no
461
+ # agent_advance between them is an actor not following, and the
462
+ # run rests rather than spin. Only what an agent_advance call
463
+ # journals counts as one between them: a seat launching or
464
+ # settling is not the actor calling it.
465
+ last_wait = entries.reverse.find { |e| %w[seats_prompted seats_waited review_dispatched].include?(e['kind']) }
466
+ since = entries.count { |e| e['kind'] == 'stop_blocked' && e['for'] == 'persona_seats' && e['seq'] > last_wait.to_h['seq'].to_i }
467
+ if since < Seats::HOLD_BLOCKS
468
+ run.append(:stop_blocked, for: 'persona_seats', fingerprint: fingerprint)
469
+ return block("Run #{run.id}: #{Guidance.next_step(run)}")
470
+ end
471
+ end
433
472
  # Once per progress: a round that never comes back rests the run.
434
- last = run.journal.entries.reverse.find { |e| e['kind'] == 'stop_blocked' }
435
- unless last && last['for'] == 'review_in_flight' && !progressed
473
+ last = entries.reverse.find { |e| e['kind'] == 'stop_blocked' }
474
+ unless last && %w[review_in_flight persona_seats].include?(last['for']) && !progressed
436
475
  run.append(:stop_blocked, for: 'review_in_flight', fingerprint: fingerprint)
437
- return block("Run #{run.id}: a review round is running. Call agent_advance(run_id: '#{run.id}') to collect it.")
476
+ return block("Run #{run.id}: #{Guidance.next_step(run)}")
438
477
  end
439
478
  end
440
479
  if progressed
@@ -523,6 +562,141 @@ module KairosMcp
523
562
  File.exist?(f) && (Time.now - File.mtime(f)) <= within
524
563
  end
525
564
 
565
+ # --- persona seats (design log/agent_persona_seats_design_v0.3) ------------
566
+
567
+ # A subagent's own tool call while the run has seat launches: a seat
568
+ # may hand back and read its own prompt file, nothing else, and a
569
+ # subagent whose launch cannot be read is held to that while a seat is
570
+ # out. nil when the call is not a seat's.
571
+ def seat_tool_gate(input, tool)
572
+ run = Run.bound_to(@paths, input['session_id'])
573
+ return nil unless run
574
+
575
+ entries = run.journal.entries
576
+ return nil unless Seats.any_launch?(entries)
577
+
578
+ kind, launch = Seats.identify(entries, input)
579
+ return nil if kind == :other
580
+ return nil if kind == :unknown && Seats.unsettled_launches(entries).empty?
581
+
582
+ if tool == Seats::HANDBACK
583
+ Seats.keep_handback(run, input['agent_id'], input.dig('tool_input', 'message')) if kind == :seat
584
+ return allow
585
+ end
586
+ return allow if kind == :seat && tool == 'Read' && seat_reads_own_prompt?(run, launch, input['tool_input'], input['cwd'] || Dir.pwd)
587
+
588
+ if kind == :seat
589
+ deny("A review seat reads only its prompt file and answers with its hand-back; #{tool} is refused to it " \
590
+ '(INV-2). Answer from the prompt.')
591
+ else
592
+ deny('The run cannot tell whether this subagent is a review seat (its host meta file names no launch) while ' \
593
+ "a seat is out, so it is held to a seat's tools (INV-2).")
594
+ end
595
+ rescue StandardError, ScriptError => e
596
+ deny("agent seat gate error (#{e.class}): #{e.message[0, 200]}")
597
+ end
598
+
599
+ def seat_reads_own_prompt?(run, launch, tool_input, cwd = Dir.pwd)
600
+ given = tool_input.is_a?(Hash) ? tool_input['file_path'].to_s : ''
601
+ !given.empty? && File.expand_path(given, cwd) == File.expand_path(Seats.prompt_path(run, launch['full_sha256'].to_s))
602
+ end
603
+
604
+ # A launch of a seat by its marker, from any caller, and messages or
605
+ # stops aimed at a seat. nil when neither applies.
606
+ def seat_launch_gate(run, input, tool)
607
+ if Seats::LAUNCH_TOOLS.include?(tool)
608
+ prompt = input.dig('tool_input', 'prompt').to_s.strip
609
+ return nil unless prompt.start_with?(Seats::MARKER_PREFIX)
610
+
611
+ return Seats.locked(run) { launch_seat(run, input, prompt) }
612
+ end
613
+ return nil unless Seats::TARGET_TOOLS.include?(tool)
614
+
615
+ entries = run.journal.entries
616
+ return nil unless Seats.any_launch?(entries)
617
+
618
+ ti = input['tool_input'].is_a?(Hash) ? input['tool_input'] : {}
619
+ targets = Seats::TARGET_KEYS.map { |k| ti[k] }.select { |v| v.is_a?(String) && !v.empty? }
620
+ out = Seats.unsettled_launches(entries).any?
621
+ targets.each do |t|
622
+ kind, launch = Seats.identify(entries, input, t)
623
+ if kind == :seat
624
+ # A settled seat's reply is frozen, so stopping it changes no
625
+ # record; a message still could reach a reply, so it stays refused.
626
+ next if tool == 'TaskStop' && Seats.settlement_for(entries, launch)
627
+
628
+ return deny("#{tool} to a review seat is refused: a seat is neither messaged nor stopped by the run (INV-3).")
629
+ end
630
+ if kind == :unknown && out
631
+ return deny("#{tool} to #{t} is refused while a review seat is out: the run cannot tell whether it is a seat (INV-3).")
632
+ end
633
+ end
634
+ nil
635
+ end
636
+
637
+ # Admits one launch of a seat (under the run's seat lock) and replaces
638
+ # the marker with the seat's short prompt; every other key the caller
639
+ # gave is dropped.
640
+ def launch_seat(run, input, prompt)
641
+ if input['tool_use_id'].to_s.empty?
642
+ return deny('A review seat cannot be launched from a call the host gave no tool_use_id: the seat could not be bound to its launch.')
643
+ end
644
+
645
+ entries = run.journal.entries
646
+ live = Seats.live_markers(run.id, entries)
647
+ seat = live[prompt]
648
+ listing = live.empty? ? 'There is no seat to launch now.' : "Live markers:\n#{live.keys.join("\n")}"
649
+ unless seat
650
+ spent = Seats.spent_reason(run.id, entries, prompt)
651
+ return deny("This seat is no longer launched: #{spent}. #{listing}") if spent
652
+
653
+ return deny("Not a live review-seat marker. #{listing}")
654
+ end
655
+
656
+ why = Seats.launch_refusal(entries, seat)
657
+ return deny("Refused: #{why}. #{listing}") if why
658
+
659
+ path = Seats.prompt_path(run, seat['sha256'])
660
+ unless File.file?(path) && Digest::SHA256.file(path).hexdigest == seat['sha256']
661
+ return deny("The stored prompt of seat #{seat['persona']} no longer matches its dispatch; the seat cannot be launched.")
662
+ end
663
+
664
+ short = Seats.short_prompt(run, seat, Seats.dispatch_for(entries, seat['token']))
665
+ run.append(:seat_launched, token: seat['token'], persona: seat['persona'], phase: seat['phase'], round: seat['round'],
666
+ tool_use_id: input['tool_use_id'], full_sha256: seat['sha256'],
667
+ prompt_sha256: Digest::SHA256.hexdigest(short), session_id: input['session_id'],
668
+ subagents_dir: Seats.subagents_dir(input))
669
+ updated = { 'description' => "review seat #{seat['persona']}", 'prompt' => short, 'subagent_type' => 'general-purpose' }
670
+ effort = input['effort'].is_a?(Hash) ? input['effort']['level'] : input['effort']
671
+ updated['effort'] = effort if Seats::EFFORTS.include?(effort)
672
+ allow(updated_input: updated)
673
+ end
674
+
675
+ # Settles a seat at its first stop, and journals a subagent the run
676
+ # cannot place while a seat is out. :seat when the stop was a seat's.
677
+ def seat_stop(run, input)
678
+ entries = run.journal.entries
679
+ return nil unless Seats.any_launch?(entries)
680
+
681
+ kind, launch = Seats.identify(entries, input)
682
+ if kind == :seat
683
+ begin
684
+ reply = Seats.kept_handback(run, input['agent_id']) || input['last_assistant_message']
685
+ cause = Seats.first_prompt_sha256(Seats.host_files(input)) == launch['prompt_sha256'] ? nil : 'prompt_mismatch'
686
+ Seats.settle(run, launch, agent_id: input['agent_id'], reply: reply, cause: cause)
687
+ rescue StandardError, ScriptError => e
688
+ run.append(:seat_settle_failed, token: launch['token'], persona: launch['persona'], tool_use_id: launch['tool_use_id'],
689
+ reason: "#{e.class}: #{e.message[0, 200]}")
690
+ end
691
+ return :seat
692
+ end
693
+ if kind == :unknown && Seats.unsettled_launches(entries).any?
694
+ run.append(:subagent_unidentified, agent_id: input['agent_id'], agent_type: input['agent_type'],
695
+ cause: 'its host meta file names no launch')
696
+ end
697
+ nil
698
+ end
699
+
526
700
  # A session whose run another session took over: its guard refuses
527
701
  # what would act, until the operator speaks here again or the session
528
702
  # ends, so its turn does not continue unguarded.
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'digest'
5
5
  require 'fileutils'
6
+ require_relative 'seats'
6
7
 
7
8
  module KairosMcp
8
9
  module SkillSets
@@ -43,7 +44,8 @@ module KairosMcp
43
44
  # revision_diff: callable(previous review_round entry, this one) -> the
44
45
  # actor's change between the two rounds' artifacts, as text.
45
46
  def initialize(run:, policy:, stand_in:, invoke:, project_root:, words: -> { [] }, precedents: ->(_t) { [] },
46
- head: nil, type: nil, revision_diff: ->(_a, _b) { '' })
47
+ head: nil, type: nil, revision_diff: ->(_a, _b) { '' }, clock: -> { Time.now },
48
+ sleep: ->(s) { Kernel.sleep(s) })
47
49
  @run = run
48
50
  @policy = policy
49
51
  @stand_in = stand_in
@@ -54,6 +56,8 @@ module KairosMcp
54
56
  @head = head
55
57
  @type = type&.to_s
56
58
  @revision_diff = revision_diff
59
+ @clock = clock
60
+ @sleep = sleep
57
61
  end
58
62
 
59
63
  def credentials?(text)
@@ -124,58 +128,200 @@ module KairosMcp
124
128
  end
125
129
  clear_blocking(name)
126
130
 
127
- payload = @invoke.call('multi_llm_review', {
128
- 'artifact_name' => artifact_name, 'review_type' => review_type,
129
- 'artifact_content' => bundle, 'review_round' => round,
130
- 'orchestrator_strategy' => 'delegate', 'orchestrator_model' => 'claude-opus-5-5',
131
- 'persona_count_declared' => 0, 'escalate' => true, 'complexity' => 'high',
132
- 'prior_findings' => carry.map { |f| { 'id' => f['id'], 'severity' => 'P0', 'issue' => f['text'][0, 400] } }
133
- })
131
+ prior = carry.map { |f| { 'id' => f['id'], 'severity' => 'P0', 'issue' => f['text'][0, 400] } }
132
+ seats = compose_seats(name, round, review_type, artifact_name, bundle, prior)
133
+ request = {
134
+ 'artifact_name' => artifact_name, 'review_type' => review_type,
135
+ 'artifact_content' => bundle, 'review_round' => round,
136
+ 'orchestrator_strategy' => 'delegate', 'orchestrator_model' => 'claude-opus-5-5',
137
+ 'persona_count_declared' => seats.size, 'escalate' => true, 'complexity' => 'high',
138
+ 'prior_findings' => prior
139
+ }
140
+ request['collect_deadline_seconds_override'] = Seats::DEADLINE_SECONDS unless seats.empty?
141
+ payload = @invoke.call('multi_llm_review', request)
134
142
  token = payload['collect_token']
135
143
  return { 'status' => 'error', 'reason' => payload['error'] || 'no collect token' } unless token
136
144
 
137
- @run.append(:review_dispatched, { phase: name, round: round, token: token,
138
- bundle_sha256: Digest::SHA256.hexdigest(bundle), artifact_digest: artifact_digest,
139
- bundle_file: store_bundle(name, round, bundle) }.merge(extra))
145
+ entry = { phase: name, round: round, token: token,
146
+ bundle_sha256: Digest::SHA256.hexdigest(bundle), artifact_digest: artifact_digest,
147
+ bundle_file: store_bundle(name, round, bundle) }.merge(extra)
148
+ # Persona seats exist in the delegated protocol only; the seats, the
149
+ # token and its deadline are journaled in the one dispatch entry.
150
+ if !seats.empty? && payload['status'] == 'delegation_pending'
151
+ entry[:seats] = seats
152
+ entry[:must_collect_by] = payload['must_collect_by']
153
+ end
154
+ @run.append(:review_dispatched, entry)
140
155
  { 'status' => 'dispatched', 'round' => round,
141
156
  'next' => "Round #{round} is running (about 5-10 minutes). Call agent_advance again to collect it." }
142
157
  end
143
158
 
159
+ # The persona seats' full prompts, stored under their digests; none
160
+ # (the round runs as 1.0.1 did) when they cannot be composed.
161
+ def compose_seats(phase, round, review_type, artifact_name, bundle, prior)
162
+ composed = Seats.compose(review_type: review_type, artifact_name: artifact_name, bundle: bundle, round: round,
163
+ prior: prior, worktree: @run.worktree, branch: @run.branch)
164
+ Seats.store_prompts(@run, composed)
165
+ rescue StandardError, ScriptError => e
166
+ @run.append(:seats_not_composed, phase: phase, round: round, reason: "#{e.class}: #{e.message[0, 300]}")
167
+ []
168
+ end
169
+
144
170
  # Collects whatever the round produced, also after a worker crash: a
145
171
  # round with quorum is a round.
146
172
  def collect(phase, pending)
147
173
  name = phase.name.to_s
148
- wait = @invoke.call('multi_llm_review_wait', { 'collect_token' => pending['token'], 'max_wait_seconds' => 540 })
174
+ budget_end = @clock.call + Seats::WAIT_SECONDS
175
+ with_seats = pending['seats'].is_a?(Array) && !pending['seats'].empty?
176
+ if with_seats
177
+ held = seat_gate(pending, budget_end)
178
+ return held if held
179
+ end
180
+ left = (budget_end - @clock.call).to_i.clamp(30, Seats::WAIT_SECONDS)
181
+ wait = @invoke.call('multi_llm_review_wait', { 'collect_token' => pending['token'], 'max_wait_seconds' => left })
149
182
  st = wait['status'].to_s
150
183
  if WAITING.include?(st)
184
+ # Each call that waited earns the Stop hook's next block (§3.7).
185
+ @run.append(:seats_waited, phase: name, round: pending['round'], token: pending['token'], running: 0) if with_seats
151
186
  return { 'status' => 'pending', 'detail' => st, 'next' => 'The round is still running. Call agent_advance again.' }
152
187
  end
153
188
 
154
- result = @invoke.call('multi_llm_review_collect', { 'collect_token' => pending['token'],
155
- 'orchestrator_reviews' => [], 'include_raw_text' => true })
189
+ reviews, persona_rows, outcomes = with_seats ? seat_submission(pending) : [[], [], nil]
190
+ result = collect_call(pending['token'], reviews)
191
+ if result['status'] == 'error' && !reviews.empty? && result['error'].to_s.start_with?('invalid orchestrator_reviews')
192
+ # MLR's refusal of a persona submission leaves the token
193
+ # collectable: the external seats' replies are kept.
194
+ @run.append(:seat_submission_refused, phase: name, round: pending['round'], token: pending['token'],
195
+ detail: result['error'].to_s[0, 600])
196
+ outcomes.each { |o| o.merge!('submitted' => false, 'cause' => 'refused_by_review_tool') if o['submitted'] }
197
+ result = collect_call(pending['token'], [])
198
+ end
156
199
  if result['status'] != 'ok'
157
200
  detail = "#{st}: #{result['error'] || result['status']}"
158
- @run.append(:review_dispatch_failed, phase: name, round: pending['round'], detail: detail)
201
+ @run.append(:review_dispatch_failed, phase: name, round: pending['round'], token: pending['token'], detail: detail)
159
202
  return { 'status' => 'dispatch_failed', 'detail' => detail, 'next' => 'Call agent_advance again to dispatch the round anew.' }
160
203
  end
161
204
  seats = Array(result['reviews']).map do |r|
162
205
  { 'seat' => r['role_label'].to_s, 'verdict' => r['verdict'].to_s, 'reply' => (r['raw_text'] || r['raw_text_excerpt']).to_s }
163
206
  end
207
+ # What the stand-in reads: each persona's own reply in place of the
208
+ # review tool's assembled persona row (INV-3).
209
+ team = Array(result['reviews']).find { |r| r['synthetic'] || r.key?('persona_rows') }
210
+ team_label = team ? team['role_label'].to_s : 'claude_team'
211
+ read = if with_seats
212
+ seats.reject { |s| s['seat'] == team_label } +
213
+ persona_rows.map { |r| { 'seat' => "#{team_label}/#{r['persona']}", 'verdict' => r['verdict'], 'reply' => r['reply'] } }
214
+ else
215
+ seats
216
+ end
164
217
  p0_lines = Array(result['aggregated_findings']).select { |f| f.is_a?(Hash) && f['severity'].to_s.upcase == 'P0' }
165
218
  .map { |f| (f['issue'] || f['text']).to_s[0, 600] }
166
219
  counted = result.dig('vote_tally', 'successful_count').to_i
167
220
  quorum = result.dig('vote_tally', 'min_quorum').to_i.nonzero? || 2
168
- file = store_replies(name, pending['round'], seats, p0_lines)
169
- @run.append(:review_round, phase: name, round: pending['round'], counted: counted, quorum: quorum,
170
- wait_status: st, commit: pending['commit'], replies_file: file,
171
- bundle_sha256: pending['bundle_sha256'], artifact: pending['artifact'],
172
- bundle_file: pending['bundle_file'], artifact_digest: pending['artifact_digest'],
173
- seats: seats.map { |s| { 'seat' => s['seat'], 'verdict' => s['verdict'],
174
- 'reply_sha256' => Digest::SHA256.hexdigest(s['reply']),
175
- 'reply' => s['reply'][0, 4000] } })
221
+ file = store_replies(name, pending['round'], read, p0_lines)
222
+ @run.append(:review_round, { phase: name, round: pending['round'], token: pending['token'], counted: counted,
223
+ quorum: quorum, wait_status: st, commit: pending['commit'], replies_file: file,
224
+ bundle_sha256: pending['bundle_sha256'], artifact: pending['artifact'],
225
+ bundle_file: pending['bundle_file'], artifact_digest: pending['artifact_digest'],
226
+ seats: seats.map { |s| { 'seat' => s['seat'], 'verdict' => s['verdict'],
227
+ 'reply_sha256' => Digest::SHA256.hexdigest(s['reply']),
228
+ 'reply' => s['reply'][0, 4000] } } }
229
+ .merge(with_seats ? { personas: outcomes, persona_count_declared: pending['seats'].size } : {}))
176
230
  label_round(phase, @run.journal.entries.reverse.find { |e| e['kind'] == 'review_round' && e['phase'] == name })
177
231
  end
178
232
 
233
+ def collect_call(token, reviews)
234
+ @invoke.call('multi_llm_review_collect', { 'collect_token' => token, 'orchestrator_reviews' => reviews,
235
+ 'include_raw_text' => true })
236
+ end
237
+
238
+ # Holds the round while persona seats are reviewing, within this
239
+ # call's budget and never past the cutoff. Seats never launched get
240
+ # the launch instruction once; after that, and at the cutoff, what is
241
+ # missing becomes a shortfall. Returns a reply, or nil to collect.
242
+ def seat_gate(pending, budget_end)
243
+ token = pending['token']
244
+ loop do
245
+ at = @clock.call
246
+ Seats.settle_orphans(@run, at)
247
+ entries = @run.journal.entries
248
+ return nil if Seats.closed?(entries, token)
249
+
250
+ d = Seats.dispatch_for(entries, token) || pending
251
+ cutoff = Seats.cutoff_of(d)
252
+ return nil if cutoff && at >= cutoff
253
+
254
+ states = Seats.seat_states(entries, d)
255
+ launch = Seats.to_launch(states)
256
+ running = states.select { |s| s['state'] == 'running' }
257
+ prompted = entries.any? { |e| e['kind'] == 'seats_prompted' && e['token'] == token }
258
+ if !launch.empty? && !prompted
259
+ @run.append(:seats_prompted, phase: d['phase'], round: d['round'], token: token,
260
+ personas: launch.map { |s| s['persona'] })
261
+ return { 'status' => 'pending', 'detail' => 'persona seats to launch',
262
+ 'markers' => launch.map { |s| Seats.marker(@run.id, token, s['persona']) } }
263
+ end
264
+ return nil if running.empty?
265
+
266
+ if at >= budget_end
267
+ @run.append(:seats_waited, phase: d['phase'], round: d['round'], token: token, running: running.size)
268
+ return { 'status' => 'pending', 'detail' => 'persona seats reviewing', 'running' => running.map { |s| s['persona'] } }
269
+ end
270
+ @sleep.call([Seats::POLL_SECONDS, budget_end - at, cutoff ? cutoff - at : Seats::POLL_SECONDS].min.clamp(0.05, Seats::POLL_SECONDS))
271
+ end
272
+ end
273
+
274
+ # The seats' replies as settled: submitted to the review tool when a
275
+ # verdict reads from them, given to the stand-in either way, never
276
+ # inferred or transcribed.
277
+ def seat_submission(pending)
278
+ close_seats(pending)
279
+ entries = @run.journal.entries
280
+ d = Seats.dispatch_for(entries, pending['token']) || pending
281
+ reviews = []
282
+ rows = []
283
+ outcomes = Seats.seat_states(entries, d).map do |s|
284
+ o = { 'persona' => s['persona'], 'launches' => s['launches'], 'late' => s['late'] }
285
+ a = s['answer']
286
+ unless a
287
+ cause = case s['state']
288
+ when 'not_launched' then 'not_launched'
289
+ when 'running' then 'not_settled'
290
+ else s['last_cause'] || 'no_reply'
291
+ end
292
+ next o.merge('submitted' => false, 'cause' => cause)
293
+ end
294
+ text = Seats.settled_reply(a)
295
+ next o.merge('submitted' => false, 'cause' => 'reply_changed') unless text
296
+
297
+ v = Seats.verdict(text)
298
+ rows << { 'persona' => s['persona'], 'verdict' => v.to_s, 'reply' => text }
299
+ next o.merge('submitted' => false, 'cause' => 'no_verdict', 'agent_id' => a['agent_id']) unless v
300
+
301
+ reviews << { 'persona' => s['persona'], 'verdict' => v, 'findings' => Seats.findings(text, v),
302
+ 'reasoning' => text, 'agent_id' => a['agent_id'] }.compact
303
+ o.merge('submitted' => true, 'verdict' => v, 'agent_id' => a['agent_id'])
304
+ end
305
+ [reviews, rows, outcomes]
306
+ rescue StandardError, ScriptError => e
307
+ # The review tool's parsers are its internals: if they cannot be
308
+ # used, the round is collected without personas, and says why.
309
+ @run.append(:seat_submission_failed, phase: pending['phase'], round: pending['round'], token: pending['token'],
310
+ reason: "#{e.class}: #{e.message[0, 300]}")
311
+ [[], [], Array(pending['seats']).map { |x| { 'persona' => x['persona'], 'submitted' => false, 'cause' => 'parse_failed' } }]
312
+ end
313
+
314
+ # Closes the round's seats. A close that fails (the seat lock cannot be
315
+ # taken) is journaled as itself and the replies already settled are
316
+ # still submitted; launches left unsettled count as not_settled. A
317
+ # journal that cannot be written fails the round whatever this does.
318
+ def close_seats(pending)
319
+ Seats.settle_remaining(@run, pending['token'])
320
+ rescue StandardError => e
321
+ @run.append(:seats_close_failed, phase: pending['phase'], round: pending['round'], token: pending['token'],
322
+ reason: "#{e.class}: #{e.message[0, 300]}")
323
+ end
324
+
179
325
  # Labels a collected round. A failed labelling keeps the round: the
180
326
  # next advance labels it again without paying for another dispatch.
181
327
  def label_round(phase, entry)
@@ -234,12 +380,12 @@ module KairosMcp
234
380
  if quorate && open.empty?
235
381
  @run.append(:phase_ended, phase: name, by: 'closure', provisional: true, round: round)
236
382
  item = @run.open_item('closure', "#{name} closed provisionally at round #{round}; the operator confirms it, or sends " \
237
- 'it back (decision send_back)', ref: name)
383
+ "it back (decision send_back)#{seats_note(entry)}", ref: name)
238
384
  return { 'status' => 'closed_provisionally', 'round' => round, 'labels' => labels, 'item' => item['id'] }
239
385
  end
240
386
  if quorate && bound && round >= bound
241
387
  return refuse_with_item(name, 'review_open', "#{name} is open (#{open.size} P0) and has had its #{bound} rounds; it " \
242
- 'waits for the operator (decision more_rounds, or freeze)',
388
+ "waits for the operator (decision more_rounds, or freeze)#{seats_note(entry)}",
243
389
  "#{name} is at its round cap; it waits for the operator", status: 'waiting',
244
390
  extra: { 'labels' => labels },
245
391
  any_of_kind: true)
@@ -272,6 +418,20 @@ module KairosMcp
272
418
  steps.join(' ')
273
419
  end
274
420
 
421
+ # Persona seats the round went without because of how they were
422
+ # launched — never, or only after another seat had answered — named
423
+ # where the operator rules on the round (design v0.3 §3.8).
424
+ def seats_note(entry)
425
+ missed = Array(entry['personas']).select { |o| o['cause'] == 'not_launched' || o['late'] }
426
+ return '' if missed.empty?
427
+
428
+ '. Persona seats launched late or never in this round: ' +
429
+ missed.map do |o|
430
+ how = o['cause'] == 'not_launched' ? 'never launched' : 'first launched after another seat had answered'
431
+ "#{o['persona']} (#{how}#{o['submitted'] ? ', then submitted' : (o['cause'] == 'not_launched' ? '' : ", then #{o['cause']}")})"
432
+ end.join(', ')
433
+ end
434
+
275
435
  def tally_of(labels)
276
436
  n = ->(label, rev = nil) { labels.count { |l| l['label'] == label && (rev.nil? || (l['from_revision'] == true) == rev) } }
277
437
  { 'carried_open' => n.call('carried_over'), 'fixed' => n.call('fixed'), 'refuted' => n.call('refuted'),
@@ -396,7 +396,7 @@ module KairosMcp
396
396
  # passed to the operator is not progress: a turn made only of those
397
397
  # rests instead of being pushed on.
398
398
  PROGRESS = %w[item_resolved created pinned check review_dispatched review_round review_labelled phase_ended
399
- report_built plan_ratified].freeze
399
+ report_built plan_ratified seat_launched seat_settled].freeze
400
400
 
401
401
  FILE_TOOLS = %w[Edit Write MultiEdit NotebookEdit].freeze
402
402