kairos-chain 3.24.3 → 3.24.9

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: fb099806eedb198afc167cbb810110ab7bffac2fceea3684eb14a24e3e7b46fb
4
- data.tar.gz: 61223eff1c6cd146eea47d44ab0ee95506a8baa6e15a0f1f5c2e929e9d44a5b1
3
+ metadata.gz: 98d111b116bdd82edadfd6b634d551bee42e289698ecf20f4fd4e60d8d0a9b2f
4
+ data.tar.gz: 3f4ffc6049f6e5e1be2dc5f8238308b2680f08f7a110218d26b1d1a69d296810
5
5
  SHA512:
6
- metadata.gz: 91d4a86fc2df06025fefb5f0252e9f019d85cb9fc31f6ceec0d2e0bbe1209c8d24277e7448faace8ced8ba28c889dee0af7f68fbf50d6dda6da27bbb3366e588
7
- data.tar.gz: b6847081bc03d40d2ae77ec317d52955ba7fdc2597b91d10addba2a052067339171542316810d8e1dc95c5f4eb35eb5963425f7f82961ae13bbc5e3bfa8e2f50
6
+ metadata.gz: ad44f0bf58d272ff80d7a50e09a0f93634e9496b6e6238f8311838e08109463e614ee05f2356b7f311c6538c1e0511ae44bfe0c122bab828d05e6888de042bfa
7
+ data.tar.gz: 4b1995339b02160c87636f520cc33e58667a45d8081092d7fa480ec6b6e300baddd3248ed76483f89253440299a25b60d497b4ed3fc3ac566769fa8d20ed632b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ 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.24.9] - 2026-05-05
8
+
9
+ ### Added (L1 knowledge: goal_setting_heuristic)
10
+
11
+ 新規 L1 knowledge skill `goal_setting_heuristic` v0.2 を追加(`templates/knowledge/goal_setting_heuristic/`)。
12
+
13
+ Knowledge Ethos v2.0(自律 agent ガードレールのフル版)が round 2 multi-LLM review で REVISE 判定 + 哲学 / 運用の根本レベル混乱が露見し塩漬けになったため、その核心 3 機能(Telos 多元性 / Boundary 監視 / Contradiction 許容)だけを抜き出した軽量代替として位置づけ。
14
+
15
+ 主な内容:
16
+ - 3 階層 (Mission / Milestone / Action) + branch (Primary / Fallback / Pivot) の goal 構造
17
+ - Goal-set 時の 3 つの自問(telos / boundary / contradiction)
18
+ - 動的改訂トリガ 6 種(rolling window bias 検出を含む)
19
+ - 完了 / 失敗時の next-step 提案 (halt 禁止) と halt 許可条件 3 種
20
+ - Mission = blocking、Milestone/Action = non-blocking 区分
21
+ - Rolling + 週次の振り返り集計
22
+ - 既存 KairosChain ツール (`dream_*`, `introspection_check`, `multi_llm_review`, `skills_promote`, `context_save`, `chain_record`) との soft / hard 強制度区分明示
23
+
24
+ 軽量レビュー (1 round, 5 reviewer, persona 2 体): 1 APPROVE / 3 REJECT / 1 REVISE。critical P0 8 件を v0.2 で反映済み。塩漬け中の Knowledge Ethos v2.0 とは別アーティファクトとして並存。
25
+
26
+ ### Changed
27
+ - `KairosMcp::VERSION` 3.24.8 → 3.24.9
28
+
7
29
  ## [3.24.1] - 2026-04-27
8
30
 
9
31
  ### Fixed (multi_llm_review_wait)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'yaml'
4
4
  require 'fileutils'
5
+ require 'securerandom'
5
6
 
6
7
  module KairosMcp
7
8
  # AnthropicSkillParser: Parses Anthropic skills format (YAML frontmatter + Markdown)
@@ -101,7 +102,7 @@ module KairosMcp
101
102
  FileUtils.mkdir_p(skill_dir)
102
103
 
103
104
  md_file = File.join(skill_dir, "#{name}.md")
104
- File.write(md_file, content)
105
+ atomic_write(md_file, content)
105
106
 
106
107
  if create_subdirs
107
108
  FileUtils.mkdir_p(File.join(skill_dir, 'scripts'))
@@ -121,10 +122,27 @@ module KairosMcp
121
122
  md_file = find_md_file(skill_dir)
122
123
  raise "No markdown file found in #{skill_dir}" unless md_file
123
124
 
124
- File.write(md_file, new_content)
125
+ atomic_write(md_file, new_content)
125
126
  parse(skill_dir)
126
127
  end
127
128
 
129
+ # Atomic-rename write. Tempfile in same directory then File.rename to
130
+ # the target. Crash mid-write leaves target either fully replaced or
131
+ # untouched — never truncated. Security-grounded (don't destroy
132
+ # someone else's valid record), not durability-grounded.
133
+ def atomic_write(target_path, content)
134
+ dir = File.dirname(target_path)
135
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
136
+ tempname = "#{File.basename(target_path)}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
137
+ temp_path = File.join(dir, tempname)
138
+ begin
139
+ File.write(temp_path, content)
140
+ File.rename(temp_path, target_path)
141
+ ensure
142
+ File.delete(temp_path) if File.exist?(temp_path)
143
+ end
144
+ end
145
+
128
146
  # List all scripts in a skill
129
147
  #
130
148
  # @param skill [SkillEntry] The skill entry
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KairosMcp
4
+ # Capability module — Phase 1.5 self-articulation infrastructure.
5
+ #
6
+ # Design reference: docs/drafts/capability_boundary_design_v1.1.md
7
+ #
8
+ # Provides:
9
+ # - active_harness detection (env_var first, auto-detect fallback, :unknown honest)
10
+ # - harness_requirement metadata normalization
11
+ # - manifest aggregation across BaseTool subclasses
12
+ #
13
+ # 8 invariants govern this module:
14
+ # 1. Self-articulation — boundary must be queryable at runtime
15
+ # 2. Honest unknown — :unknown beats false guess
16
+ # 3. Declare-not-enforce — articulation only, no runtime gate
17
+ # 4. Structural congruence — DSL matches existing BaseTool method override pattern
18
+ # 5. Composability — SkillSet tools participate equally
19
+ # 6. Active vs external separation (with same-source exclusion)
20
+ # 7. Forward-only metadata — opt-in, with declared:true/false in manifest
21
+ # 8. Acknowledgment — runtime dependence is articulated, not silently absorbed
22
+ module Capability
23
+ TIERS = %i[core harness_assisted harness_specific].freeze
24
+
25
+ # Mapping from active_harness symbol to its "same-source" CLI name.
26
+ # When active_harness=:claude_code and a tool declares requires_externals: [:claude_cli],
27
+ # claude_cli is excluded from used_externals because it is the SAME source as the
28
+ # harness running KairosChain (active vs external separation invariant).
29
+ SAME_SOURCE_CLI = {
30
+ claude_code: :claude_cli,
31
+ codex_cli: :codex_cli,
32
+ cursor: :cursor_cli
33
+ }.freeze
34
+
35
+ class << self
36
+ # Returns active_harness detection result. Cached at process boot.
37
+ #
38
+ # @return [Hash] { active_harness:, detection_method:, confidence: }
39
+ def detect_harness
40
+ @detection ||= compute_detection
41
+ end
42
+
43
+ # Test-only escape hatch. Production code never calls this.
44
+ def reset!
45
+ @detection = nil
46
+ end
47
+
48
+ # Normalize a tool's harness_requirement return value to canonical Hash form.
49
+ # Symbol → { tier: <symbol> }
50
+ # Hash → validated Hash (raises ArgumentError on violation)
51
+ def normalize_requirement(value)
52
+ hash = case value
53
+ when Symbol then { tier: value }
54
+ when Hash then deep_symbolize(value)
55
+ else
56
+ raise ArgumentError, "harness_requirement must be Symbol or Hash, got #{value.class}"
57
+ end
58
+
59
+ validate!(hash)
60
+ hash
61
+ end
62
+
63
+ # Aggregate harness_requirement declarations across all registered tools.
64
+ # Skip + warn on per-tool validation failure (partial-failure policy).
65
+ #
66
+ # @param registry [KairosMcp::ToolRegistry]
67
+ # @return [Hash] { tools: [...], summary: {...}, declaration_errors: [...] }
68
+ def aggregate_manifest(registry)
69
+ tools_index = registry.instance_variable_get(:@tools) || {}
70
+ sources = registry.instance_variable_get(:@tool_sources) || {}
71
+
72
+ entries = []
73
+ errors = []
74
+ summary = Hash.new(0)
75
+
76
+ tools_index.each do |name, tool|
77
+ source = sources[name] || :core_tool
78
+ # declared = explicitly overridden in tool subclass (vs inherited BaseTool default)
79
+ declared = tool.method(:harness_requirement).owner != KairosMcp::Tools::BaseTool
80
+ raw = safe_call_requirement(tool)
81
+
82
+ begin
83
+ normalized = normalize_requirement(raw)
84
+ entry = { name: name, declared: declared, source: source }.merge(normalized)
85
+ entries << entry
86
+ tier_key = declared ? normalized[:tier] : :"undeclared_default_#{normalized[:tier]}"
87
+ summary[tier_key] += 1
88
+ rescue ArgumentError => e
89
+ errors << { tool: name, issue: "invalid harness_requirement: #{e.message}",
90
+ severity: :declaration_error }
91
+ entries << { name: name, declared: false, source: source, tier: :unknown,
92
+ declaration_error: e.message }
93
+ summary[:declaration_errors] += 1
94
+ end
95
+ end
96
+
97
+ { tools: entries, summary: summary.transform_values(&:to_i),
98
+ declaration_errors: errors }
99
+ end
100
+
101
+ # which-style PATH check using only filesystem (no subprocess).
102
+ # Returns true/false.
103
+ def cli_in_path?(name)
104
+ return false unless name.is_a?(Symbol) || name.is_a?(String)
105
+ bin = name.to_s
106
+ ENV.fetch('PATH', '').split(File::PATH_SEPARATOR).any? do |dir|
107
+ full = File.join(dir, bin)
108
+ File.executable?(full) && !File.directory?(full)
109
+ end
110
+ end
111
+
112
+ # Get version of a CLI by spawning subprocess (only when probe_versions: true).
113
+ # Returns nil on any failure (Honest unknown).
114
+ def cli_version(name)
115
+ return nil unless cli_in_path?(name)
116
+ out = `#{name} --version 2>&1`.strip
117
+ $?.success? ? out.lines.first&.strip : nil
118
+ rescue StandardError
119
+ nil
120
+ end
121
+
122
+ # Compute used_externals from declared manifest entries given active_harness.
123
+ # Applies same-source exclusion rule.
124
+ def compute_used_externals(manifest_entries, active_harness)
125
+ same_source = SAME_SOURCE_CLI[active_harness]
126
+ union = manifest_entries.flat_map { |e| Array(e[:requires_externals]) }.uniq
127
+ excluded = same_source && union.include?(same_source) ? [same_source] : []
128
+ {
129
+ value: union - excluded,
130
+ same_source_excluded: excluded
131
+ }
132
+ end
133
+
134
+ private
135
+
136
+ def compute_detection
137
+ env = ENV['KAIROS_HARNESS']
138
+ if env && !env.empty?
139
+ if env =~ /\A[A-Za-z0-9_\-]{1,64}\z/
140
+ return { active_harness: env.to_sym, detection_method: :env_var, confidence: :explicit }
141
+ else
142
+ warn "[Capability] KAIROS_HARNESS=#{env.inspect} is malformed; falling back to :unknown"
143
+ return { active_harness: :unknown, detection_method: :none, confidence: :unknown }
144
+ end
145
+ end
146
+
147
+ if (auto = auto_detect)
148
+ { active_harness: auto, detection_method: :auto_detect, confidence: :inferred }
149
+ else
150
+ { active_harness: :unknown, detection_method: :none, confidence: :unknown }
151
+ end
152
+ end
153
+
154
+ # Auto-detect harness from harness-native signals only.
155
+ # CWD markers (CLAUDE.md, MEMORY.md) are intentionally NOT used because
156
+ # they are project artifacts, not harness signatures (would re-introduce
157
+ # conflation that Phase 1.5 is meant to remove).
158
+ def auto_detect
159
+ # Claude Code sets several env vars when running. Check for any.
160
+ return :claude_code if ENV.keys.any? { |k| k.start_with?('CLAUDE_CODE_') || k == 'CLAUDECODE' }
161
+ # Codex CLI / Cursor specific env vars (heuristic; may be empty in practice).
162
+ return :codex_cli if ENV.key?('CODEX_CLI') || ENV.key?('CODEX_AGENT_ID')
163
+ return :cursor if ENV.key?('CURSOR_AGENT') || ENV.key?('CURSOR_TRACE_ID')
164
+ nil
165
+ end
166
+
167
+ def deep_symbolize(hash)
168
+ hash.each_with_object({}) do |(k, v), out|
169
+ key = k.is_a?(String) ? k.to_sym : k
170
+ out[key] = case v
171
+ when Hash then deep_symbolize(v)
172
+ when Array then v.map { |item| item.is_a?(Hash) ? deep_symbolize(item) : item }
173
+ else v
174
+ end
175
+ end
176
+ end
177
+
178
+ def validate!(hash)
179
+ tier = hash[:tier]
180
+ unless TIERS.include?(tier)
181
+ raise ArgumentError, "tier must be one of #{TIERS.inspect}, got #{tier.inspect}"
182
+ end
183
+
184
+ if tier == :harness_specific && hash[:target_harness].nil?
185
+ raise ArgumentError, "harness_specific tier requires :target_harness"
186
+ end
187
+
188
+ Array(hash[:requires_harness_features]).each_with_index do |entry, idx|
189
+ unless entry.is_a?(Hash) && entry[:feature] && entry[:target_harness]
190
+ raise ArgumentError, "requires_harness_features[#{idx}] missing :feature or :target_harness"
191
+ end
192
+ end
193
+
194
+ Array(hash[:fallback_chain]).each_with_index do |entry, idx|
195
+ unless entry.is_a?(Hash) && entry[:path] && entry[:tier] && entry[:condition]
196
+ raise ArgumentError, "fallback_chain[#{idx}] missing :path/:tier/:condition"
197
+ end
198
+ if entry[:tier] == :harness_specific && entry[:target_harness].nil?
199
+ raise ArgumentError, "fallback_chain[#{idx}] tier=:harness_specific requires :target_harness"
200
+ end
201
+ end
202
+
203
+ nil
204
+ end
205
+
206
+ def safe_call_requirement(tool)
207
+ tool.harness_requirement
208
+ rescue StandardError => e
209
+ raise ArgumentError, "tool raised during harness_requirement: #{e.message}"
210
+ end
211
+ end
212
+
213
+ end
214
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'fileutils'
5
+ require 'securerandom'
6
+ require 'time'
7
+ require 'yaml'
8
+
9
+ module KairosMcp
10
+ # ContextGraph: Phase 1 minimal mapping for L2 informed_by edges.
11
+ #
12
+ # Design reference: docs/drafts/context_graph_l2_mapping_design_v2.1.md
13
+ #
14
+ # Responsibilities:
15
+ # - target string parse (TARGET_RE)
16
+ # - resolve_target: path containment + symlink rejection (security)
17
+ # - relations array validation (shape + type whitelist)
18
+ #
19
+ # Non-responsibilities (by design, L2-evidential):
20
+ # - durability invariants (write serialization, fsync sequence) — not L2's job
21
+ # - semantic validation (whether informed_by claim is true) — defer to traverser
22
+ # - edges.jsonl cache — Phase 2 if observation justifies
23
+ module ContextGraph
24
+ # Recognized edge types in Phase 1. Unknown types are accepted on write
25
+ # (forward-compat) but skipped on traverse.
26
+ KNOWN_TYPES = %w[informed_by].freeze
27
+
28
+ # Target canonical regex (v2.1 §2). Permissive enough to reference both
29
+ # canonical session_ids (session_<8>_<6>_<8hex>) and the human-readable
30
+ # forms already on disk (e.g. coaching_insights_20260327, received_skills).
31
+ # Leading char restricted to [A-Za-z0-9_] to forbid dot/hyphen path tricks.
32
+ TARGET_RE = /\Av1:(?<sid>[A-Za-z0-9_][A-Za-z0-9_.\-]{0,127})\/(?<name>[A-Za-z0-9_][A-Za-z0-9_.\-]{0,127})\z/.freeze
33
+
34
+ # YAML value types permitted inside relations[] items.
35
+ # Anything outside this set is rejected on write to keep YAML.dump
36
+ # output free of anchors/aliases that downstream non-safe loaders
37
+ # could exploit.
38
+ SAFE_VALUE_TYPES = [
39
+ String, Integer, Float, TrueClass, FalseClass, NilClass,
40
+ Hash, Array, Time, Date
41
+ ].freeze
42
+
43
+ DEFAULT_MAX_DEPTH = 3
44
+ MAX_DEPTH_CLAMP = 16
45
+ # Recursion depth cap for assert_safe_value!. Pathological deeply-nested
46
+ # YAML in user-supplied frontmatter can otherwise blow Ruby's call stack
47
+ # (SystemStackError) before validation completes. 32 is well above any
48
+ # legitimate usage and far below Ruby's default stack limit.
49
+ MAX_VALUE_NESTING = 32
50
+
51
+ # Error hierarchy. All inherit from a single base so callers can
52
+ # rescue ContextGraph::Error to surface uniformly.
53
+ class Error < StandardError; end
54
+ class MalformedTargetError < Error; end
55
+ class MalformedRelationsError < Error; end
56
+ class UnsafeRelationValueError < Error; end
57
+ class PathEscapeError < Error; end
58
+ class SymlinkRejectedError < Error; end
59
+ class PathResolutionError < Error; end
60
+ class InvalidFrontmatterError < Error; end
61
+
62
+ module_function
63
+
64
+ # Parse a target string into {sid, name}. Returns nil on mismatch.
65
+ def parse_target(target_str)
66
+ return nil unless target_str.is_a?(String)
67
+
68
+ m = TARGET_RE.match(target_str)
69
+ return nil unless m
70
+
71
+ { sid: m[:sid], name: m[:name] }
72
+ end
73
+
74
+ # Resolve target to an on-disk file path with full security checks.
75
+ #
76
+ # @param target_str [String] e.g. "v1:session_xxx/name"
77
+ # @param context_root [String] absolute path to L2 context root
78
+ # @return [Hash] { path: String|nil, status: :ok|:dangling }
79
+ # @raise MalformedTargetError, PathEscapeError, SymlinkRejectedError, PathResolutionError
80
+ def resolve_target(target_str, context_root)
81
+ parsed = parse_target(target_str)
82
+ raise MalformedTargetError, "target does not match canonical form: #{target_str.inspect}" unless parsed
83
+
84
+ root_real = begin
85
+ File.realpath(context_root)
86
+ rescue Errno::ENOENT
87
+ # context_root itself is missing — treat as resolution failure
88
+ raise PathResolutionError, "context_root does not exist: #{context_root}"
89
+ end
90
+
91
+ candidate = File.join(root_real, parsed[:sid], parsed[:name], "#{parsed[:name]}.md")
92
+
93
+ # Symlink rejection BEFORE realpath: lstat does not follow links, so
94
+ # if the final component is a symlink we reject it here without
95
+ # leaking the symlink's target into containment evaluation.
96
+ lst = begin
97
+ File.lstat(candidate)
98
+ rescue Errno::ENOENT
99
+ # Forward reference: target file does not exist yet.
100
+ verify_dangling_containment(candidate, root_real)
101
+ return { path: nil, status: :dangling }
102
+ rescue SystemCallError => e
103
+ raise PathResolutionError, "fs error stat'ing #{target_str}: #{e.message}"
104
+ end
105
+
106
+ raise SymlinkRejectedError, "target final component is a symlink: #{candidate}" if lst.symlink?
107
+
108
+ resolved = begin
109
+ File.realpath(candidate)
110
+ rescue SystemCallError => e
111
+ raise PathResolutionError, "fs error resolving #{target_str}: #{e.message}"
112
+ end
113
+
114
+ sep = File::SEPARATOR
115
+ unless resolved == root_real || resolved.start_with?(root_real + sep)
116
+ raise PathEscapeError, "resolved path escapes context_root: #{resolved}"
117
+ end
118
+
119
+ { path: resolved, status: :ok }
120
+ end
121
+
122
+ # Validate a relations[] array on the write path. Mutates nothing.
123
+ # Returns nil on success, raises on the first violation.
124
+ #
125
+ # Rules (v2.1 §1.1, §4.2):
126
+ # - relations is Array
127
+ # - each item is Hash with String type and String target
128
+ # - target matches TARGET_RE
129
+ # - all values are SAFE_VALUE_TYPES (recursively)
130
+ def validate_relations!(relations)
131
+ raise MalformedRelationsError, 'relations must be an Array' unless relations.is_a?(Array)
132
+
133
+ relations.each_with_index do |item, idx|
134
+ raise MalformedRelationsError, "relations[#{idx}] must be a Hash" unless item.is_a?(Hash)
135
+
136
+ type = item['type'] || item[:type]
137
+ target = item['target'] || item[:target]
138
+
139
+ raise MalformedRelationsError, "relations[#{idx}] missing 'type'" if type.nil?
140
+ raise MalformedRelationsError, "relations[#{idx}] missing 'target'" if target.nil?
141
+ raise MalformedRelationsError, "relations[#{idx}].type must be String" unless type.is_a?(String)
142
+ raise MalformedRelationsError, "relations[#{idx}].target must be String" unless target.is_a?(String)
143
+ raise MalformedTargetError, "relations[#{idx}].target does not match canonical form: #{target.inspect}" unless TARGET_RE.match?(target)
144
+
145
+ item.each do |k, v|
146
+ assert_safe_value!(v, "relations[#{idx}].#{k}", 0)
147
+ end
148
+ end
149
+
150
+ nil
151
+ end
152
+
153
+ # Recursively check that a value tree contains only SAFE_VALUE_TYPES.
154
+ # Bounded by MAX_VALUE_NESTING to prevent SystemStackError from
155
+ # pathological frontmatter input.
156
+ def assert_safe_value!(value, location, depth)
157
+ raise UnsafeRelationValueError, "value nesting exceeds MAX_VALUE_NESTING=#{MAX_VALUE_NESTING} at #{location}" if depth > MAX_VALUE_NESTING
158
+
159
+ case value
160
+ when Hash
161
+ value.each do |k, v|
162
+ assert_safe_value!(k, "#{location}.<key>", depth + 1)
163
+ assert_safe_value!(v, "#{location}.#{k}", depth + 1)
164
+ end
165
+ when Array
166
+ value.each_with_index { |v, i| assert_safe_value!(v, "#{location}[#{i}]", depth + 1) }
167
+ else
168
+ return if SAFE_VALUE_TYPES.any? { |t| value.is_a?(t) }
169
+
170
+ raise UnsafeRelationValueError,
171
+ "unsafe value type #{value.class} at #{location} (allowed: #{SAFE_VALUE_TYPES.map(&:name).join(', ')})"
172
+ end
173
+ end
174
+
175
+ # Atomic write: create tempfile in same directory, write, rename.
176
+ # Replaces target with full content. Crash mid-write leaves target
177
+ # either pre- or post-rename (never truncated).
178
+ #
179
+ # @param target_path [String] file to replace
180
+ # @param content [String] full file content
181
+ def atomic_write(target_path, content)
182
+ dir = File.dirname(target_path)
183
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
184
+
185
+ tempname = "#{File.basename(target_path)}.tmp.#{Process.pid}.#{SecureRandom.hex(4)}"
186
+ temp_path = File.join(dir, tempname)
187
+
188
+ begin
189
+ File.write(temp_path, content)
190
+ File.rename(temp_path, target_path)
191
+ ensure
192
+ File.delete(temp_path) if File.exist?(temp_path)
193
+ end
194
+ end
195
+
196
+ # Check that the parent directory of a missing target stays inside root.
197
+ # Used for the dangling (ENOENT) branch.
198
+ def verify_dangling_containment(candidate, root_real)
199
+ # Walk up until we find an existing ancestor
200
+ ancestor = candidate
201
+ until File.exist?(ancestor)
202
+ parent = File.dirname(ancestor)
203
+ break if parent == ancestor # reached fs root
204
+ ancestor = parent
205
+ end
206
+
207
+ return unless File.exist?(ancestor)
208
+
209
+ ancestor_real = File.realpath(ancestor)
210
+ sep = File::SEPARATOR
211
+ return if ancestor_real == root_real || ancestor_real.start_with?(root_real + sep)
212
+
213
+ raise PathEscapeError, "dangling target's nearest ancestor escapes context_root: #{ancestor_real}"
214
+ end
215
+
216
+ # BFS traverse informed_by edges starting from (start_sid, start_name).
217
+ #
218
+ # @param start_sid [String]
219
+ # @param start_name [String]
220
+ # @param context_root [String]
221
+ # @param max_depth [Integer]
222
+ # @return [Hash] { root:, nodes: [...], warnings: [...] }
223
+ def traverse_informed_by(start_sid:, start_name:, context_root:, max_depth: DEFAULT_MAX_DEPTH)
224
+ depth_limit = sanitize_max_depth(max_depth)
225
+ root_target = "v1:#{start_sid}/#{start_name}"
226
+ result = { root: root_target, nodes: [], warnings: [] }
227
+ visited = {}
228
+
229
+ queue = [[root_target, 0]]
230
+
231
+ until queue.empty?
232
+ target, depth = queue.shift
233
+ next if visited.key?(target)
234
+
235
+ visited[target] = true
236
+
237
+ node = visit_node(target, context_root, result[:warnings])
238
+ node[:depth] = depth
239
+ result[:nodes] << node
240
+
241
+ next if node[:status] != :ok
242
+ next if depth >= depth_limit
243
+
244
+ outgoing = read_relations(node[:path], result[:warnings])
245
+ outgoing.each_with_index do |edge, idx|
246
+ unless edge.is_a?(Hash)
247
+ result[:warnings] << "skip relations item #{idx} of #{node[:target]}: not a Hash"
248
+ next
249
+ end
250
+ next unless edge['type'] == 'informed_by' || edge[:type] == 'informed_by'
251
+ edge_target = edge['target'] || edge[:target]
252
+ next unless edge_target.is_a?(String)
253
+ next if visited.key?(edge_target)
254
+
255
+ queue << [edge_target, depth + 1]
256
+ end
257
+ end
258
+
259
+ result
260
+ end
261
+
262
+ # Coerce caller-supplied max_depth into [0, MAX_DEPTH_CLAMP]. Non-Integer
263
+ # input falls back to DEFAULT_MAX_DEPTH (avoids ArgumentError /
264
+ # NoMethodError on string or nil from tool args).
265
+ def sanitize_max_depth(value)
266
+ n = Integer(value) rescue DEFAULT_MAX_DEPTH
267
+ return 0 if n < 0
268
+ [n, MAX_DEPTH_CLAMP].min
269
+ end
270
+
271
+ # Visit one node: resolve, classify status. Returns node hash.
272
+ def visit_node(target, context_root, warnings)
273
+ resolved = resolve_target(target, context_root)
274
+ if resolved[:status] == :dangling
275
+ return { target: target, status: :dangling, reason: nil, path: nil }
276
+ end
277
+
278
+ { target: target, status: :ok, reason: nil, path: resolved[:path] }
279
+ rescue MalformedTargetError => e
280
+ warnings << "skip #{target}: malformed (#{e.message})"
281
+ { target: target, status: :skipped, reason: 'malformed', path: nil }
282
+ rescue PathResolutionError => e
283
+ warnings << "skip #{target}: path_resolution (#{e.message})"
284
+ { target: target, status: :skipped, reason: 'path_resolution', path: nil }
285
+ # PathEscapeError and SymlinkRejectedError intentionally NOT caught:
286
+ # those are hard fails on both write and read paths (v2.1 §3.1).
287
+ end
288
+
289
+ # Read a node's relations[] for traversal. Returns [] on any read-side
290
+ # issue (parse fail, missing schema, unknown schema), appending a warning.
291
+ def read_relations(md_path, warnings)
292
+ return [] unless md_path && File.exist?(md_path)
293
+
294
+ content = File.read(md_path, encoding: 'UTF-8')
295
+ m = content.match(/\A---\r?\n(.+?)\r?\n---\r?\n/m)
296
+ return [] unless m
297
+
298
+ begin
299
+ # permitted_classes intentionally symmetric with write-side
300
+ # SAFE_VALUE_TYPES (no Symbol). Legacy files containing Symbol values
301
+ # parse-fail loudly here rather than feed asymmetric data into BFS.
302
+ front = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {}
303
+ rescue StandardError => e
304
+ warnings << "skip relations of #{md_path}: parse_failed (#{e.message})"
305
+ return []
306
+ end
307
+
308
+ unless front.is_a?(Hash)
309
+ warnings << "skip relations of #{md_path}: frontmatter root is not a Hash (got #{front.class})"
310
+ return []
311
+ end
312
+
313
+ schema_v = front['relations_schema'] || front[:relations_schema]
314
+ if schema_v && schema_v != 1
315
+ warnings << "skip relations of #{md_path}: unknown_schema_version=#{schema_v.inspect}"
316
+ return []
317
+ end
318
+
319
+ rels = front['relations'] || front[:relations]
320
+ return [] unless rels.is_a?(Array)
321
+
322
+ rels
323
+ end
324
+ end
325
+ end