rails-informant 0.5.0 → 0.5.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: ebe80aab76837ee09279a0831b344bca9072a873e48b70197868e54a7b5a6ceb
4
- data.tar.gz: f637b4e6f460bbb982a2c3f51b49d87cb3fcaa8e6fd2c3547a694fc0f4fa70fc
3
+ metadata.gz: ef3e66d24feee02958d2d57db85411ec1e19db17fa0cc440936c3f2072016d7c
4
+ data.tar.gz: a43101bb80bffbad9d1a940d06e2a2d35bc5a8853a2978d5ada0dfdcbd87063d
5
5
  SHA512:
6
- metadata.gz: '08bf946e26795a53d035667fad63f91ccf5dda9497710a09d9e187d82ee35323ed64397b4761a8af6e81896d915df47e4a6b3f94777e5150fb3250262db6c5ba'
7
- data.tar.gz: 8fe9bb913213f18310a90ff232f11c1317246b231aeec3c9afba80c4e253d017dff9e41a829d90df5beee43f9dade333eb4317c24831340b1fdeabf50e068b02
6
+ metadata.gz: 626124766b399453b846fb37ea492a9cc971206dafcbe5019e050b8adde854f20a55944c84f8906d03c5a55255530480c7599dd3aaf143ac37bc4aca743ae83a
7
+ data.tar.gz: 8fb18f7ab56d4044601224d6c7c20ffcc77944517738d2a7fded15470fc3a18bb83ae963b6784305145519166d54f887b39e84f34c7ff3b5e2623581fd85096c
@@ -1,61 +1,107 @@
1
1
  #!/usr/bin/env bash
2
- # Informant: On the first Claude Code prompt of a session, check for unresolved
3
- # production errors unless that first prompt is the /informant command.
4
- # Requires: curl, jq
2
+ # Informant: on the first Claude Code prompt of a session, surface two things —
3
+ # an out-of-date Claude Code integration (drift), and unresolved production
4
+ # errors unless that first prompt is the /informant command. Either channel
5
+ # may fire on its own; when both fire, they share one combined message with a
6
+ # single pause instruction.
7
+ # Requires: jq (and curl, for the production check)
5
8
  # Env vars: INFORMANT_PRODUCTION_URL, INFORMANT_PRODUCTION_TOKEN
6
9
  # INFORMANT_PRODUCTION_PATH_PREFIX (optional, default: /informant)
7
10
 
8
11
  set -euo pipefail
9
12
 
10
- # Silent exit if env vars are missing or URL is not HTTPS
11
- [[ -z "${INFORMANT_PRODUCTION_URL:-}" ]] && exit 0
12
- [[ -z "${INFORMANT_PRODUCTION_TOKEN:-}" ]] && exit 0
13
- [[ "$INFORMANT_PRODUCTION_URL" == https://* ]] || exit 0
14
-
15
- # Silent exit if jq is not installed (needed to parse the hook payload)
13
+ # Silent exit if jq is not installed (needed to parse the hook payload).
16
14
  command -v jq >/dev/null 2>&1 || exit 0
17
15
 
18
16
  # UserPromptSubmit delivers a JSON payload on stdin carrying session_id and prompt.
19
17
  payload=$(cat)
20
18
  session_id=$(printf '%s' "$payload" | jq -r '.session_id // empty' 2>/dev/null || true)
19
+ prompt=$(printf '%s' "$payload" | jq -r '.prompt // empty' 2>/dev/null || true)
21
20
 
22
- # Run at most once per session, on the first prompt: a marker keyed by session_id
23
- # records that this session was handled, so later prompts short-circuit here before
24
- # parsing the prompt or doing network work. Require a token-shaped session_id (it is
25
- # interpolated into the path below) and stay silent when we can't record the marker,
26
- # rather than re-alerting on every later prompt of the session.
21
+ # Require a token-shaped session_id (it is interpolated into the marker path below).
27
22
  [[ "$session_id" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0
23
+
24
+ # Is the committed Claude Code integration out of date? The Ruby channels (dev
25
+ # boot warning, informant:doctor) write this flag under Rails.root/tmp. This hook
26
+ # reads it relative to the session's working directory, assuming that cwd is the
27
+ # Rails app root. In a monorepo where the Rails root is a subdirectory of the
28
+ # session cwd, this channel stays silent — the boot warning and informant:doctor
29
+ # still cover that case.
30
+ drift=0
31
+ [[ -e "tmp/rails-informant-drift" ]] && drift=1
32
+
33
+ # Can we check production errors? Needs both env vars and an HTTPS URL.
34
+ production=1
35
+ [[ -z "${INFORMANT_PRODUCTION_URL:-}" ]] && production=0
36
+ [[ -z "${INFORMANT_PRODUCTION_TOKEN:-}" ]] && production=0
37
+ [[ "${INFORMANT_PRODUCTION_URL:-}" == https://* ]] || production=0
38
+
39
+ # Nothing either channel could say → exit without claiming the session, so a
40
+ # later prompt (e.g. once env vars are set) still gets its chance. This also
41
+ # keeps a drift-free, production-unconfigured app a true silent no-op.
42
+ [[ "$drift" -eq 0 && "$production" -eq 0 ]] && exit 0
43
+
44
+ # Run at most once per session, on the first prompt: a marker keyed by session_id
45
+ # records that this session was handled, so later prompts short-circuit here.
46
+ # Stay silent when we can't record the marker, rather than re-firing every prompt.
28
47
  marker="${TMPDIR:-/tmp}/rails-informant-alert-${session_id}"
29
48
  [[ -e "$marker" ]] && exit 0
30
49
  : > "$marker" 2>/dev/null || exit 0
31
50
 
32
51
  # Stay quiet for the whole session when the first prompt is the /informant command —
33
- # the user is already triaging errors, so the startup alert would be redundant.
34
- prompt=$(printf '%s' "$payload" | jq -r '.prompt // empty' 2>/dev/null || true)
52
+ # the user is already triaging, so these startup nudges would be redundant.
35
53
  [[ "$prompt" =~ ^/informant($|[[:space:]]) ]] && exit 0
36
54
 
37
- path_prefix="${INFORMANT_PRODUCTION_PATH_PREFIX:-/informant}"
38
- url="${INFORMANT_PRODUCTION_URL}${path_prefix}/api/v1/status"
39
-
40
- # Fetch status (silent on failure)
41
- response=$(curl -s -f \
42
- --connect-timeout 3 \
43
- --max-time 5 \
44
- -H @- \
45
- "$url" <<< "Authorization: Bearer ${INFORMANT_PRODUCTION_TOKEN}" \
46
- 2>/dev/null) || exit 0
47
-
48
- # Parse unresolved count (silent exit on a missing or non-numeric value)
49
- unresolved=$(echo "$response" | jq -r '.unresolved_count // 0' 2>/dev/null) || exit 0
50
- [[ "$unresolved" =~ ^[0-9]+$ ]] || exit 0
51
- [[ "$unresolved" -eq 0 ]] && exit 0
52
-
53
- # Format error summary
54
- label="error"
55
- [[ "$unresolved" -gt 1 ]] && label="errors"
56
- cat <<ALERT
57
- 🚨 Informant: ${unresolved} unresolved ${label} in production
58
- $(echo "$response" | jq -r '.top_errors[]? | " - \(.error_class) (\(.total_occurrences) \(if .total_occurrences == 1 then "occurrence" else "occurrences" end))"' 2>/dev/null || true)
59
-
60
- START your response by casually mentioning these errors, then ask the user if they want to investigate or continue with their request. Do NOT proceed with the user's request until they answer.
61
- ALERT
55
+ # Assemble the data blocks first (the instruction comes last, on purpose).
56
+ body=""
57
+
58
+ if [[ "$drift" -eq 1 ]]; then
59
+ body+="🧭 Informant: this app's Claude Code integration is out of date.
60
+ The installed rails-informant gem would now generate different .claude/ files than the ones committed here.
61
+ Update it by running: bin/rails g rails_informant:skill
62
+ "
63
+ fi
64
+
65
+ errors=0
66
+ if [[ "$production" -eq 1 ]]; then
67
+ path_prefix="${INFORMANT_PRODUCTION_PATH_PREFIX:-/informant}"
68
+ url="${INFORMANT_PRODUCTION_URL}${path_prefix}/api/v1/status"
69
+
70
+ # Fetch status. A failure here must not suppress a pending drift nudge, so
71
+ # swallow the error into an empty response rather than exiting.
72
+ response=$(curl -s -f \
73
+ --connect-timeout 3 \
74
+ --max-time 5 \
75
+ -H @- \
76
+ "$url" <<< "Authorization: Bearer ${INFORMANT_PRODUCTION_TOKEN}" \
77
+ 2>/dev/null) || response=""
78
+
79
+ if [[ -n "$response" ]]; then
80
+ unresolved=$(printf '%s' "$response" | jq -r '.unresolved_count // 0' 2>/dev/null || echo 0)
81
+ if [[ "$unresolved" =~ ^[0-9]+$ && "$unresolved" -gt 0 ]]; then
82
+ label="error"
83
+ [[ "$unresolved" -gt 1 ]] && label="errors"
84
+ [[ -n "$body" ]] && body+="
85
+ "
86
+ body+="🚨 Informant: ${unresolved} unresolved ${label} in production
87
+ $(printf '%s' "$response" | jq -r '.top_errors[]? | " - \(.error_class) (\(.total_occurrences) \(if .total_occurrences == 1 then "occurrence" else "occurrences" end))"' 2>/dev/null || true)
88
+ "
89
+ errors=1
90
+ fi
91
+ fi
92
+ fi
93
+
94
+ # Nothing to report (e.g. drift flag absent and zero unresolved errors).
95
+ [[ -z "$body" ]] && exit 0
96
+
97
+ # One conversational instruction, last — with a specific behavior, a concrete
98
+ # question, and a single explicit pause (see the prompt-engineering notes).
99
+ if [[ "$drift" -eq 1 && "$errors" -eq 1 ]]; then
100
+ instruction="START your response by casually mentioning both the out-of-date integration (show the one-command fix) and these production errors, then ask the user how they want to proceed — update, investigate, or continue. Do NOT proceed with their request until they answer."
101
+ elif [[ "$drift" -eq 1 ]]; then
102
+ instruction="START your response by casually mentioning that the integration is out of date and showing the one-command fix, then ask the user whether to update it now or continue with their request. Do NOT proceed with their request until they answer."
103
+ else
104
+ instruction="START your response by casually mentioning these errors, then ask the user if they want to investigate or continue with their request. Do NOT proceed with the user's request until they answer."
105
+ fi
106
+
107
+ printf '%s\n%s\n' "$body" "$instruction"
@@ -1,8 +1,11 @@
1
1
  require "json"
2
2
  require "rails/generators"
3
+ require "rails_informant/claude_integration_content"
3
4
 
4
5
  module RailsInformant
5
6
  class SkillGenerator < Rails::Generators::Base
7
+ Content = ClaudeIntegrationContent
8
+
6
9
  source_root File.expand_path("skill/templates", __dir__)
7
10
 
8
11
  def copy_skill_file
@@ -16,16 +19,15 @@ module RailsInformant
16
19
 
17
20
  def create_or_update_mcp_json
18
21
  mcp_path = File.join(destination_root, ".mcp.json")
19
- informant_entry = { "command" => "informant-mcp" }
20
22
 
21
23
  if File.exist?(mcp_path)
22
24
  existing = JSON.parse(File.read(mcp_path))
23
25
  existing["mcpServers"] ||= {}
24
- existing["mcpServers"]["informant"] = informant_entry
26
+ existing["mcpServers"]["informant"] = Content.mcp_entry
25
27
  create_file ".mcp.json", JSON.pretty_generate(existing) + "\n", force: true
26
28
  else
27
29
  create_file ".mcp.json", JSON.pretty_generate(
28
- "mcpServers" => { "informant" => informant_entry }
30
+ "mcpServers" => { "informant" => Content.mcp_entry }
29
31
  ) + "\n"
30
32
  end
31
33
  rescue JSON::ParserError
@@ -34,31 +36,28 @@ module RailsInformant
34
36
 
35
37
  def create_or_update_settings_json
36
38
  settings_path = File.join(destination_root, ".claude", "settings.json")
37
- hook_command = ".claude/hooks/informant-alerts.sh"
38
39
 
39
40
  if File.exist?(settings_path)
40
41
  existing = JSON.parse(File.read(settings_path))
41
- existing["hooks"] ||= {}
42
- existing["hooks"]["UserPromptSubmit"] ||= []
43
-
44
- already_registered = existing["hooks"]["UserPromptSubmit"].any? do |entry|
45
- entry["hooks"]&.any? { it["command"] == hook_command }
46
- end
47
-
48
- unless already_registered
49
- existing["hooks"]["UserPromptSubmit"] << user_prompt_submit_hook(hook_command)
50
- end
51
-
42
+ existing["hooks"] = migrate_informant_hooks(existing["hooks"])
43
+ # Coerce a hand-written non-array event value (a lone hook object, a string)
44
+ # to an array so registering never raises NoMethodError on <<.
45
+ existing["hooks"][Content::HOOK_EVENT] = [] unless existing["hooks"][Content::HOOK_EVENT].is_a?(Array)
46
+ existing["hooks"][Content::HOOK_EVENT] << Content.hook_registration
52
47
  create_file ".claude/settings.json", JSON.pretty_generate(existing) + "\n", force: true
53
48
  else
54
49
  create_file ".claude/settings.json", JSON.pretty_generate(
55
- "hooks" => { "UserPromptSubmit" => [ user_prompt_submit_hook(hook_command) ] }
50
+ "hooks" => Content.expected_registrations
56
51
  ) + "\n"
57
52
  end
58
53
  rescue JSON::ParserError
59
54
  say "Could not parse existing .claude/settings.json — skipping hook setup.", :red
60
55
  end
61
56
 
57
+ def clear_drift_flag
58
+ RailsInformant::Integration.new(app_root: destination_root).write_drift_flag stale: false
59
+ end
60
+
62
61
  def print_next_steps
63
62
  say ""
64
63
  say "Claude Code integration installed!", :green
@@ -82,12 +81,18 @@ module RailsInformant
82
81
 
83
82
  private
84
83
 
85
- def user_prompt_submit_hook(command)
86
- {
87
- "hooks" => [
88
- { "type" => "command", "command" => command, "timeout" => 10 }
89
- ]
90
- }
84
+ # Remove every prior informant registration (matched by script path) from all
85
+ # event keys and drop keys left empty, so a re-run migrates a stale
86
+ # registration (e.g. a leftover SessionStart from an earlier gem version)
87
+ # instead of adding a second one alongside it. Unrelated hooks are preserved.
88
+ # Self-heals across future event-key changes rather than hardcoding an event.
89
+ def migrate_informant_hooks(hooks)
90
+ hooks = {} unless hooks.is_a?(Hash)
91
+ hooks.each_value do |entries|
92
+ entries.reject! { |entry| Content.informant_hook_entry?(entry) } if entries.is_a?(Array)
93
+ end
94
+ hooks.reject! { |_event, entries| entries.is_a?(Array) && entries.empty? }
95
+ hooks
91
96
  end
92
97
  end
93
98
  end
@@ -0,0 +1,77 @@
1
+ require "json"
2
+
3
+ module RailsInformant
4
+ # Single source of truth for the generated Claude Code integration content.
5
+ #
6
+ # Both SkillGenerator (generation) and Integration (drift detection) build
7
+ # their fragments here so the two can never silently diverge. Intentionally
8
+ # free of any rails/generators (Thor) dependency: Integration runs on the
9
+ # host-app boot path via the engine initializer, which must not load Thor.
10
+ module ClaudeIntegrationContent
11
+ # Paths, relative to the host app root, of the files the generator writes.
12
+ HOOK_SCRIPT_PATH = ".claude/hooks/informant-alerts.sh"
13
+ SKILL_PATH = ".claude/skills/informant/SKILL.md"
14
+ SETTINGS_PATH = ".claude/settings.json"
15
+ MCP_PATH = ".mcp.json"
16
+
17
+ # The command string a settings.json hook entry uses to invoke the script.
18
+ # Match-by-path detection keys on this value across every event key.
19
+ HOOK_COMMAND = HOOK_SCRIPT_PATH
20
+
21
+ # The event key the current gem registers the hook under.
22
+ HOOK_EVENT = "UserPromptSubmit"
23
+
24
+ module_function
25
+
26
+ # Directory of the installed gem, resolved from RubyGems rather than a
27
+ # generator's source_root so it works on the host-app boot path. Never
28
+ # RailsInformant::VERSION — that constant is env-driven and resolves to the
29
+ # 0.0.0.dev fallback in host apps.
30
+ def gem_dir
31
+ Gem.loaded_specs["rails-informant"].gem_dir
32
+ end
33
+
34
+ def templates_dir
35
+ File.join gem_dir, "lib", "generators", "rails_informant", "skill", "templates"
36
+ end
37
+
38
+ def hook_script
39
+ File.read File.join(templates_dir, "informant-alerts.sh")
40
+ end
41
+
42
+ def skill_markdown
43
+ File.read File.join(templates_dir, "SKILL.md")
44
+ end
45
+
46
+ # The informant server entry inside .mcp.json's "mcpServers".
47
+ def mcp_entry
48
+ { "command" => "informant-mcp" }
49
+ end
50
+
51
+ # A single UserPromptSubmit hook registration for settings.json.
52
+ def hook_registration
53
+ {
54
+ "hooks" => [
55
+ { "type" => "command", "command" => HOOK_COMMAND, "timeout" => 10 }
56
+ ]
57
+ }
58
+ end
59
+
60
+ # The informant-owned hooks map the generator produces: the registration
61
+ # keyed by its event. Detection compares the host app's extracted informant
62
+ # registrations against this, so a leftover entry under a different event key
63
+ # (e.g. a stale SessionStart) reads as drift.
64
+ def expected_registrations
65
+ { HOOK_EVENT => [ hook_registration ] }
66
+ end
67
+
68
+ # Whether a settings.json hook entry targets the informant script — the
69
+ # shared match-by-path predicate used by both generation (to sweep stale
70
+ # registrations) and detection (to extract the informant fragment).
71
+ def informant_hook_entry?(entry)
72
+ entry.is_a?(Hash) && Array(entry["hooks"]).any? do |hook|
73
+ hook.is_a?(Hash) && hook["command"] == HOOK_COMMAND
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,53 @@
1
+ require "rails_informant/integration"
2
+
3
+ module RailsInformant
4
+ # The informant:doctor channel: reports the integration state, prints the fix,
5
+ # refreshes the drift flag, and returns a process exit code (nonzero on stale
6
+ # or error) so it doubles as a CI signal. A thin wrapper over Integration that
7
+ # never raises out — a diagnostic must not crash the run it reports on.
8
+ class Doctor
9
+ def initialize(integration: Integration.new, io: $stdout)
10
+ @integration = integration
11
+ @io = io
12
+ end
13
+
14
+ def run
15
+ status = @integration.status
16
+ @integration.write_drift_flag stale: status == :stale
17
+ @io.puts report_for(status)
18
+ exit_code_for status
19
+ rescue StandardError => e
20
+ # An unexpected failure must exit nonzero, not 0 — a doctor wired into CI
21
+ # to gate drift must never report a false green when it could not check.
22
+ @io.puts "[Informant] doctor could not complete: #{e.message}"
23
+ 1
24
+ end
25
+
26
+ private
27
+
28
+ def report_for(status)
29
+ case status
30
+ when :current
31
+ "[Informant] Claude Code integration is up to date."
32
+ when :not_installed
33
+ "[Informant] Claude Code integration is not installed — nothing to check."
34
+ when :stale
35
+ <<~REPORT.chomp
36
+ [Informant] Claude Code integration is OUT OF DATE.
37
+ The installed gem would generate different .claude/ files than this app has committed.
38
+ Fix: bin/rails g rails_informant:skill
39
+ REPORT
40
+ when :error
41
+ <<~REPORT.chomp
42
+ [Informant] Claude Code integration could not be verified.
43
+ .claude/settings.json or .mcp.json is present but is not valid JSON.
44
+ Fix that file by hand — re-running the generator skips unparseable files.
45
+ REPORT
46
+ end
47
+ end
48
+
49
+ def exit_code_for(status)
50
+ status == :stale || status == :error ? 1 : 0
51
+ end
52
+ end
53
+ end
@@ -56,6 +56,33 @@ module RailsInformant
56
56
  config.after_initialize { RailsInformant::Engine.validate_api_token! }
57
57
  end
58
58
 
59
+ initializer "rails_informant.check_integration_drift" do
60
+ config.after_initialize { RailsInformant::Engine.check_integration_drift! }
61
+ end
62
+
63
+ # Dev-only, warn-only nudge: when the committed .claude/ integration has
64
+ # drifted from what the installed gem would generate now, log the one-command
65
+ # fix and refresh the drift flag the hook reads. Silent when current,
66
+ # not_installed, error, or in production; never raises out (a drift check must
67
+ # not break boot).
68
+ def self.check_integration_drift!
69
+ return unless Rails.env.development? && (RailsInformant.server_mode? || RailsInformant.console_mode?)
70
+
71
+ integration = RailsInformant::Integration.new
72
+ status = integration.status
73
+ integration.write_drift_flag stale: status == :stale
74
+
75
+ return unless status == :stale
76
+
77
+ Rails.logger&.warn <<~MSG.squish
78
+ [Informant] Your Claude Code integration is out of date — the installed
79
+ gem would generate different .claude/ files than this app has committed.
80
+ Run `bin/rails g rails_informant:skill` to update it.
81
+ MSG
82
+ rescue StandardError
83
+ # Never break boot over a drift check.
84
+ end
85
+
59
86
  MINIMUM_TOKEN_LENGTH = 32
60
87
 
61
88
  def self.validate_api_token!
@@ -0,0 +1,202 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "json"
4
+ require "pathname"
5
+ require "rails_informant/claude_integration_content"
6
+
7
+ module RailsInformant
8
+ # Classifies the host app's committed Claude Code integration by comparing its
9
+ # live .claude/ files against what the installed gem would generate now. The
10
+ # detection primitive behind the three drift channels (boot warning, doctor,
11
+ # hook nudge); Rails-boot-independent and unit-testable in isolation.
12
+ #
13
+ # Internals may raise (missing gem spec, unreadable templates); each channel
14
+ # wraps a single top-level rescue so a drift check never breaks boot or CI.
15
+ # The one exception is #write_drift_flag, which is best-effort by nature and
16
+ # swallows its own IO failures.
17
+ class Integration
18
+ Content = ClaudeIntegrationContent
19
+
20
+ DRIFT_FLAG = "rails-informant-drift"
21
+
22
+ # Fixed order so the digest is deterministic across runs.
23
+ COMPONENTS = %w[hook mcp settings skill].freeze
24
+
25
+ def initialize(app_root: Rails.root)
26
+ @app_root = Pathname(app_root)
27
+ end
28
+
29
+ # :not_installed | :current | :stale | :error
30
+ #
31
+ # not_installed wins first so apps that use the gem only for error capture
32
+ # are never nagged. error (a present-but-unparseable settings.json/.mcp.json)
33
+ # is distinct from stale because re-running the generator skips unparseable
34
+ # files — it could never clear a stale reported on one.
35
+ def status
36
+ return :not_installed unless installed?
37
+ return :error if json_error?
38
+
39
+ live_digest == expected_digest ? :current : :stale
40
+ end
41
+
42
+ def installed?
43
+ hook_script_present? || settings_informant_present? || mcp_informant_present?
44
+ end
45
+
46
+ def stale?
47
+ status == :stale
48
+ end
49
+
50
+ # Display only — the drift decision uses the digest, not the version. Harmless
51
+ # that local path/git installs report 0.0.0.dev here; only published installs
52
+ # show a real number.
53
+ def gem_version
54
+ Gem.loaded_specs["rails-informant"]&.version&.to_s
55
+ end
56
+
57
+ def drift_flag_path
58
+ @app_root.join "tmp", DRIFT_FLAG
59
+ end
60
+
61
+ # Best-effort: the Ruby channels refresh this flag so the bash hook can read
62
+ # drift without loading Ruby. Never raises out — a read-only tmp/ must not
63
+ # break a dev boot or a doctor run.
64
+ def write_drift_flag(stale:)
65
+ if stale
66
+ FileUtils.mkdir_p drift_flag_path.dirname
67
+ drift_flag_path.write "The Claude Code integration is out of date. " \
68
+ "Run `bin/rails g rails_informant:skill` to update it.\n"
69
+ elsif drift_flag_path.exist?
70
+ drift_flag_path.delete
71
+ end
72
+ rescue SystemCallError
73
+ nil
74
+ end
75
+
76
+ private
77
+
78
+ def hook_path = @app_root.join(Content::HOOK_SCRIPT_PATH)
79
+ def skill_path = @app_root.join(Content::SKILL_PATH)
80
+ def settings_path = @app_root.join(Content::SETTINGS_PATH)
81
+ def mcp_path = @app_root.join(Content::MCP_PATH)
82
+
83
+ def hook_script_present? = hook_path.exist?
84
+
85
+ def settings_informant_present?
86
+ registrations = live_settings_registrations
87
+ registrations && !registrations.empty?
88
+ end
89
+
90
+ def mcp_informant_present?
91
+ !live_mcp_entry.nil?
92
+ end
93
+
94
+ def json_error?
95
+ parse_failed?(settings_path) || parse_failed?(mcp_path)
96
+ end
97
+
98
+ # --- digest -------------------------------------------------------------
99
+
100
+ def expected_digest
101
+ digest_of(
102
+ "hook" => normalize_text(Content.hook_script),
103
+ "skill" => normalize_text(Content.skill_markdown),
104
+ "settings" => canonical_json(Content.expected_registrations),
105
+ "mcp" => canonical_json(Content.mcp_entry)
106
+ )
107
+ end
108
+
109
+ def live_digest
110
+ digest_of(
111
+ "hook" => normalize_text(read_or_empty(hook_path)),
112
+ "skill" => normalize_text(read_or_empty(skill_path)),
113
+ "settings" => canonical_json(live_settings_registrations || {}),
114
+ "mcp" => canonical_json(live_mcp_entry || {})
115
+ )
116
+ end
117
+
118
+ def digest_of(components)
119
+ material = COMPONENTS.map { |name| "#{name}:#{components[name]}" }.join("\n")
120
+ Digest::SHA256.hexdigest material
121
+ end
122
+
123
+ # --- live extraction ----------------------------------------------------
124
+
125
+ # The informant-owned hook registrations, keyed by event, swept from every
126
+ # event key by script path. nil when settings.json is absent or unparseable.
127
+ def live_settings_registrations
128
+ settings = parsed_json(settings_path) or return nil
129
+ hooks = settings["hooks"]
130
+ return {} unless hooks.is_a?(Hash)
131
+
132
+ hooks.each_with_object({}) do |(event, entries), result|
133
+ next unless entries.is_a?(Array)
134
+
135
+ informant = entries.select { |entry| Content.informant_hook_entry?(entry) }
136
+ result[event] = informant unless informant.empty?
137
+ end
138
+ end
139
+
140
+ # The informant server entry from .mcp.json, or nil when absent/unparseable.
141
+ def live_mcp_entry
142
+ mcp = parsed_json(mcp_path) or return nil
143
+ mcp.dig "mcpServers", "informant"
144
+ end
145
+
146
+ # --- json / text helpers ------------------------------------------------
147
+
148
+ # Read and parse each JSON file at most once per instance. Instances are
149
+ # created fresh per channel call, so this both removes the redundant reads
150
+ # and lets installed?/json_error?/the digest classify from one consistent
151
+ # snapshot (no split read where one path sees the file valid and another a
152
+ # concurrent edit).
153
+ def json_state(path)
154
+ @_json_states ||= {}
155
+ @_json_states[path] ||= compute_json_state(path)
156
+ end
157
+
158
+ def compute_json_state(path)
159
+ return [ :absent, nil ] unless path.exist?
160
+
161
+ [ :ok, JSON.parse(path.read) ]
162
+ rescue JSON::ParserError
163
+ [ :error, nil ]
164
+ end
165
+
166
+ def parsed_json(path)
167
+ state, data = json_state(path)
168
+ state == :ok ? data : nil
169
+ end
170
+
171
+ def parse_failed?(path)
172
+ json_state(path).first == :error
173
+ end
174
+
175
+ def read_or_empty(path)
176
+ path.exist? ? path.read : ""
177
+ end
178
+
179
+ # Canonical JSON: recursively sort hash keys so a host-side key reorder in a
180
+ # JSON fragment does not read as drift.
181
+ def canonical_json(object)
182
+ JSON.generate deep_sort(object)
183
+ end
184
+
185
+ def deep_sort(object)
186
+ case object
187
+ when Hash then object.keys.sort.each_with_object({}) { |key, sorted| sorted[key] = deep_sort(object[key]) }
188
+ when Array then object.map { |element| deep_sort(element) }
189
+ else object
190
+ end
191
+ end
192
+
193
+ # Strip a leading BOM, normalize line endings, and trim trailing whitespace so
194
+ # host-side encoding noise (CRLF via .gitattributes, an EditorConfig
195
+ # trim/final-newline rule, a BOM-prepending editor) does not byte-diverge into
196
+ # a permanent stale. Content reformatting (reindentation, blank-line collapse)
197
+ # is intentionally out of scope — it would risk masking real drift.
198
+ def normalize_text(text)
199
+ text.delete_prefix("\uFEFF").gsub(/\r\n?/, "\n").split("\n", -1).map(&:rstrip).join("\n").sub(/\n+\z/, "")
200
+ end
201
+ end
202
+ end
@@ -25,13 +25,16 @@ module RailsInformant
25
25
 
26
26
  autoload :BreadcrumbBuffer, "rails_informant/breadcrumb_buffer"
27
27
  autoload :BreadcrumbSubscriber, "rails_informant/breadcrumb_subscriber"
28
+ autoload :ClaudeIntegrationContent, "rails_informant/claude_integration_content"
28
29
  autoload :ContextBuilder, "rails_informant/context_builder"
29
30
  autoload :ContextFilter, "rails_informant/context_filter"
30
31
  autoload :Current, "rails_informant/current"
32
+ autoload :Doctor, "rails_informant/doctor"
31
33
  autoload :ErrorRecorder, "rails_informant/error_recorder"
32
34
  autoload :ErrorSubscriber, "rails_informant/error_subscriber"
33
35
  autoload :Event, "rails_informant/event"
34
36
  autoload :Fingerprint, "rails_informant/fingerprint"
37
+ autoload :Integration, "rails_informant/integration"
35
38
  autoload :StructuredEventSubscriber, "rails_informant/structured_event_subscriber"
36
39
 
37
40
  module Middleware
@@ -1,4 +1,9 @@
1
1
  namespace :informant do
2
+ desc "Report whether the Claude Code integration is current, stale, or not installed"
3
+ task doctor: :environment do
4
+ exit RailsInformant::Doctor.new.run
5
+ end
6
+
2
7
  desc "Purge resolved errors older than retention_days"
3
8
  task purge: :environment do
4
9
  RailsInformant::PurgeJob.perform_now
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-informant
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel López Prat
@@ -136,15 +136,18 @@ files:
136
136
  - lib/rails_informant.rb
137
137
  - lib/rails_informant/breadcrumb_buffer.rb
138
138
  - lib/rails_informant/breadcrumb_subscriber.rb
139
+ - lib/rails_informant/claude_integration_content.rb
139
140
  - lib/rails_informant/configuration.rb
140
141
  - lib/rails_informant/context_builder.rb
141
142
  - lib/rails_informant/context_filter.rb
142
143
  - lib/rails_informant/current.rb
144
+ - lib/rails_informant/doctor.rb
143
145
  - lib/rails_informant/engine.rb
144
146
  - lib/rails_informant/error_recorder.rb
145
147
  - lib/rails_informant/error_subscriber.rb
146
148
  - lib/rails_informant/event.rb
147
149
  - lib/rails_informant/fingerprint.rb
150
+ - lib/rails_informant/integration.rb
148
151
  - lib/rails_informant/mcp.rb
149
152
  - lib/rails_informant/mcp/base_tool.rb
150
153
  - lib/rails_informant/mcp/client.rb