reanimator 0.1.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 (114) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +207 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +483 -0
  5. data/SECURITY.md +32 -0
  6. data/exe/reanimator +6 -0
  7. data/lib/reanimator/acceptance_criterion.rb +75 -0
  8. data/lib/reanimator/agent_connection.rb +62 -0
  9. data/lib/reanimator/agent_profile.rb +51 -0
  10. data/lib/reanimator/agents/adapter.rb +49 -0
  11. data/lib/reanimator/agents/capability.rb +29 -0
  12. data/lib/reanimator/agents/constraints.rb +57 -0
  13. data/lib/reanimator/agents/hosted.rb +38 -0
  14. data/lib/reanimator/agents/local.rb +46 -0
  15. data/lib/reanimator/agents/patch.rb +60 -0
  16. data/lib/reanimator/agents/repair_task.rb +51 -0
  17. data/lib/reanimator/agents/result.rb +42 -0
  18. data/lib/reanimator/agents/strategies/arithmetic_fix.rb +51 -0
  19. data/lib/reanimator/agents/strategies/base.rb +26 -0
  20. data/lib/reanimator/agents.rb +38 -0
  21. data/lib/reanimator/autonomy_level.rb +41 -0
  22. data/lib/reanimator/baseline.rb +106 -0
  23. data/lib/reanimator/baseline_runner.rb +81 -0
  24. data/lib/reanimator/bounded_command.rb +87 -0
  25. data/lib/reanimator/classifier.rb +89 -0
  26. data/lib/reanimator/cli.rb +273 -0
  27. data/lib/reanimator/cohort.rb +61 -0
  28. data/lib/reanimator/commissioning/fixture.rb +69 -0
  29. data/lib/reanimator/commissioning/report.rb +45 -0
  30. data/lib/reanimator/commissioning/test.rb +145 -0
  31. data/lib/reanimator/confidence.rb +66 -0
  32. data/lib/reanimator/configuration.rb +35 -0
  33. data/lib/reanimator/contract_file.rb +116 -0
  34. data/lib/reanimator/contract_snapshot.rb +188 -0
  35. data/lib/reanimator/criteria_stack.rb +91 -0
  36. data/lib/reanimator/criterion_result.rb +70 -0
  37. data/lib/reanimator/detectors/base.rb +49 -0
  38. data/lib/reanimator/detectors/bundler.rb +68 -0
  39. data/lib/reanimator/detectors/commands.rb +112 -0
  40. data/lib/reanimator/detectors/fork.rb +25 -0
  41. data/lib/reanimator/detectors/github_actions.rb +68 -0
  42. data/lib/reanimator/detectors/operational_proof.rb +48 -0
  43. data/lib/reanimator/detectors/rails_app.rb +51 -0
  44. data/lib/reanimator/detectors/relationships.rb +46 -0
  45. data/lib/reanimator/detectors/ruby_gem.rb +40 -0
  46. data/lib/reanimator/detectors/security_controls.rb +36 -0
  47. data/lib/reanimator/discovered_fact.rb +99 -0
  48. data/lib/reanimator/discovery.rb +48 -0
  49. data/lib/reanimator/discovery_result.rb +89 -0
  50. data/lib/reanimator/drift_detector.rb +227 -0
  51. data/lib/reanimator/drift_report.rb +86 -0
  52. data/lib/reanimator/effective_contract.rb +108 -0
  53. data/lib/reanimator/effective_policy_resolver.rb +116 -0
  54. data/lib/reanimator/errors.rb +21 -0
  55. data/lib/reanimator/evaluators/base.rb +55 -0
  56. data/lib/reanimator/evaluators/command.rb +34 -0
  57. data/lib/reanimator/evaluators/documentation_check.rb +25 -0
  58. data/lib/reanimator/evaluators/http_smoke.rb +19 -0
  59. data/lib/reanimator/evaluators/repository_integrity.rb +22 -0
  60. data/lib/reanimator/evaluators/security_evidence.rb +24 -0
  61. data/lib/reanimator/evaluators/support_matrix.rb +25 -0
  62. data/lib/reanimator/evaluators.rb +41 -0
  63. data/lib/reanimator/family/gate.rb +40 -0
  64. data/lib/reanimator/family/gate_result.rb +46 -0
  65. data/lib/reanimator/family/gate_runner.rb +91 -0
  66. data/lib/reanimator/family.rb +89 -0
  67. data/lib/reanimator/family_inference.rb +113 -0
  68. data/lib/reanimator/git.rb +72 -0
  69. data/lib/reanimator/isolation.rb +36 -0
  70. data/lib/reanimator/language_inference.rb +43 -0
  71. data/lib/reanimator/lifecycle_intent.rb +93 -0
  72. data/lib/reanimator/obligation.rb +35 -0
  73. data/lib/reanimator/permission_model.rb +61 -0
  74. data/lib/reanimator/portfolio.rb +68 -0
  75. data/lib/reanimator/portfolio_drift.rb +120 -0
  76. data/lib/reanimator/profile_inference.rb +37 -0
  77. data/lib/reanimator/profiles.rb +110 -0
  78. data/lib/reanimator/proof_of_life.rb +45 -0
  79. data/lib/reanimator/proposed_change.rb +55 -0
  80. data/lib/reanimator/provenance.rb +39 -0
  81. data/lib/reanimator/relationship.rb +62 -0
  82. data/lib/reanimator/repair_run.rb +146 -0
  83. data/lib/reanimator/reports/agents.rb +28 -0
  84. data/lib/reanimator/reports/baseline.rb +73 -0
  85. data/lib/reanimator/reports/commissioning.rb +48 -0
  86. data/lib/reanimator/reports/contract.rb +70 -0
  87. data/lib/reanimator/reports/discovery.rb +38 -0
  88. data/lib/reanimator/reports/drift.rb +63 -0
  89. data/lib/reanimator/reports/families.rb +41 -0
  90. data/lib/reanimator/reports/family_gates.rb +41 -0
  91. data/lib/reanimator/reports/github_check.rb +45 -0
  92. data/lib/reanimator/reports/inventory.rb +37 -0
  93. data/lib/reanimator/reports/permissions.rb +32 -0
  94. data/lib/reanimator/reports/portfolio_baseline.rb +68 -0
  95. data/lib/reanimator/reports/portfolio_drift.rb +107 -0
  96. data/lib/reanimator/reports/run.rb +52 -0
  97. data/lib/reanimator/reports/snapshot.rb +58 -0
  98. data/lib/reanimator/reports.rb +36 -0
  99. data/lib/reanimator/repository.rb +96 -0
  100. data/lib/reanimator/repository_metadata.rb +78 -0
  101. data/lib/reanimator/repository_state.rb +48 -0
  102. data/lib/reanimator/routing/decision.rb +35 -0
  103. data/lib/reanimator/routing/router.rb +122 -0
  104. data/lib/reanimator/run.rb +48 -0
  105. data/lib/reanimator/safety_floor.rb +81 -0
  106. data/lib/reanimator/security_control.rb +87 -0
  107. data/lib/reanimator/security_posture.rb +80 -0
  108. data/lib/reanimator/snapshot_store.rb +78 -0
  109. data/lib/reanimator/task_class.rb +63 -0
  110. data/lib/reanimator/version.rb +5 -0
  111. data/lib/reanimator/vitality_profile.rb +44 -0
  112. data/lib/reanimator/waiver.rb +74 -0
  113. data/lib/reanimator.rb +214 -0
  114. metadata +262 -0
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Reports
5
+ # Drift across a portfolio, rendered as a triage list rather than a pile of
6
+ # diffs. The ordering is the point: repositories that need a human come
7
+ # first and carry the reason for each policy change, repositories that
8
+ # merely changed follow as information, and repositories with nothing
9
+ # recorded are named rather than quietly counted as fine.
10
+ class PortfolioDrift
11
+ def initialize(drift)
12
+ @drift = drift
13
+ end
14
+
15
+ def render
16
+ ([banner] + sections + [verdict, footer]).compact.join("\n\n")
17
+ end
18
+
19
+ private
20
+
21
+ def banner
22
+ [
23
+ "REANIMATOR DRIFT",
24
+ "Portfolio: #{@drift.name}",
25
+ "Generated: #{@drift.generated_at.strftime("%Y-%m-%d %H:%M %Z")}",
26
+ "Mode: read-only"
27
+ ].join("\n")
28
+ end
29
+
30
+ def sections
31
+ [
32
+ review_section,
33
+ listing("CHANGED, BENIGN", @drift.benign) { |entry| change_lines(entry.report.benign_changes, "·", 4) },
34
+ listing("UNCHANGED", @drift.unchanged),
35
+ listing("NOT RECORDED", @drift.unrecorded) { [" nothing to compare against yet"] },
36
+ listing("COULD NOT READ", @drift.failed) { |entry| [" #{entry.error}"] }
37
+ ].compact
38
+ end
39
+
40
+ # The only section that spells out *why* each change matters: these are
41
+ # the ones somebody has to decide about.
42
+ def review_section
43
+ listing("REVIEW REQUIRED", @drift.policy_changed) do |entry|
44
+ changes = entry.report.policy_changes
45
+ [" #{Reports.count(changes.size, "policy change")}"] + change_lines(changes, "!", 6, explain: true)
46
+ end
47
+ end
48
+
49
+ def listing(title, entries)
50
+ return if entries.empty?
51
+
52
+ lines = ["#{title} — #{Reports.count(entries.size, "repository", "repositories")}"]
53
+ entries.each do |entry|
54
+ lines << " #{entry.name}"
55
+ lines.concat(block_given? ? yield(entry) : [])
56
+ end
57
+ lines.join("\n")
58
+ end
59
+
60
+ def change_lines(changes, marker, indent, explain: false)
61
+ pad = " " * indent
62
+ changes.flat_map do |change|
63
+ lines = ["#{pad}#{marker} #{change.summary}"]
64
+ lines << "#{pad} #{change.explanation}" if explain
65
+ lines
66
+ end
67
+ end
68
+
69
+ # Two separate reasons a portfolio can need a human, reported separately
70
+ # because they call for different things: drift somebody has to approve,
71
+ # and a snapshot nobody can read.
72
+ def verdict
73
+ return "No policy changes across the portfolio; nothing here needs a decision." unless @drift.requires_review?
74
+
75
+ [policy_verdict, failure_verdict].compact.join("\n\n")
76
+ end
77
+
78
+ def policy_verdict
79
+ repositories = @drift.policy_changed
80
+ return if repositories.empty?
81
+
82
+ total = repositories.sum { |entry| entry.report.policy_changes.size }
83
+ "REVIEW REQUIRED: #{Reports.count(total, "policy change")} across " \
84
+ "#{Reports.count(repositories.size, "repository", "repositories")} — less is being proven than before.\n" \
85
+ "Intended? Re-record with: #{@drift.store.record_command}"
86
+ end
87
+
88
+ def failure_verdict
89
+ failures = @drift.failed
90
+ return if failures.empty?
91
+
92
+ "REVIEW REQUIRED: #{Reports.count(failures.size, "repository", "repositories")} could not be read, " \
93
+ "so drift could not be ruled out there."
94
+ end
95
+
96
+ # The read-only promise, stated precisely. Recording writes snapshot
97
+ # files and nothing else, so the report says which it did rather than
98
+ # claiming nothing happened.
99
+ def footer
100
+ recorded = @drift.entries.count { |entry| entry.recorded_to }
101
+ return "NO FILES WERE WRITTEN. NO SOURCE CHANGES WERE MADE." if recorded.zero?
102
+
103
+ "Recorded #{Reports.count(recorded, "snapshot")}. NO SOURCE CHANGES WERE MADE."
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Reports
5
+ # Renders a run ledger entry (specification section 17). It leads with the
6
+ # outcome, records the agent and policy version, and - for a proposed repair
7
+ # - shows the draft PR that was produced but not submitted, reaffirming that
8
+ # no source was written.
9
+ class Run
10
+ OUTCOME_LABEL = {
11
+ no_op: "NO-OP (healthy)",
12
+ report: "REPORT",
13
+ draft_pr: "DRAFT PR (proposed, not submitted)",
14
+ escalation: "ESCALATION",
15
+ safe_stop: "SAFE STOP"
16
+ }.freeze
17
+
18
+ def initialize(run)
19
+ @run = run
20
+ end
21
+
22
+ def render
23
+ lines = [
24
+ "Run — #{@run.repository.full_name}",
25
+ "Outcome: #{OUTCOME_LABEL.fetch(@run.outcome, @run.outcome.to_s.upcase)}",
26
+ "Trigger: #{@run.trigger}",
27
+ "Policy: #{@run.policy_version}"
28
+ ]
29
+ lines << "Objective: #{@run.objective.id} — #{@run.objective.claim}" if @run.objective
30
+ lines << "Agent: #{@run.provider}" if @run.provider
31
+ lines << "Evidence: #{@run.pre_state} → #{@run.post_state}" if @run.post_state
32
+ lines << "Reason: #{@run.reason}" if @run.reason
33
+ lines.concat(proposed_change_lines) if @run.proposed_change
34
+ lines << "NO SOURCE WAS WRITTEN TO THE REPOSITORY."
35
+ lines.join("\n")
36
+ end
37
+
38
+ private
39
+
40
+ def proposed_change_lines
41
+ change = @run.proposed_change
42
+ [
43
+ "",
44
+ "Proposed draft PR:",
45
+ " branch: #{change.branch}",
46
+ " title: #{change.title}",
47
+ " files: #{change.patch.paths.join(", ")}"
48
+ ]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Reports
5
+ # Renders a recorded contract snapshot for a person rather than a machine:
6
+ # what this repository is currently required to prove, and how to put that
7
+ # on record so later drift has something to be measured against.
8
+ class Snapshot
9
+ def initialize(snapshot, saved_to: nil, record_with: "reanimator drift --record")
10
+ @snapshot = snapshot
11
+ @saved_to = saved_to
12
+ @record_with = record_with
13
+ end
14
+
15
+ def render
16
+ ([header] + [policy, proves, evidence].compact + [footer]).join("\n")
17
+ end
18
+
19
+ private
20
+
21
+ def header
22
+ "Contract snapshot — #{@snapshot.repository}\nTaken: #{@snapshot.taken_at}"
23
+ end
24
+
25
+ def policy
26
+ "Intent: #{@snapshot.intent} Profile: #{@snapshot.profile} " \
27
+ "Security: #{@snapshot.security_posture} posture"
28
+ end
29
+
30
+ def proves
31
+ required = @snapshot.criteria.count { |criterion| criterion[:required] }
32
+ line = "Proves: #{Reports.count(@snapshot.criteria.size, "criterion", "criteria")} (#{required} required)"
33
+ return line if @snapshot.waivers.empty?
34
+
35
+ "#{line}, #{Reports.count(@snapshot.waivers.size, "accepted risk")}"
36
+ end
37
+
38
+ # The evidence half of the snapshot: the facts that drift can narrow
39
+ # without anyone editing .reanimator.yml.
40
+ def evidence
41
+ parts = []
42
+ parts << "runtimes #{@snapshot.support_matrix.join(", ")}" if @snapshot.support_matrix.any?
43
+ parts << "security gates #{@snapshot.security_controls.join(", ")}" if @snapshot.security_controls.any?
44
+ "Evidence: #{parts.join(" · ")}" if parts.any?
45
+ end
46
+
47
+ # Whichever way this snapshot was recorded, the next thing a maintainer
48
+ # wants is the command that compares against it - so the footer names
49
+ # that, not the flag they happened to use.
50
+ def footer
51
+ return "\nRecorded to #{@saved_to}. Compare later with: reanimator drift" if @saved_to
52
+
53
+ "\nNothing was written. Record this with `#{@record_with}` so later drift " \
54
+ "has something to be measured against."
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Plain-text reporters. They build deterministic strings with no color or
5
+ # TTY dependency so they are trivially testable; the CLI layers on boxes and
6
+ # color when a human is watching. The output style mirrors the specification's
7
+ # own artifacts (the section 15 GitHub check and the Appendix A baseline).
8
+ module Reports
9
+ # Status glyphs shared across reporters.
10
+ GLYPH = {
11
+ verified: "✓",
12
+ failed: "✗",
13
+ missing_evidence: "•",
14
+ skipped: "◦",
15
+ accepted_risk: "!",
16
+ planned: "…",
17
+ error: "✗"
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def glyph(status) = GLYPH.fetch(status.to_sym, "·")
23
+
24
+ # Human-readable label for a result status.
25
+ def status_label(status)
26
+ status.to_s.tr("_", " ")
27
+ end
28
+
29
+ # "1 repository" / "3 repositories". Every reporter needs to put a number
30
+ # in front of a noun, and each had grown its own spelling of it - which is
31
+ # how a user eventually reads "1 repositorys".
32
+ def count(size, singular, plural = "#{singular}s")
33
+ "#{size} #{(size == 1) ? singular : plural}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A single repository under (potential) stewardship: its location, its
5
+ # read-only metadata, any owner-declared contract, and the lifecycle intent
6
+ # currently assigned to it. Detectors read a repository through the file
7
+ # helpers here so they never reach outside its root, and so a future
8
+ # non-filesystem source could satisfy the same surface.
9
+ class Repository
10
+ # @return [String] absolute path to the working tree
11
+ attr_reader :path
12
+ # @return [RepositoryMetadata]
13
+ attr_reader :metadata
14
+ # @return [ContractFile, nil] parsed .reanimator.yml, when present
15
+ attr_reader :declared
16
+ # @return [Symbol, nil] assigned lifecycle intent (mutable during onboarding)
17
+ attr_accessor :intent
18
+
19
+ # Builds a repository from a path on disk.
20
+ # @raise [RepositoryError] when the path is not a directory
21
+ def self.at(path)
22
+ root = File.expand_path(path.to_s)
23
+ unless File.directory?(root)
24
+ raise RepositoryError, "#{path.inspect} is not a directory."
25
+ end
26
+
27
+ declared = ContractFile.load(root)
28
+ new(
29
+ path: root,
30
+ metadata: RepositoryMetadata.from_local(root),
31
+ declared: declared,
32
+ intent: declared&.intent
33
+ )
34
+ end
35
+
36
+ def initialize(path:, metadata:, declared: nil, intent: nil)
37
+ @path = path
38
+ @metadata = metadata
39
+ @declared = declared
40
+ @intent = intent
41
+ end
42
+
43
+ def name = metadata.name
44
+
45
+ def full_name = metadata.full_name || metadata.name
46
+
47
+ def fork? = metadata.fork?
48
+
49
+ # Whether the owner committed a contract file. When true, the declared
50
+ # profile and intent win over inference (section 06 conflict resolution).
51
+ def declared? = !declared.nil?
52
+
53
+ # @return [Symbol, nil] the profile the owner declared, if any
54
+ def declared_profile = declared&.profile
55
+
56
+ # --- read-only file access used by detectors -----------------------------
57
+
58
+ # @return [String] absolute path for a repository-relative path
59
+ def absolute(relative) = File.join(path, relative)
60
+
61
+ # @return [Boolean] whether a repository-relative path exists
62
+ def exist?(relative) = File.exist?(absolute(relative))
63
+
64
+ # @return [Array<String>] repository-relative paths matching a glob
65
+ def glob(pattern)
66
+ Dir.glob(File.join(path, pattern), File::FNM_DOTMATCH)
67
+ .map { |absolute| absolute.delete_prefix("#{path}/") }
68
+ .sort
69
+ end
70
+
71
+ # @return [Boolean] whether any path matches the glob
72
+ def any?(pattern) = !glob(pattern).empty?
73
+
74
+ # @return [String, nil] contents of a repository-relative file, or nil.
75
+ # Repository content is untrusted and may not be valid UTF-8; it is read
76
+ # as UTF-8 and scrubbed so a detector's regex or slice can never raise on
77
+ # a stray byte (which would otherwise be swallowed as a "detector failed"
78
+ # and silently drop facts), including under a C/US-ASCII locale.
79
+ def read(relative)
80
+ return unless exist?(relative)
81
+
82
+ File.read(absolute(relative), encoding: "UTF-8").scrub
83
+ end
84
+
85
+ def to_h
86
+ {
87
+ name: name,
88
+ full_name: full_name,
89
+ path: path,
90
+ intent: intent,
91
+ declared: declared?,
92
+ metadata: metadata.to_h
93
+ }
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The read-only facts about a repository that exist before any detector runs
5
+ # (specification section 04 inventory inputs): name, ownership, default
6
+ # branch, primary language, fork status, and last activity. This object is
7
+ # the seam between "where the repository lives" and "what it is": a local
8
+ # working tree fills it from git today, and a GitHub inventory source can
9
+ # fill the very same shape tomorrow without touching the rest of the system.
10
+ class RepositoryMetadata
11
+ attr_reader :name, :full_name, :default_branch, :primary_language,
12
+ :last_activity, :commit_count, :topics
13
+
14
+ def initialize(name:, full_name: nil, default_branch: nil, primary_language: nil,
15
+ fork: false, archived: false, last_activity: nil, commit_count: nil, topics: [], has_git: false)
16
+ @name = name
17
+ @full_name = full_name
18
+ @default_branch = default_branch
19
+ @primary_language = primary_language
20
+ @fork = fork
21
+ @archived = archived
22
+ @last_activity = last_activity
23
+ @commit_count = commit_count
24
+ @topics = topics
25
+ @has_git = has_git
26
+ end
27
+
28
+ def fork? = @fork
29
+
30
+ def archived? = @archived
31
+
32
+ def has_git? = @has_git
33
+
34
+ # Days since the last commit, or nil when unknown. Feeds the "old
35
+ # unarchived repository" and "recent experiment" cohort heuristics.
36
+ def days_since_activity(now: Time.now)
37
+ return nil unless last_activity
38
+ ((now - last_activity) / 86_400).floor
39
+ end
40
+
41
+ # Builds metadata from a local working tree. Everything is best-effort:
42
+ # a directory that is not a git repository still yields a usable object.
43
+ def self.from_local(root)
44
+ has_git = Git.repository?(root)
45
+ origin = has_git ? Git.origin_url(root) : nil
46
+ new(
47
+ name: File.basename(root),
48
+ full_name: full_name_from_url(origin),
49
+ default_branch: has_git ? Git.current_branch(root) : nil,
50
+ primary_language: LanguageInference.call(root),
51
+ fork: has_git && Git.upstream_remote?(root),
52
+ archived: false,
53
+ last_activity: has_git ? Git.last_commit_at(root) : nil,
54
+ commit_count: has_git ? Git.commit_count(root) : nil,
55
+ topics: [],
56
+ has_git: has_git
57
+ )
58
+ end
59
+
60
+ # Parses "owner/repo" from a git remote URL in either SSH or HTTPS form.
61
+ def self.full_name_from_url(url)
62
+ return nil unless url
63
+ cleaned = url.sub(/\.git\z/, "")
64
+ case cleaned
65
+ when %r{[:/]([^/:]+)/([^/]+)\z}
66
+ "#{$1}/#{$2}"
67
+ end
68
+ end
69
+
70
+ def to_h
71
+ {
72
+ name: name, full_name: full_name, default_branch: default_branch,
73
+ primary_language: primary_language, fork: fork?, archived: archived?,
74
+ last_activity: last_activity&.iso8601, commit_count: commit_count, topics: topics
75
+ }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The proof-of-life states a repository can hold (specification section 15).
5
+ # These make "healthy silence" visible: Alive is an earned, evidence-backed
6
+ # status, not the absence of alarms, and Unproven is never allowed to masquerade
7
+ # as green.
8
+ class RepositoryState
9
+ attr_reader :key, :meaning, :color
10
+
11
+ def initialize(key:, meaning:, color:)
12
+ @key = key
13
+ @meaning = meaning
14
+ @color = color
15
+ freeze
16
+ end
17
+
18
+ def to_sym = key
19
+
20
+ def to_s = key.to_s
21
+
22
+ # @return [Boolean] whether this state counts as verified vitality
23
+ def alive? = key == :alive
24
+
25
+ ALL = {
26
+ alive: new(key: :alive, color: :green,
27
+ meaning: "All due obligations have fresh passing evidence and no unapproved blocking risk."),
28
+ at_risk: new(key: :at_risk, color: :yellow,
29
+ meaning: "A deadline, support window, vulnerability, or degrading signal requires attention soon."),
30
+ needs_you: new(key: :needs_you, color: :magenta,
31
+ meaning: "A decision, permission, secret, environment, or domain judgment blocks safe progress."),
32
+ recovering: new(key: :recovering, color: :cyan,
33
+ meaning: "A repair or coordinated migration is active."),
34
+ unproven: new(key: :unproven, color: :yellow,
35
+ meaning: "A claim exists but sufficient executable evidence is absent or stale."),
36
+ sleeping: new(key: :sleeping, color: :blue,
37
+ meaning: "The repository is intentionally monitored or preserved rather than actively maintained."),
38
+ diverged: new(key: :diverged, color: :magenta,
39
+ meaning: "A maintained fork has meaningful upstream drift or unique commits requiring review."),
40
+ retired: new(key: :retired, color: :white,
41
+ meaning: "End-of-life is explicit and its retirement checklist is complete.")
42
+ }.freeze
43
+
44
+ def self.fetch(key)
45
+ ALL.fetch(key.to_sym) { raise UnknownError, "Unknown repository state #{key.inspect}." }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Routing
5
+ # The outcome of routing one task to one profile (specification section 08).
6
+ # A decision is never silent: it carries the human-readable explanation the
7
+ # spec insists on ("Selected Codex because ... Amp was excluded because
8
+ # ...") and the declared fallbacks - fallback is never implied.
9
+ class Decision
10
+ attr_reader :profile, :strategy, :explanation, :excluded, :fallbacks
11
+
12
+ def initialize(profile:, strategy:, explanation:, excluded: [], fallbacks: [])
13
+ @profile = profile
14
+ @strategy = strategy
15
+ @explanation = explanation
16
+ @excluded = excluded
17
+ @fallbacks = fallbacks
18
+ end
19
+
20
+ def selected? = !profile.nil?
21
+
22
+ def provider = profile&.provider
23
+
24
+ def to_h
25
+ {
26
+ strategy: strategy,
27
+ selected: provider,
28
+ explanation: explanation,
29
+ fallbacks: fallbacks.map(&:provider),
30
+ excluded: excluded
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Routing
5
+ # Chooses which ready profile performs a task (specification section 08
6
+ # routing strategies). Provider choice is an execution concern, not the
7
+ # repository's definition of life, so routing operates entirely on
8
+ # commissioned profiles and always produces a visible explanation. Fallback
9
+ # is considered only when explicitly declared, and only across profiles that
10
+ # share a data-handling policy.
11
+ class Router
12
+ STRATEGIES = %i[auto pinned ordered task_routed local_only manual].freeze
13
+
14
+ def initialize(profiles, strategy: :auto, order: [], pin: nil, fallback: [], task_routes: {})
15
+ @profiles = profiles
16
+ @strategy = strategy.to_sym
17
+ @order = Array(order).map(&:to_sym)
18
+ @pin = pin&.to_sym
19
+ @fallback = Array(fallback)
20
+ @task_routes = task_routes
21
+ raise UnknownError, "Unknown routing strategy #{strategy.inspect}." unless STRATEGIES.include?(@strategy)
22
+ end
23
+
24
+ # @param required_capabilities [Array<Symbol>]
25
+ # @param task_class [Symbol, nil]
26
+ # @return [Decision]
27
+ def select(required_capabilities: [], task_class: nil)
28
+ excluded = []
29
+ eligible = @profiles.select do |profile|
30
+ reason = ineligibility(profile, required_capabilities, task_class)
31
+ excluded << {provider: profile.provider, reason: reason} if reason
32
+ reason.nil?
33
+ end
34
+
35
+ chosen = choose(eligible, task_class)
36
+ fallbacks = chosen ? declared_fallbacks(chosen) : []
37
+
38
+ Decision.new(
39
+ profile: chosen, strategy: @strategy, excluded: excluded, fallbacks: fallbacks,
40
+ explanation: explain(chosen, excluded, fallbacks, required_capabilities, task_class)
41
+ )
42
+ end
43
+
44
+ private
45
+
46
+ # Returns a reason string when a profile cannot be used, else nil.
47
+ def ineligibility(profile, required_capabilities, task_class)
48
+ return "it is not commissioned ready (#{profile.readiness})" unless profile.ready?
49
+
50
+ missing = required_capabilities.map(&:to_sym) - profile.capabilities
51
+ return "it lacks required capabilities: #{missing.join(", ")}" if missing.any?
52
+
53
+ case @strategy
54
+ when :local_only
55
+ "it is not a local profile" unless profile.connection.local?
56
+ when :pinned
57
+ "it is not the pinned provider (#{@pin})" unless profile.provider == @pin
58
+ when :task_routed
59
+ expected = @task_routes[task_class]
60
+ "task #{task_class} routes to #{expected}, not #{profile.provider}" if expected && profile.provider != expected
61
+ end
62
+ end
63
+
64
+ def choose(eligible, task_class)
65
+ return nil if @strategy == :manual
66
+
67
+ case @strategy
68
+ when :ordered
69
+ @order.filter_map { |provider| eligible.find { |p| p.provider == provider } }.first
70
+ when :pinned
71
+ eligible.find { |p| p.provider == @pin }
72
+ when :task_routed
73
+ eligible.find { |p| p.provider == @task_routes[task_class] } || prefer(eligible)
74
+ else # auto, local_only
75
+ prefer(eligible)
76
+ end
77
+ end
78
+
79
+ # Prefer local (cheapest, most private), then declared order, else stable.
80
+ def prefer(eligible)
81
+ eligible.min_by.with_index do |profile, index|
82
+ [profile.connection.local? ? 0 : 1, order_rank(profile), index]
83
+ end
84
+ end
85
+
86
+ def order_rank(profile)
87
+ rank = @order.index(profile.provider)
88
+ rank || @order.size
89
+ end
90
+
91
+ # Fallback is never implied: only explicitly declared profiles that are
92
+ # ready and share the chosen profile's data-handling policy qualify.
93
+ def declared_fallbacks(chosen)
94
+ @fallback.select { |f| f.ready? && f != chosen && f.same_data_handling?(chosen) }
95
+ end
96
+
97
+ def explain(chosen, excluded, fallbacks, required_capabilities, task_class)
98
+ parts = []
99
+ if chosen
100
+ because = ["it is commissioned ready"]
101
+ because << "offers #{required_capabilities.join(", ")}" if required_capabilities.any?
102
+ parts << "Selected #{chosen.provider} (#{@strategy}) because #{because.join(" and ")}."
103
+ parts << "Task class: #{task_class}." if task_class
104
+ parts << if fallbacks.any?
105
+ "#{fallbacks.map(&:provider).join(", ")} configured as fallback (same data-handling policy)."
106
+ else
107
+ "No fallback is configured; fallback is never implied."
108
+ end
109
+ else
110
+ parts << if @strategy == :manual
111
+ "No agent was auto-selected: manual routing defers the choice to you."
112
+ else
113
+ "No eligible agent profile was found."
114
+ end
115
+ parts << "Observation and baseline remain automatic."
116
+ end
117
+ excluded.each { |e| parts << "#{e[:provider]} was excluded because #{e[:reason]}." }
118
+ parts.join(" ")
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Reanimator
6
+ # An immutable record of one stewardship attempt (specification section 17 run
7
+ # ledger). A run captures its trigger, objective, policy version, agent, the
8
+ # access it used, its evidence, and its outcome - the auditable story of what
9
+ # Reanimator did and why. Outcomes are drawn from the section 17 vocabulary:
10
+ # a healthy no-op, a report, a draft PR, an escalation, or a safe stop.
11
+ class Run
12
+ OUTCOMES = %i[no_op report draft_pr escalation safe_stop].freeze
13
+
14
+ attr_reader :outcome, :repository, :policy_version, :trigger, :objective,
15
+ :provider, :access, :pre_state, :post_state, :cost_usd, :proposed_change, :reason, :generated_at
16
+
17
+ def initialize(outcome:, repository:, policy_version:, trigger:, objective: nil, provider: nil,
18
+ access: {}, pre_state: nil, post_state: nil, cost_usd: 0.0, proposed_change: nil, reason: nil, now: nil)
19
+ @outcome = outcome.to_sym
20
+ @repository = repository
21
+ @policy_version = policy_version
22
+ @trigger = trigger
23
+ @objective = objective
24
+ @provider = provider
25
+ @access = access
26
+ @pre_state = pre_state
27
+ @post_state = post_state
28
+ @cost_usd = cost_usd
29
+ @proposed_change = proposed_change
30
+ @reason = reason
31
+ @generated_at = now || Time.now
32
+ raise UnknownError, "Unknown run outcome #{outcome.inspect}." unless OUTCOMES.include?(@outcome)
33
+ end
34
+
35
+ # The whole slice's guarantee: repair never writes to the repository. A
36
+ # proposed draft PR is an artifact, not a submission.
37
+ def wrote_to_repository? = false
38
+
39
+ def to_h
40
+ {
41
+ outcome: outcome, repository: repository.full_name, policy_version: policy_version,
42
+ trigger: trigger, objective: objective&.id, provider: provider, access: access,
43
+ pre_state: pre_state, post_state: post_state, cost_usd: cost_usd,
44
+ reason: reason, proposed_change: proposed_change&.to_h, generated_at: generated_at.iso8601
45
+ }.compact
46
+ end
47
+ end
48
+ end