kairos-chain 3.16.0 → 3.17.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +1 -0
  4. data/lib/kairos_mcp/daemon/active_observe.rb +180 -0
  5. data/lib/kairos_mcp/daemon/approval_gate.rb +231 -0
  6. data/lib/kairos_mcp/daemon/attach_server.rb +415 -0
  7. data/lib/kairos_mcp/daemon/budget.rb +172 -0
  8. data/lib/kairos_mcp/daemon/canonical.rb +83 -0
  9. data/lib/kairos_mcp/daemon/chronos.rb +641 -0
  10. data/lib/kairos_mcp/daemon/code_gen_act.rb +247 -0
  11. data/lib/kairos_mcp/daemon/code_gen_phase_handler.rb +87 -0
  12. data/lib/kairos_mcp/daemon/command_mailbox.rb +91 -0
  13. data/lib/kairos_mcp/daemon/credentials.rb +182 -0
  14. data/lib/kairos_mcp/daemon/daemon_llm_caller.rb +253 -0
  15. data/lib/kairos_mcp/daemon/daemon_policy.rb +112 -0
  16. data/lib/kairos_mcp/daemon/edit_kernel.rb +54 -0
  17. data/lib/kairos_mcp/daemon/elevation_token.rb +36 -0
  18. data/lib/kairos_mcp/daemon/execution_context.rb +21 -0
  19. data/lib/kairos_mcp/daemon/heartbeat.rb +138 -0
  20. data/lib/kairos_mcp/daemon/idempotency_check.rb +132 -0
  21. data/lib/kairos_mcp/daemon/idempotent_chain_recorder.rb +140 -0
  22. data/lib/kairos_mcp/daemon/integration.rb +293 -0
  23. data/lib/kairos_mcp/daemon/llm_phase_functions.rb +184 -0
  24. data/lib/kairos_mcp/daemon/mandate_factory.rb +123 -0
  25. data/lib/kairos_mcp/daemon/ooda_cycle_runner.rb +275 -0
  26. data/lib/kairos_mcp/daemon/pdf_build.rb +96 -0
  27. data/lib/kairos_mcp/daemon/pid_lock.rb +103 -0
  28. data/lib/kairos_mcp/daemon/planner.rb +100 -0
  29. data/lib/kairos_mcp/daemon/policy_elevation.rb +74 -0
  30. data/lib/kairos_mcp/daemon/proposal_routes.rb +135 -0
  31. data/lib/kairos_mcp/daemon/restricted_shell/argv_validators.rb +114 -0
  32. data/lib/kairos_mcp/daemon/restricted_shell/binary_resolver.rb +70 -0
  33. data/lib/kairos_mcp/daemon/restricted_shell/errors.rb +30 -0
  34. data/lib/kairos_mcp/daemon/restricted_shell/runner.rb +153 -0
  35. data/lib/kairos_mcp/daemon/restricted_shell/sandbox_context.rb +26 -0
  36. data/lib/kairos_mcp/daemon/restricted_shell/sandbox_factory.rb +89 -0
  37. data/lib/kairos_mcp/daemon/restricted_shell.rb +129 -0
  38. data/lib/kairos_mcp/daemon/scope_classifier.rb +44 -0
  39. data/lib/kairos_mcp/daemon/signal_handler.rb +80 -0
  40. data/lib/kairos_mcp/daemon/task_dag.rb +322 -0
  41. data/lib/kairos_mcp/daemon/wal.rb +391 -0
  42. data/lib/kairos_mcp/daemon/wal_phase_recorder.rb +90 -0
  43. data/lib/kairos_mcp/daemon/wal_recovery.rb +86 -0
  44. data/lib/kairos_mcp/daemon.rb +363 -0
  45. data/lib/kairos_mcp/invocation_context.rb +44 -6
  46. data/lib/kairos_mcp/logger.rb +164 -0
  47. data/lib/kairos_mcp/version.rb +1 -1
  48. data/templates/knowledge/llm_cross_evaluation/assets/prompts/cross_evaluation.md.erb +35 -0
  49. data/templates/knowledge/llm_cross_evaluation/assets/prompts/cross_evaluation_philosophy.md.erb +65 -0
  50. data/templates/knowledge/llm_cross_evaluation/assets/prompts/incompleteness_report.md.erb +36 -0
  51. data/templates/knowledge/llm_cross_evaluation/assets/prompts/meta_evaluation.md.erb +37 -0
  52. data/templates/knowledge/llm_cross_evaluation/assets/prompts/meta_evaluation_philosophy.md.erb +45 -0
  53. data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_postgame.md.erb +36 -0
  54. data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_proposal.md.erb +34 -0
  55. data/templates/knowledge/llm_cross_evaluation/assets/prompts/nomic_vote.md.erb +32 -0
  56. data/templates/knowledge/llm_cross_evaluation/assets/prompts/self_calibration.md.erb +42 -0
  57. data/templates/knowledge/llm_cross_evaluation/assets/prompts/self_calibration_philosophy.md.erb +43 -0
  58. data/templates/knowledge/llm_cross_evaluation/assets/tasks/code_generation.yaml +20 -0
  59. data/templates/knowledge/llm_cross_evaluation/assets/tasks/kairoschain_philosophy.yaml +55 -0
  60. data/templates/knowledge/llm_cross_evaluation/assets/tasks/logic_reasoning.yaml +19 -0
  61. data/templates/knowledge/llm_cross_evaluation/assets/tasks/scientific_reasoning.yaml +27 -0
  62. data/templates/knowledge/llm_cross_evaluation/llm_cross_evaluation.md +409 -0
  63. data/templates/knowledge/llm_cross_evaluation/scripts/run_cross_eval.rb +1752 -0
  64. data/templates/knowledge/multi_llm_review_workflow/multi_llm_review_workflow.md +50 -16
  65. data/templates/knowledge/multi_llm_reviewer_evaluation/multi_llm_reviewer_evaluation.md +29 -8
  66. data/templates/skillsets/agent/config/agent.yml +39 -0
  67. data/templates/skillsets/agent/lib/agent/cognitive_loop.rb +189 -20
  68. data/templates/skillsets/agent/tools/agent_step.rb +183 -5
  69. data/templates/skillsets/external_tools/lib/external_tools/tool_support.rb +42 -0
  70. data/templates/skillsets/external_tools/lib/external_tools/workspace_confinement.rb +111 -0
  71. data/templates/skillsets/external_tools/lib/external_tools.rb +6 -0
  72. data/templates/skillsets/external_tools/skillset.json +24 -0
  73. data/templates/skillsets/external_tools/test/test_external_tools.rb +521 -0
  74. data/templates/skillsets/external_tools/test/test_http_tools.rb +451 -0
  75. data/templates/skillsets/external_tools/tools/safe_file_copy.rb +90 -0
  76. data/templates/skillsets/external_tools/tools/safe_file_delete.rb +75 -0
  77. data/templates/skillsets/external_tools/tools/safe_file_edit.rb +106 -0
  78. data/templates/skillsets/external_tools/tools/safe_file_list.rb +95 -0
  79. data/templates/skillsets/external_tools/tools/safe_file_read.rb +85 -0
  80. data/templates/skillsets/external_tools/tools/safe_file_write.rb +110 -0
  81. data/templates/skillsets/external_tools/tools/safe_git_branch.rb +140 -0
  82. data/templates/skillsets/external_tools/tools/safe_git_commit.rb +125 -0
  83. data/templates/skillsets/external_tools/tools/safe_git_push.rb +111 -0
  84. data/templates/skillsets/external_tools/tools/safe_git_status.rb +81 -0
  85. data/templates/skillsets/external_tools/tools/safe_http_get.rb +203 -0
  86. data/templates/skillsets/external_tools/tools/safe_http_post.rb +209 -0
  87. data/templates/skillsets/llm_client/config/llm_client.yml +3 -3
  88. data/templates/skillsets/llm_client/lib/llm_client/claude_code_adapter.rb +59 -13
  89. data/templates/skillsets/llm_client/lib/llm_client/codex_adapter.rb +157 -0
  90. data/templates/skillsets/llm_client/lib/llm_client/cursor_adapter.rb +149 -0
  91. data/templates/skillsets/llm_client/lib/llm_client/error_taxonomy.rb +124 -0
  92. data/templates/skillsets/llm_client/lib/llm_client/safe_subprocess.rb +206 -0
  93. data/templates/skillsets/llm_client/test/test_error_taxonomy.rb +195 -0
  94. data/templates/skillsets/llm_client/tools/llm_call.rb +94 -17
  95. data/templates/skillsets/llm_client/tools/llm_configure.rb +11 -3
  96. data/templates/skillsets/llm_client/tools/llm_status.rb +45 -0
  97. data/templates/skillsets/mmp/lib/mmp/identity.rb +2 -1
  98. data/templates/skillsets/multi_llm_review/config/multi_llm_review.yml +69 -0
  99. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/consensus.rb +145 -0
  100. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/dispatcher.rb +189 -0
  101. data/templates/skillsets/multi_llm_review/lib/multi_llm_review/prompt_builder.rb +126 -0
  102. data/templates/skillsets/multi_llm_review/skillset.json +18 -0
  103. data/templates/skillsets/multi_llm_review/test/test_multi_llm_review.rb +335 -0
  104. data/templates/skillsets/multi_llm_review/tools/multi_llm_review.rb +275 -0
  105. metadata +90 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57b5814a96eadd45ccf9056493e217017d0ff5d83f676ee5cccde7c8ae8b3fb7
4
- data.tar.gz: 12c91db84b545e576e821fe1e1a82c7fa0f30089850fff92adcab7f85e244cc0
3
+ metadata.gz: 9d2b6b702508caf49a43e1fdc09beb4b6d5a7dd478a89492d07503643744855c
4
+ data.tar.gz: faaceed727c7de49424391d32768bff4790411a414cd4b2a671fcd31501aadf5
5
5
  SHA512:
6
- metadata.gz: 528258d448d7079162acb42e7bb6d4ad62402b5d3c6a3802882137175462f24468e3355cc2d90a0efdb778a0949c523209d5e060a637fb5500f893155f9b2ed5
7
- data.tar.gz: 74e8d097b208b131bab97a3b2c81208644279c08a48f7f4cb54ddedd9a8fe31c15eb7b22ebfddd0d07eec2bd93672990168e246e9350af202ff517b232c0f992
6
+ metadata.gz: 727829e5b3672c2b694336baabb14af1a0906ee3b7c196bb320ea0060db98bc163ab36a7971f405eb88e0c1d0393dcb370fed337bb6220e0e8dd1716885e7166
7
+ data.tar.gz: 12be3d20add101e4ac13871b4c36dce043e104fcbe67ab1af95ad55793b018b2cc0e785324d34a4f8887a2969c7d6b5db9da61a9f777496d560296a8d94f14cc
data/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ 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.17.0] - 2026-04-22
8
+
9
+ ### Added
10
+
11
+ - **Multi-LLM Review SkillSet** — New `multi_llm_review` tool dispatching review
12
+ tasks in parallel to heterogeneous LLMs (Claude Opus 4.6/4.7 via Agent tool
13
+ and CLI, OpenAI Codex GPT-5.4, Cursor Composer-2). Returns consensus verdict
14
+ with aggregated findings. Convergence rule configurable (default `3/4 APPROVE`).
15
+ Complexity auto-detection from `review_type` + artifact size. Sandbox mode
16
+ via `review_context: independent` prevents CLAUDE.md contamination.
17
+ - **LLM adapter extensions** — `codex_adapter` (`codex exec` subprocess) and
18
+ `cursor_adapter` (`agent -p` subprocess) join the existing `claude_code_adapter`.
19
+ All share the `SafeSubprocess` wrapper with timeout, stderr isolation, and
20
+ ANSI stripping. `provider_override` parameter on `llm_call` routes to a
21
+ specific adapter independent of the default config.
22
+ - **Agent SkillSet integration** — Agent OODA loop can invoke `multi_llm_review`
23
+ as an ACT step for design/implementation review cycles.
24
+
25
+ ### Fixed
26
+
27
+ - **Adapter model label** — `codex_adapter` and `cursor_adapter` no longer fall
28
+ back to `llm_client.yml`'s Anthropic default model (`claude-opus-4-6`) when
29
+ the caller omits `model`. They now report `codex-cli-default` /
30
+ `cursor-cli-default` in response JSON, honestly reflecting that the CLI's
31
+ own default model was used. No change to dispatch behavior.
32
+ - **Runtime integration** — CLI auth flow, effort control per provider,
33
+ auto-complexity mapping (`low`/`medium`/`high`/`xhigh`) validated end-to-end.
34
+ - **MMP keypair persistence** — Keypair saved to `.kairos/keys/` instead of
35
+ ephemeral CWD, preventing loss on directory change.
36
+ - **paused_risk skip transition** — Agent state machine correctly handles
37
+ `skip` action from `paused_risk` state.
38
+ - **llm_call AuthError fallback** — Auto-switch to `claude_code` provider
39
+ when Anthropic API auth fails.
40
+
41
+ ### Review
42
+
43
+ - Design: 2 rounds × 4 LLMs (Claude Opus 4.6, Claude Opus 4.7, Codex GPT-5.4,
44
+ Cursor Composer-2)
45
+ - Implementation: 2 rounds × 4 LLMs
46
+ - Runtime test: 4-LLM diversity verified on buggy Fibonacci artifact
47
+ (4/4 REJECT with distinct findings per reviewer characteristic)
48
+
7
49
  ## [3.16.0] - 2026-04-19
8
50
 
9
51
  ### Changed
data/README.md CHANGED
@@ -20,6 +20,7 @@ A self-referential [Model Context Protocol (MCP)](https://modelcontextprotocol.i
20
20
  - **Attestation System (Synoptis)** — Cryptographic attestation and trust scoring
21
21
  - **Dream Mode** — Speculative knowledge proposals with community review
22
22
  - **Claude Code Plugin Projection** — Auto-project SkillSets as Claude Code plugins (hooks, agents, slash commands)
23
+ - **Multi-LLM Review** — Parallel dispatch to heterogeneous LLMs (Claude, Codex, Cursor) via CLI subprocesses; consensus verdict with aggregated findings
23
24
 
24
25
  ## Installation
25
26
 
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KairosMcp
4
+ class Daemon
5
+ # ActiveObserve — policy-driven OBSERVE phase.
6
+ #
7
+ # Design (v0.2 §2, P3.1):
8
+ # The passive OBSERVE phase inspects mandate state in memory. The
9
+ # active variant additionally invokes a whitelisted set of READ-ONLY
10
+ # tools named in the mandate's `observe_policies`, collects their
11
+ # results, and runs a cheap triage step to highlight what looks
12
+ # relevant. In P3.1 the triage is a keyword filter stub; a future
13
+ # revision will slot in a cheap LLM call with the same interface.
14
+ #
15
+ # Safety:
16
+ # Only tools listed in READ_ONLY_ALLOWLIST (or the caller-supplied
17
+ # allowlist) are ever invoked. A mandate cannot widen its own
18
+ # observation surface — policies must be a subset of the allowlist.
19
+ class ActiveObserve
20
+ # A deliberately conservative default. Additional read-only tools may
21
+ # be allowed by passing an explicit `allowlist:` into #initialize.
22
+ READ_ONLY_ALLOWLIST = %w[
23
+ chain_history
24
+ chain_status
25
+ chain_verify
26
+ knowledge_get
27
+ knowledge_list
28
+ skills_list
29
+ skills_get
30
+ skills_dsl_list
31
+ skills_dsl_get
32
+ resource_list
33
+ resource_read
34
+ introspection_health
35
+ introspection_check
36
+ state_status
37
+ state_history
38
+ document_status
39
+ meeting_browse
40
+ meeting_check_freshness
41
+ skillset_browse
42
+ ].freeze
43
+
44
+ def initialize(allowlist: READ_ONLY_ALLOWLIST, keywords: nil, logger: nil)
45
+ @allowlist = allowlist.map(&:to_s).freeze
46
+ @keywords = keywords
47
+ @logger = logger
48
+ end
49
+
50
+ # Execute the active OBSERVE step.
51
+ #
52
+ # @param mandate_hash [Hash] must expose :observe_policies (or the
53
+ # 'observe_policies' key) as an Array of tool names. May expose
54
+ # :goal_name and :goal for keyword-based triage.
55
+ # @param tool_invoker [#call] a callable accepting
56
+ # (tool_name, args) and returning the tool's native result. The
57
+ # caller supplies this so ActiveObserve itself is I/O-agnostic
58
+ # and trivially testable.
59
+ # @return [Hash] structured observation with :policies_invoked,
60
+ # :policies_skipped, :results, :relevant, :errors.
61
+ def observe(mandate_hash, tool_invoker:)
62
+ raise ArgumentError, 'mandate_hash required' unless mandate_hash.is_a?(Hash)
63
+ raise ArgumentError, 'tool_invoker must respond to call' unless tool_invoker.respond_to?(:call)
64
+
65
+ policies = Array(mandate_hash[:observe_policies] || mandate_hash['observe_policies'])
66
+ # Deduplicate by normalized tool name — first-wins for args if dupes exist.
67
+ policies = policies.uniq { |e| normalize_policy(e).first }
68
+ invoked = []
69
+ skipped = []
70
+ results = {}
71
+ errors = {}
72
+
73
+ policies.each do |entry|
74
+ tool_name, args = normalize_policy(entry)
75
+ unless allowed?(tool_name)
76
+ skipped << tool_name
77
+ log(:warn, :active_observe_skip, tool: tool_name, reason: 'not_in_allowlist')
78
+ next
79
+ end
80
+
81
+ begin
82
+ results[tool_name] = tool_invoker.call(tool_name, args)
83
+ invoked << tool_name
84
+ rescue StandardError => e
85
+ errors[tool_name] = "#{e.class}: #{e.message}"
86
+ log(:error, :active_observe_error, tool: tool_name, error: errors[tool_name])
87
+ end
88
+ end
89
+
90
+ relevant = select_relevant(results, mandate_hash)
91
+
92
+ {
93
+ policies_invoked: invoked,
94
+ policies_skipped: skipped,
95
+ results: results,
96
+ relevant: relevant,
97
+ errors: errors
98
+ }
99
+ end
100
+
101
+ # Triage stub. In P3.1 we score results by simple keyword membership
102
+ # (mandate goal tokens). Returning the full result under a :match
103
+ # entry keeps the interface stable for the eventual LLM-backed
104
+ # implementation, which will add confidence scores without changing
105
+ # the key layout.
106
+ #
107
+ # @return [Hash] tool_name → { match: Boolean, score: Float, matched_keywords: [...] }
108
+ def select_relevant(results, mandate_hash)
109
+ keywords = effective_keywords(mandate_hash)
110
+ return {} if results.empty?
111
+
112
+ results.each_with_object({}) do |(tool, payload), acc|
113
+ matched = match_keywords(payload, keywords)
114
+ acc[tool] = {
115
+ match: !matched.empty? || keywords.empty?,
116
+ score: keywords.empty? ? 1.0 : matched.size.to_f / keywords.size,
117
+ matched_keywords: matched
118
+ }
119
+ end
120
+ end
121
+
122
+ # ------------------------------------------------------------------ helpers
123
+
124
+ private
125
+
126
+ # A policy entry is either a String tool-name or a Hash
127
+ # { tool: "...", args: {...} }. Normalizing here means the rest of
128
+ # the class can assume a pair.
129
+ def normalize_policy(entry)
130
+ case entry
131
+ when String
132
+ [entry, {}]
133
+ when Hash
134
+ tool = entry[:tool] || entry['tool'] || entry[:name] || entry['name']
135
+ args = entry[:args] || entry['args'] || {}
136
+ [tool.to_s, args]
137
+ else
138
+ [entry.to_s, {}]
139
+ end
140
+ end
141
+
142
+ def allowed?(tool_name)
143
+ @allowlist.include?(tool_name.to_s)
144
+ end
145
+
146
+ def effective_keywords(mandate_hash)
147
+ return Array(@keywords).map(&:to_s).reject(&:empty?) if @keywords
148
+
149
+ raw = [
150
+ mandate_hash[:goal_name], mandate_hash['goal_name'],
151
+ mandate_hash[:goal], mandate_hash['goal']
152
+ ].compact.map(&:to_s).join(' ')
153
+ raw.downcase.scan(/[a-z0-9]{3,}/).uniq
154
+ end
155
+
156
+ def match_keywords(payload, keywords)
157
+ return [] if keywords.empty?
158
+
159
+ haystack = payload_to_string(payload).downcase
160
+ keywords.select { |k| haystack.include?(k) }
161
+ end
162
+
163
+ def payload_to_string(payload)
164
+ case payload
165
+ when String then payload
166
+ when Hash, Array then payload.to_s
167
+ else payload.to_s
168
+ end
169
+ end
170
+
171
+ def log(level, event, **fields)
172
+ return unless @logger && @logger.respond_to?(level)
173
+
174
+ @logger.public_send(level, "#{event} #{fields.inspect}")
175
+ rescue StandardError
176
+ # Never let a logger crash mask the original tool error.
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'fileutils'
6
+ require 'time'
7
+ require 'digest'
8
+
9
+ module KairosMcp
10
+ class Daemon
11
+ # ApprovalGate — pending-file based approval system for code-gen proposals.
12
+ #
13
+ # Design (P3.2 v0.2 §4):
14
+ # Proposals are staged as JSON files in .kairos/run/proposals/.
15
+ # Human approval/rejection is recorded as a separate .decision.json file.
16
+ # proposal_hash binds reviewed content to applied content cryptographically.
17
+ class ApprovalGate
18
+ DEFAULT_TTL = 28_800 # 8 hours (daemon mode)
19
+ MAX_PENDING = 16
20
+
21
+ def initialize(dir:, clock: -> { Time.now.utc }, logger: nil)
22
+ @dir = dir
23
+ @clock = clock
24
+ @logger = logger
25
+ FileUtils.mkdir_p(@dir, mode: 0o700)
26
+ end
27
+
28
+ # Stage a pending-approval proposal. Returns the stored Hash.
29
+ def stage(proposal)
30
+ id = proposal[:proposal_id] || proposal['proposal_id']
31
+ raise ArgumentError, 'proposal_id required' if id.to_s.empty?
32
+ check_backpressure!
33
+
34
+ now = @clock.call
35
+ ttl = proposal[:ttl_seconds] || proposal['ttl_seconds'] || DEFAULT_TTL
36
+
37
+ # Compute proposal_hash from canonical content (excludes mutable fields)
38
+ canonical = proposal.reject { |k, _| mutable_key?(k) }
39
+ p_hash = canonical_hash(canonical)
40
+
41
+ p = proposal.merge(
42
+ status: 'pending_approval',
43
+ proposal_hash: p_hash,
44
+ created_at: now.iso8601,
45
+ expires_at: (now + ttl).iso8601
46
+ )
47
+ write_atomic(file_for(id), JSON.pretty_generate(stringify_keys(p)))
48
+ p
49
+ end
50
+
51
+ # Auto-approve (fast path for L2 scopes).
52
+ def auto_approve(proposal)
53
+ id = proposal[:proposal_id] || proposal['proposal_id']
54
+ raise ArgumentError, 'proposal_id required' if id.to_s.empty?
55
+
56
+ now = @clock.call
57
+ ttl = proposal[:ttl_seconds] || proposal['ttl_seconds'] || DEFAULT_TTL
58
+
59
+ canonical = proposal.reject { |k, _| mutable_key?(k) }
60
+ p_hash = canonical_hash(canonical)
61
+
62
+ p = proposal.merge(
63
+ status: 'auto_approved',
64
+ proposal_hash: p_hash,
65
+ created_at: now.iso8601,
66
+ expires_at: (now + ttl).iso8601
67
+ )
68
+ write_atomic(file_for(id), JSON.pretty_generate(stringify_keys(p)))
69
+ write_decision(id,
70
+ decision: 'approve',
71
+ reviewer: 'policy:auto_approve',
72
+ proposal_hash: p_hash,
73
+ granted_at: now.iso8601,
74
+ reason: "scope=#{proposal.dig(:scope, :scope) || proposal.dig('scope', 'scope')} auto-approved")
75
+ p
76
+ end
77
+
78
+ # Non-blocking status check.
79
+ # @return [Symbol] :pending | :approved | :rejected | :expired | :not_found
80
+ def status_of(proposal_id)
81
+ p = read_proposal(proposal_id)
82
+ return :not_found unless p
83
+ return :expired if Time.parse(p['expires_at']) < @clock.call
84
+ d = read_decision(proposal_id)
85
+ return :pending unless d
86
+ d['decision'] == 'approve' ? :approved : :rejected
87
+ end
88
+
89
+ # Record a human decision (via AttachServer mailbox).
90
+ def record_decision(proposal_id, decision:, reviewer:, reason: nil)
91
+ raise ArgumentError, 'decision must be approve|reject' unless %w[approve reject].include?(decision)
92
+ p = read_proposal(proposal_id)
93
+ raise NotFoundError, "proposal not found: #{proposal_id}" unless p
94
+ raise ConflictError, "already decided: #{proposal_id}" if File.exist?(decision_file(proposal_id))
95
+ raise ExpiredError, "expired: #{proposal_id}" if Time.parse(p['expires_at']) < @clock.call
96
+
97
+ write_decision(proposal_id,
98
+ decision: decision,
99
+ reviewer: reviewer,
100
+ proposal_hash: p['proposal_hash'],
101
+ granted_at: @clock.call.iso8601,
102
+ reason: reason)
103
+ end
104
+
105
+ # For cycle re-entry: returns ApprovalGrant or nil. Never blocks.
106
+ def consume_grant(proposal_id)
107
+ case status_of(proposal_id)
108
+ when :approved
109
+ ApprovalGrant.new(
110
+ proposal_id: proposal_id,
111
+ decision: read_decision(proposal_id),
112
+ proposal: read_proposal(proposal_id)
113
+ )
114
+ else
115
+ nil
116
+ end
117
+ end
118
+
119
+ # Verify proposal content integrity at apply time.
120
+ # @return [Boolean]
121
+ def verify_proposal_integrity(proposal_id)
122
+ p = read_proposal(proposal_id)
123
+ return false unless p
124
+ d = read_decision(proposal_id)
125
+ return false unless d
126
+
127
+ canonical = p.reject { |k, _| mutable_key?(k) }
128
+ recomputed = canonical_hash(canonical)
129
+ recomputed == p['proposal_hash'] && d['proposal_hash'] == p['proposal_hash']
130
+ end
131
+
132
+ # List pending proposals.
133
+ def pending_proposals
134
+ Dir.glob(File.join(@dir, '*.json')).filter_map do |f|
135
+ next if f.end_with?('.decision.json') || f.end_with?('.applied.json')
136
+ p = safe_read_json(f)
137
+ next unless p
138
+ next if p['status'] == 'auto_approved' && File.exist?(decision_file(p['proposal_id']))
139
+ s = status_of(p['proposal_id'])
140
+ s == :pending ? p : nil
141
+ end
142
+ end
143
+
144
+ # Read a proposal record.
145
+ def read_proposal(proposal_id)
146
+ safe_read_json(file_for(proposal_id))
147
+ end
148
+
149
+ # Read a decision record.
150
+ def read_decision(proposal_id)
151
+ safe_read_json(decision_file(proposal_id))
152
+ end
153
+
154
+ ApprovalGrant = Struct.new(:proposal_id, :decision, :proposal, keyword_init: true)
155
+ class NotFoundError < StandardError; end
156
+ class ConflictError < StandardError; end
157
+ class ExpiredError < StandardError; end
158
+ class BackpressureError < StandardError; end
159
+
160
+ private
161
+
162
+ def file_for(id) ; File.join(@dir, "#{id}.json") end
163
+ def decision_file(id) ; File.join(@dir, "#{id}.decision.json") end
164
+
165
+ MUTABLE_KEYS = %w[status proposal_hash created_at expires_at ttl_seconds].freeze
166
+
167
+ def mutable_key?(k)
168
+ MUTABLE_KEYS.include?(k.to_s)
169
+ end
170
+
171
+ # Sorted-key canonical JSON for deterministic hashing (R2 residual fix).
172
+ def canonical_hash(obj)
173
+ "sha256:#{Digest::SHA256.hexdigest(canonical_json(obj))}"
174
+ end
175
+
176
+ def canonical_json(obj)
177
+ case obj
178
+ when Hash
179
+ '{' + obj.keys.map(&:to_s).sort.map { |k|
180
+ # Look up by both string and symbol to handle mixed-key hashes
181
+ val = obj.key?(k) ? obj[k] : obj[k.to_sym]
182
+ k.to_json + ':' + canonical_json(val)
183
+ }.join(',') + '}'
184
+ when Array
185
+ '[' + obj.map { |v| canonical_json(v) }.join(',') + ']'
186
+ when Symbol
187
+ obj.to_s.to_json
188
+ else
189
+ obj.to_json
190
+ end
191
+ end
192
+
193
+ def stringify_keys(hash)
194
+ hash.transform_keys(&:to_s)
195
+ end
196
+
197
+ def write_decision(proposal_id, **fields)
198
+ write_atomic(decision_file(proposal_id), JSON.pretty_generate(fields))
199
+ end
200
+
201
+ def write_atomic(path, data)
202
+ tmp = "#{path}.tmp.#{SecureRandom.hex(4)}"
203
+ File.open(tmp, 'wb', 0o600) do |f|
204
+ f.write(data)
205
+ f.flush
206
+ f.fsync rescue nil
207
+ end
208
+ File.rename(tmp, path)
209
+ ensure
210
+ File.unlink(tmp) if tmp && File.exist?(tmp)
211
+ end
212
+
213
+ def safe_read_json(path)
214
+ return nil unless File.file?(path)
215
+ JSON.parse(File.read(path))
216
+ rescue StandardError
217
+ nil
218
+ end
219
+
220
+ def check_backpressure!
221
+ count = Dir.glob(File.join(@dir, '*.json')).count do |f|
222
+ next false if f.end_with?('.decision.json') || f.end_with?('.applied.json')
223
+ # Only count proposals that have no decision file (truly pending)
224
+ proposal_id = File.basename(f, '.json')
225
+ !File.exist?(decision_file(proposal_id))
226
+ end
227
+ raise BackpressureError, "max pending proposals (#{MAX_PENDING}) exceeded" if count >= MAX_PENDING
228
+ end
229
+ end
230
+ end
231
+ end