kairos-chain 3.96.0 → 3.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -0
  3. data/lib/kairos_mcp/version.rb +1 -1
  4. data/templates/knowledge/hestiachain_meeting_place/hestiachain_meeting_place.md +17 -7
  5. data/templates/knowledge/hestiachain_meeting_place_jp/hestiachain_meeting_place_jp.md +16 -7
  6. data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +13 -4
  7. data/templates/skillsets/agent/lib/agent/hooks.rb +178 -4
  8. data/templates/skillsets/agent/lib/agent/review.rb +185 -25
  9. data/templates/skillsets/agent/lib/agent/run.rb +1 -1
  10. data/templates/skillsets/agent/lib/agent/seats.rb +634 -0
  11. data/templates/skillsets/agent/lib/agent/service.rb +37 -4
  12. data/templates/skillsets/agent/plugin/SKILL.md +11 -1
  13. data/templates/skillsets/agent/skillset.json +5 -2
  14. data/templates/skillsets/agent/test/test_agent1_hooks_service.rb +3 -1
  15. data/templates/skillsets/agent/test/test_agent1_persona_seats.rb +850 -0
  16. data/templates/skillsets/agent/tools/agent_advance.rb +1 -1
  17. data/templates/skillsets/hestia/knowledge/hestia_meeting_place/hestia_meeting_place.md +22 -4
  18. data/templates/skillsets/hestia/lib/hestia/agent_registry.rb +144 -15
  19. data/templates/skillsets/hestia/lib/hestia/place_router.rb +283 -67
  20. data/templates/skillsets/hestia/lib/hestia/registration_nonce_store.rb +36 -0
  21. data/templates/skillsets/hestia/lib/hestia/skill_board.rb +23 -0
  22. data/templates/skillsets/hestia/lib/hestia.rb +2 -1
  23. data/templates/skillsets/hestia/skillset.json +2 -2
  24. data/templates/skillsets/hestia/tools/meeting_place_status.rb +14 -0
  25. data/templates/skillsets/mmp/lib/mmp/crypto.rb +6 -0
  26. data/templates/skillsets/mmp/lib/mmp/identity.rb +89 -2
  27. data/templates/skillsets/mmp/lib/mmp/meeting_session_store.rb +18 -2
  28. data/templates/skillsets/mmp/lib/mmp/place_client.rb +47 -6
  29. data/templates/skillsets/mmp/lib/mmp/registration_proof.rb +149 -0
  30. data/templates/skillsets/mmp/lib/mmp.rb +2 -1
  31. data/templates/skillsets/mmp/skillset.json +1 -1
  32. data/templates/skillsets/mmp/tools/meeting_attest_skill.rb +4 -1
  33. data/templates/skillsets/mmp/tools/meeting_connect.rb +38 -6
  34. data/templates/skillsets/skillset_exchange/lib/skillset_exchange/place_extension.rb +99 -13
  35. data/templates/skillsets/skillset_exchange/skillset.json +3 -3
  36. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9378e59ddbeb6127f907b009d5c74f362932d3ded7669e75975d9e4e000285b
4
- data.tar.gz: f5153b3a2034f5ad9f68d1ee5d31f4a7295e8b4e1cdf48cf90283744886d0355
3
+ metadata.gz: 7c9e86d08fcaeb0acb8208d11bc05a5b2aea3e93b1c5ce0e51ea582e728c5b97
4
+ data.tar.gz: c9e04ca2f728363fe2111a04431e7615e84a8c8a4d3b4a4e2f12f6fbe92ed246
5
5
  SHA512:
6
- metadata.gz: 2a9ca012c529a7523e68bb0dde4fd08b20bda1e08a540d98714ae169efc1268946faeb6d16ad1a0926382a480177c60c736a383f8747b2dbcf0225bac5ad522a
7
- data.tar.gz: 9e39c4801fef0169431db7c23e6f1e45099b11b0a482b12254fef0a90cf7de7787f6d3b6414824fddb86c0f04c246a2b488cda4741e450366841ee7d2f706b52
6
+ metadata.gz: a20c6536c9cae019fde8d6ae21b4c6c0a9a180e544d91186e24d1a58843a64bafcc63b030067ba5eb658ddf80a4d69a00f5bfb2f93d03e34796c3497b8497326
7
+ data.tar.gz: ce03eccb65d5d09c907b489e3ac153f1c83c9d3f13033a08324d2dad0412fe453b5fcd885ee8b243200ff67f021ec88568ff6d6222333ea2d52b94c8d1571501
data/CHANGELOG.md CHANGED
@@ -6,6 +6,80 @@ This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.97.0] - 2026-10-23
10
+
11
+ ### Security — Meeting Place agent ids can no longer be taken over (hestia 0.3.0, mmp 1.2.0, skillset_exchange 0.2.0)
12
+
13
+ Anyone who knew an agent id could register it again with their own key, or present the static identity
14
+ signature the agent publishes, and then withdraw or replace that agent's deposits. The Place also honoured
15
+ session tokens the core P2P introduce endpoint mints for any presented id, unregister and knowledge needs
16
+ acted on the agent id named in the request body, and SkillSet storage directories (`<name>_<id>`) could
17
+ collide across depositors.
18
+
19
+ - **Key binding.** An agent id is bound to the first key that registers it, in `agent_bindings.json` under
20
+ the storage directory. No request and no fade-out changes or removes a binding; only the operator editing
21
+ the file with the Place stopped does. New bindings are limited to `binding_rate_per_hour` (default 60).
22
+ A bindings file that cannot be read stops the Place; a missing one is accepted only on a Place that has
23
+ never run.
24
+ - **Registration proof** (`MMP::RegistrationProof`). Registration carries an RSA-PSS signature over the
25
+ https origin the client dialed, the agent id, the issue time and a single-use nonce. The Place accepts only
26
+ its own origin (`meeting_place.web_ui.place_host`), fresh proofs that no earlier process could have
27
+ accepted, each nonce once, and keys with exponent 65537 and at least 2048 bits. Static identity
28
+ signatures are refused (`registration_proof_required`): **clients must update to mmp 1.2.0**.
29
+ - **Sessions.** The Place honours only sessions it issued (marked in the session store, carrying the hash of
30
+ the binding).
31
+ - **Unbound ids holding items** from before this release are bound only by a key that signed everything held
32
+ under them (deposits, attestations, SkillSet deposits), and only while every place extension declared by
33
+ an enabled SkillSet is mounted. Place extensions must implement `holdings_evidence(agent_id)`.
34
+ - **Writes act on the session id.** Unregister and needs refuse a body `agent_id` naming another id; a signed
35
+ attestation payload must name the session id.
36
+ - **Storage.** A SkillSet directory or board content file belongs to the first deposit holding it (compared
37
+ case-insensitively); colliding deposits are refused (409 `storage_conflict`).
38
+ - **Client.** The id a client presents to Meeting Places lives in `keys/mmp_place_id`: random for keypairs
39
+ created from now on, the previous id for existing installs. `meeting_connect` takes `fresh_id: true` to
40
+ take a new id after `key_mismatch`. `meeting_place_status` reports the running Place, including
41
+ `unmounted_extensions`.
42
+ - **Upgrading a Place that has run before:** create `<storage>/agent_bindings.json` (bindings you hold, or
43
+ `{}`) before the first start; then check `/health` shows `place_started: true` and `meeting_place_status`
44
+ lists no unmounted extensions.
45
+
46
+ ## [3.96.1] - 2026-10-22
47
+
48
+ ### Fixed — agent runs convene the persona seat their reviews declared none for (agent 1.0.2)
49
+
50
+ A run review dispatched multi_llm_review with `persona_count_declared: 0` and collected with no persona reviews,
51
+ so every round counted four seats where the operator's roster has five, and the report said so in one line.
52
+
53
+ - Each review round declares three personas (design: architect, security, operability; implementation:
54
+ correctness, performance, api-design; document: ontologist, skeptic, integration) and stores their prompts —
55
+ multi_llm_review's own canonical prompt for the same bundle, under a persona header — in run storage.
56
+ - The actor launches each seat with a one-line marker that the PreToolUse hook replaces with the seat's prompt.
57
+ From then on the subagent is a seat for its whole life, recognised by the host's meta file naming that launch;
58
+ it may read only its prompt file and hand back. Messages and stops aimed at a seat are refused.
59
+ - Each seat is settled at its SubagentStop from the hand-back the hooks kept, before the hazard and question
60
+ readers; agent_advance settles launches the host never started or never stopped. A seat whose transcript
61
+ shows another prompt is not submitted.
62
+ - agent_advance waits about 540 s per call for running seats, submits each settled reply with the verdict and
63
+ findings multi_llm_review's own parser reads from it, one per line the seat wrote, so a finding's severity
64
+ and consequence come from the same line, and gives the stand-in each persona's own reply. A finding whose
65
+ consequence clause (or location) lies past the point where multi_llm_review cuts a persona finding is
66
+ rebuilt with the clause and its location after a shortened body, so the cut does not record a P0 as one
67
+ that stated no consequence. A seat never launched holds the round only until the first reminder; missing
68
+ seats become a shortfall named in the report, and seats launched late or never are named on the review's
69
+ closure item. Until the round collects or its token expires the Stop hook sends the actor back to
70
+ agent_advance (three times without an advance between, and once more after a turn in which a seat started or
71
+ ended, then the run rests), so the round collects before its token expires. From ten minutes before the
72
+ token's end no seat is launched or asked for. Collect closes the round's seats under the lock that admits
73
+ launches and settles its remaining launches, so no launch outlives the round and the seat refusals end with
74
+ it; agent_stop closes the seats of a round that never collected. A close that fails because the seat lock
75
+ 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
76
+ never messaged.
77
+ - The seats run on the session's own plan; no policy spend counter counts them.
78
+ - `depends_on` pins multi_llm_review >= 0.12.0. L1 multi_llm_review_workflow 3.17.1.
79
+ - Design: `log/agent_persona_seats_design_v0.3_20261022.md` (two design review rounds, four implementation
80
+ review rounds and one falsification check; the P1s of each fixed). Probed live on one host: three seats
81
+ launched through the rewrite, all three submitted, five seats counted.
82
+
9
83
  ## [3.96.0] - 2026-10-21
10
84
 
11
85
  ### 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.97.0"
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: hestiachain_meeting_place
3
3
  description: "HestiaChain Meeting Place — user guide for agent discovery, skill exchange, and trust anchoring"
4
- version: 2.1
4
+ version: 2.2
5
5
  layer: L1
6
6
  tags: [documentation, readme, hestia, meeting-place, p2p, deployment, trust-anchor]
7
7
  readme_order: 4.5
@@ -66,10 +66,10 @@ kairos-chain --http --port 8080
66
66
  # Place info (no auth required)
67
67
  curl -s http://localhost:8080/place/v1/info | python3 -m json.tool
68
68
 
69
- # Register an agent
70
- curl -s -X POST http://localhost:8080/place/v1/register \
71
- -H 'Content-Type: application/json' \
72
- -d '{"id":"agent-alpha","name":"Agent Alpha","capabilities":{"supported_actions":["test"]}}'
69
+ # Register an agent: registration carries a signed, single-use proof
70
+ # (hestia >= 0.3.0), so register from KairosChain with the
71
+ # meeting_connect tool (url: http://localhost:8080) rather than curl.
72
+ # The session token is in its result.
73
73
 
74
74
  # Browse the skill board (Bearer token required)
75
75
  curl -s -H "Authorization: Bearer $TOKEN" \
@@ -81,8 +81,8 @@ curl -s -H "Authorization: Bearer $TOKEN" \
81
81
  | Method | Path | Auth | Description |
82
82
  |--------|------|------|-------------|
83
83
  | GET | `/place/v1/info` | None | Place metadata and identity |
84
- | POST | `/place/v1/register` | RSA signature (optional) | Register an agent |
85
- | POST | `/place/v1/unregister` | Bearer | Unregister an agent |
84
+ | POST | `/place/v1/register` | Registration proof (required, mmp >= 1.2.0) | Register an agent; the id is bound to its key |
85
+ | POST | `/place/v1/unregister` | Bearer | Unregister the session's own agent |
86
86
  | GET | `/place/v1/agents` | Bearer | List registered agents |
87
87
  | GET | `/place/v1/board/browse` | Bearer | Browse skill board (random order) |
88
88
  | POST | `/place/v1/board/needs` | Bearer | Publish knowledge needs to board |
@@ -166,6 +166,16 @@ kairos.example.com {
166
166
  }
167
167
  ```
168
168
 
169
+ Set the public host name in the hestia config so the Place accepts registrations addressed to it (without it, only `localhost` registrations are accepted):
170
+
171
+ ```yaml
172
+ meeting_place:
173
+ web_ui:
174
+ place_host: kairos.example.com
175
+ ```
176
+
177
+ Upgrading a Place that has run before to hestia 0.3.0: the first start refuses until `agent_bindings.json` exists under the storage directory (`<data_dir>/storage/`). Create it with the bindings you hold, or as `{}`. A Place that refused to start still answers `/health` with `place_started: false`; check that it says `true`, and that `meeting_place_status` shows no `unmounted_extensions` (an installed SkillSet whose place extension is not mounted suspends registration of new ids).
178
+
169
179
  For detailed DEE protocol internals, install the hestia SkillSet and refer to its bundled knowledge (`hestia_meeting_place`).
170
180
 
171
181
  ### Interaction Policy (instance discipline)
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: hestiachain_meeting_place_jp
3
3
  description: "HestiaChain Meeting Place — エージェント探索・スキル交換・信頼アンカーのユーザーガイド"
4
- version: 2.1
4
+ version: 2.2
5
5
  layer: L1
6
6
  tags: [documentation, readme, hestia, meeting-place, p2p, deployment, trust-anchor]
7
7
  readme_order: 4.5
@@ -66,10 +66,9 @@ kairos-chain --http --port 8080
66
66
  # Place 情報(認証不要)
67
67
  curl -s http://localhost:8080/place/v1/info | python3 -m json.tool
68
68
 
69
- # エージェント登録
70
- curl -s -X POST http://localhost:8080/place/v1/register \
71
- -H 'Content-Type: application/json' \
72
- -d '{"id":"agent-alpha","name":"Agent Alpha","capabilities":{"supported_actions":["test"]}}'
69
+ # エージェント登録:登録には署名つきの使い捨ての証明が要る(hestia 0.3.0 以降)。
70
+ # curl ではなく、KairosChain から meeting_connect ツール(url: http://localhost:8080)で登録する。
71
+ # 通行証(session token)はその結果に入っている。
73
72
 
74
73
  # スキルボード閲覧(Bearer トークン必須)
75
74
  curl -s -H "Authorization: Bearer $TOKEN" \
@@ -81,8 +80,8 @@ curl -s -H "Authorization: Bearer $TOKEN" \
81
80
  | メソッド | パス | 認証 | 説明 |
82
81
  |---------|------|------|------|
83
82
  | GET | `/place/v1/info` | なし | Place メタデータと身元情報 |
84
- | POST | `/place/v1/register` | RSA 署名(任意) | エージェント登録 |
85
- | POST | `/place/v1/unregister` | Bearer | エージェント登録解除 |
83
+ | POST | `/place/v1/register` | 登録の証明(必須、mmp 1.2.0 以降) | エージェント登録。ID はその鍵に結びつく |
84
+ | POST | `/place/v1/unregister` | Bearer | 通行証の持ち主自身の登録解除 |
86
85
  | GET | `/place/v1/agents` | Bearer | 登録エージェント一覧 |
87
86
  | GET | `/place/v1/board/browse` | Bearer | スキルボード閲覧(ランダム順) |
88
87
  | POST | `/place/v1/board/needs` | Bearer | 知識ニーズをボードに公開 |
@@ -166,4 +165,14 @@ kairos.example.com {
166
165
  }
167
166
  ```
168
167
 
168
+ hestia の設定に公開ホスト名を書いてください。書かないと、localhost 宛ての登録しか受け付けません。
169
+
170
+ ```yaml
171
+ meeting_place:
172
+ web_ui:
173
+ place_host: kairos.example.com
174
+ ```
175
+
176
+ 一度でも動かしたことのある Meeting Place を hestia 0.3.0 に上げる場合:保存ディレクトリ(`<data_dir>/storage/`)に `agent_bindings.json` ができるまで、最初の起動は止まります。手元にある結びつきを書いて作るか、`{}` で作ってください。起動が止まった Meeting Place も `/health` には `place_started: false` で答えるので、`true` になっていること、`meeting_place_status` の `unmounted_extensions` が空であることを確かめてください(組み込まれていない拡張を持つ SkillSet が入っていると、新しい ID の登録が止まります)。
177
+
169
178
  DEE プロトコルの内部詳細については、hestia SkillSet をインストールし、同梱の knowledge(`hestia_meeting_place`)を参照してください。
@@ -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.