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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A timestamped, read-only measurement of a repository's current obligations
5
+ # and pre-existing failures (specification sections 02 and 14). A baseline is
6
+ # the organism measured before any operation: it separates verified claims,
7
+ # failed claims, missing evidence, and skipped boundaries, and it carries the
8
+ # policy version it was measured against so later runs can detect genuine
9
+ # improvement or regression rather than noise.
10
+ class Baseline
11
+ attr_reader :contract, :results, :state, :generated_at, :executed
12
+
13
+ def initialize(contract:, results:, state:, generated_at:, executed:)
14
+ @contract = contract
15
+ @results = results
16
+ @state = state
17
+ @generated_at = generated_at
18
+ @executed = executed
19
+ end
20
+
21
+ # @return [Boolean] whether checks actually executed, versus were planned
22
+ def executed? = @executed
23
+
24
+ def repository = contract.repository
25
+
26
+ def policy_version = contract.policy_version
27
+
28
+ # Counts of each result status, e.g. {verified: 3, missing_evidence: 1}.
29
+ # @return [Hash{Symbol=>Integer}]
30
+ def tally
31
+ results.each_with_object(Hash.new(0)) { |r, h| h[r.status] += 1 }
32
+ end
33
+
34
+ def results_with_status(status)
35
+ results.select { |r| r.status == status.to_sym }
36
+ end
37
+
38
+ # Required criteria that failed - the actionable, blocking ones.
39
+ def failing_required
40
+ results.select { |r| r.required? && r.failed? }
41
+ end
42
+
43
+ # Required criteria that are neither verified nor accepted risk - the ones
44
+ # that genuinely still need attention (failed, missing, planned, skipped).
45
+ def unresolved_required
46
+ results.select { |r| r.required? && !r.verified? && !r.accepted_risk? }
47
+ end
48
+
49
+ # Required criteria whose failure has been explicitly waived - accepted
50
+ # risk, kept separate from verified vitality (section 15).
51
+ def accepted_risks
52
+ results.select(&:accepted_risk?)
53
+ end
54
+
55
+ # The first required criterion that failed, or nil - the natural repair
56
+ # target. Callers ask the baseline rather than digging through #results.
57
+ def first_failing_required_criterion
58
+ failing_required.first&.criterion
59
+ end
60
+
61
+ # The result for a criterion id, or nil.
62
+ def result_for(criterion_id)
63
+ results.find { |r| r.criterion.id == criterion_id }
64
+ end
65
+
66
+ # Ids of required criteria that are verified - the set a later run must not
67
+ # regress from.
68
+ def verified_required_ids
69
+ results.select { |r| r.required? && r.verified? }.map { |r| r.criterion.id }
70
+ end
71
+
72
+ # The required criteria the Verified Vitality Rate is measured over.
73
+ # Accepted risk is deliberately excluded so it is reported separately
74
+ # rather than counted as a failure (section 15).
75
+ def rated_required
76
+ results.select { |r| r.required? && !r.accepted_risk? }
77
+ end
78
+
79
+ # Verified Vitality Rate for active (required) obligations - the headline
80
+ # dashboard metric from section 15, kept separate from accepted risk.
81
+ # @return [Float] 0.0..1.0
82
+ def verified_vitality_rate
83
+ rated = rated_required
84
+ return 1.0 if rated.empty?
85
+
86
+ rated.count(&:verified?).to_f / rated.size
87
+ end
88
+
89
+ # A repository is proven when it is Alive with no failed or unproven
90
+ # required criteria.
91
+ def proven? = state.alive?
92
+
93
+ def to_h
94
+ {
95
+ repository: repository.full_name,
96
+ state: state.to_sym,
97
+ executed: executed?,
98
+ generated_at: generated_at.iso8601,
99
+ policy_version: policy_version,
100
+ verified_vitality_rate: verified_vitality_rate.round(3),
101
+ tally: tally,
102
+ results: results.map(&:to_h)
103
+ }
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Reanimator
6
+ # Executes a baseline for one repository against its effective contract
7
+ # (specification section 14). Every criterion is graded by an isolated
8
+ # evaluator; the runner itself never edits the repository. Execution is
9
+ # opt-in and doubly gated - by the per-run `execute` flag and by the global
10
+ # `allow_execution` configuration - so the default really is read-only, and
11
+ # even when executing, native (inspection-only) evaluators still run.
12
+ #
13
+ # When commands do execute, they run in a *disposable copy* of the repository,
14
+ # never the original working tree (section 14: "all commands run in a
15
+ # disposable workspace" and "the baseline is read-only with respect to
16
+ # repository source"). Inspection-only evaluators still read the original.
17
+ class BaselineRunner
18
+ attr_reader :contract, :execute, :timeout
19
+
20
+ def initialize(contract, execute: false, timeout: nil, now: nil)
21
+ @contract = contract
22
+ @execute = execute
23
+ @timeout = timeout || Reanimator.configuration.command_timeout
24
+ @now = now
25
+ end
26
+
27
+ # @return [Baseline]
28
+ def run
29
+ if execution_allowed?
30
+ # Commands run inside a throwaway copy so builds/tests cannot mutate the
31
+ # real checkout (no stray .gem, coverage/, or tmp/ artifacts).
32
+ Isolation.copy(contract.repository.path) { |workspace| measure(workspace) }
33
+ else
34
+ measure(contract.repository.path)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def measure(execution_root)
41
+ results = contract.criteria.map do |criterion|
42
+ Evaluators.for(
43
+ criterion, contract: contract, execute: execution_allowed?,
44
+ timeout: timeout, execution_root: execution_root
45
+ ).call
46
+ end
47
+ results = apply_waivers(results)
48
+
49
+ Baseline.new(
50
+ contract: contract,
51
+ results: results,
52
+ state: ProofOfLife.assess(contract, results),
53
+ generated_at: now,
54
+ executed: execution_allowed?
55
+ )
56
+ end
57
+
58
+ # Re-tags an unresolved required criterion as accepted risk when an active
59
+ # waiver covers it (section 09). The waiver does not make it verified - it
60
+ # records the failure as explicitly accepted, so it stops blocking the
61
+ # repository state while staying visible.
62
+ def apply_waivers(results)
63
+ results.map do |result|
64
+ next result unless result.waivable?
65
+
66
+ waiver = contract.waiver_for(result.criterion.id)
67
+ waiver ? result.accepted_via(waiver) : result
68
+ end
69
+ end
70
+
71
+ # Execution requires both the per-run request and the global allowance.
72
+ # Native evaluators ignore this flag and always inspect.
73
+ def execution_allowed?
74
+ execute && Reanimator.configuration.allow_execution
75
+ end
76
+
77
+ def now
78
+ @now || Time.now
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Reanimator
7
+ # Runs a single shell command under a hard wall-clock bound and captures its
8
+ # combined output (specification section 12: bounded attempts, time, and
9
+ # paths). Baselines are bounded by construction - a check that hangs is a
10
+ # failed check, never an unbounded wait. The command runs through `sh -c` so
11
+ # discovered commands with globs (for example `gem build *.gemspec`) behave as
12
+ # they do in CI.
13
+ #
14
+ # The child is started as its own process-group leader and, on timeout, the
15
+ # *entire group* is signalled - not just the `sh` parent - so a spawned
16
+ # grandchild (the actual test or build process) cannot outlive the bound.
17
+ module BoundedCommand
18
+ # Cap on captured output so a runaway producer cannot exhaust memory before
19
+ # the time bound fires. The pipe is still drained past the cap so the child
20
+ # never blocks on a full pipe; only accumulation stops.
21
+ MAX_OUTPUT_BYTES = 1 << 20 # 1 MiB
22
+ READ_CHUNK = 16_384
23
+
24
+ Result = Struct.new(:success, :output, :duration, :timed_out) do
25
+ def success? = success
26
+ def timed_out? = timed_out
27
+ end
28
+
29
+ module_function
30
+
31
+ # @param command [String]
32
+ # @param chdir [String]
33
+ # @param timeout [Integer] seconds
34
+ # @return [Result]
35
+ def run(command, chdir:, timeout:)
36
+ started = clock
37
+ stdin, out_and_err, wait_thread = Open3.popen2e("sh", "-c", command, chdir: chdir, pgroup: true)
38
+ stdin.close
39
+ out_and_err.binmode
40
+
41
+ output = +"".b
42
+ truncated = false
43
+ timed_out = false
44
+ begin
45
+ Timeout.timeout(timeout) do
46
+ while (chunk = out_and_err.read(READ_CHUNK))
47
+ if output.bytesize < MAX_OUTPUT_BYTES
48
+ output << chunk
49
+ else
50
+ truncated = true # keep draining, stop accumulating
51
+ end
52
+ end
53
+ end
54
+ wait_thread.join
55
+ rescue Timeout::Error
56
+ timed_out = true
57
+ kill_group(wait_thread.pid)
58
+ wait_thread.join # reap the terminated leader
59
+ ensure
60
+ out_and_err.close
61
+ end
62
+
63
+ output << "\n...[output truncated]" if truncated
64
+
65
+ Result.new(
66
+ success: !timed_out && wait_thread.value&.success?,
67
+ # Command output is untrusted bytes; present it as scrubbed UTF-8 so
68
+ # callers' string operations never raise.
69
+ output: output.force_encoding("UTF-8").scrub,
70
+ duration: (clock - started).round(2),
71
+ timed_out: timed_out
72
+ )
73
+ end
74
+
75
+ # Signals the whole process group led by pid (the negative-pid form), so
76
+ # every process the command spawned is terminated together.
77
+ def kill_group(pid)
78
+ Process.kill("TERM", -pid)
79
+ Process.kill("KILL", -pid)
80
+ rescue Errno::ESRCH, Errno::EPERM
81
+ # Already gone; nothing to clean up.
82
+ end
83
+
84
+ # Monotonic clock so a system time change cannot skew a duration.
85
+ def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
+ end
87
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Assigns a repository to a triage cohort and a provisional lifecycle intent
5
+ # from read-only evidence (specification sections 04 and 05). The classifier
6
+ # is deliberately conservative: it flags ambiguity rather than assuming
7
+ # maintenance, and it never treats its own suggestion as final - intent must
8
+ # be confirmed before activation.
9
+ class Classifier
10
+ # Gems that imply a hardware- or credential-constrained environment.
11
+ HARDWARE_MARKERS = %w[rpi_gpio pi_piper serialport dino artoo denko wiringpi].freeze
12
+ RECENT_DAYS = 120
13
+ STALE_DAYS = 365
14
+ EXPERIMENT_COMMITS = 15
15
+
16
+ # The result of classification: the cohort, the intent it suggests, how
17
+ # confident that suggestion is, and why.
18
+ Classification = Struct.new(:cohort, :suggested_intent, :confidence, :reason) do
19
+ def recommended_for_onboarding? = cohort.recommended_for_onboarding?
20
+ end
21
+
22
+ attr_reader :repository, :discovery
23
+
24
+ def initialize(repository, discovery: nil)
25
+ @repository = repository
26
+ @discovery = discovery
27
+ end
28
+
29
+ # @return [Classification]
30
+ def classify
31
+ cohort_key, confidence, reason = decide
32
+ cohort = Cohort.fetch(cohort_key)
33
+ Classification.new(
34
+ cohort: cohort,
35
+ suggested_intent: cohort.default_intent,
36
+ confidence: Confidence[confidence],
37
+ reason: reason
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def decide
44
+ metadata = repository.metadata
45
+
46
+ if repository.fork? || discovery&.fact?(:fork_of)
47
+ [:fork_observation, :high, "The repository is a fork; defaulting to upstream observation."]
48
+ elsif hardware?
49
+ [:constrained, :medium, "Hardware/credential-dependent gems detected; a constrained profile is safer."]
50
+ elsif metadata.archived?
51
+ [:archived, :high, "The repository is archived."]
52
+ elsif readme_only?
53
+ [:readme_only, :medium, "No recognized source language; looks like a concept or presentation."]
54
+ elsif stale?
55
+ [:old_unarchived, :medium, "No activity for over a year; intent is ambiguous rather than clearly maintained."]
56
+ elsif experiment?
57
+ [:recent_experiment, :medium, "Recent, low-commit repository; likely still an experiment."]
58
+ else
59
+ [:active_original, :high, "Active original repository with recognizable structure."]
60
+ end
61
+ end
62
+
63
+ def hardware?
64
+ body = repository.read("Gemfile").to_s + repository.read("Gemfile.lock").to_s
65
+ HARDWARE_MARKERS.any? { |marker| body.include?(marker) } ||
66
+ HARDWARE_MARKERS.any? { |marker| repository.name.include?(marker) }
67
+ end
68
+
69
+ def readme_only?
70
+ repository.metadata.primary_language.nil? &&
71
+ !repository.any?("*.gemspec") &&
72
+ !repository.exist?("package.json") &&
73
+ repository.any?("README*")
74
+ end
75
+
76
+ def stale?
77
+ days = repository.metadata.days_since_activity
78
+ days && days > STALE_DAYS
79
+ end
80
+
81
+ def experiment?
82
+ days = repository.metadata.days_since_activity
83
+ count = repository.metadata.commit_count
84
+ return false unless days && count
85
+
86
+ days <= RECENT_DAYS && count < EXPERIMENT_COMMITS
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+
6
+ module Reanimator
7
+ # The interactive surface. Every command here is read-only by default and
8
+ # maps to one onboarding stage from section 03: inventory (classify),
9
+ # discover, contract (review), and baseline (measure). Nothing in this CLI
10
+ # writes to a repository or connects an agent - it exists to make the
11
+ # repository's definition of life visible, executable, and trustworthy.
12
+ class CLI < Thor
13
+ def self.exit_on_failure? = true
14
+
15
+ desc "version", "Print the Reanimator version"
16
+ def version
17
+ puts Reanimator::VERSION
18
+ end
19
+ map %w[--version -v] => :version
20
+
21
+ desc "inventory [PATH...]", "Classify repositories into lifecycle cohorts (read-only)"
22
+ long_desc <<~DESC
23
+ Stage 1 of onboarding: classify before acting. Groups each repository into
24
+ a suggested cohort and lifecycle intent, and recommends a small,
25
+ high-confidence set for onboarding - never all of them.
26
+ DESC
27
+ def inventory(*paths)
28
+ guard do
29
+ paths = [Dir.pwd] if paths.empty?
30
+ puts Reports::Inventory.new(Reanimator.inventory(paths)).render
31
+ end
32
+ end
33
+
34
+ desc "discover [PATH]", "Show discovered facts and their provenance (read-only)"
35
+ def discover(path = Dir.pwd)
36
+ guard { puts Reports::Discovery.new(Reanimator.discover(path)).render }
37
+ end
38
+
39
+ desc "contract [PATH]", "Show or export the effective vitality contract"
40
+ option :intent, desc: "Override the inferred lifecycle intent (e.g. maintain, preserve)"
41
+ option :export, type: :boolean, default: false, desc: "Print a minimal .reanimator.yml instead of the review"
42
+ def contract(path = Dir.pwd)
43
+ guard do
44
+ contract = Reanimator.contract(path, intent: options[:intent]&.to_sym)
45
+ if options[:export]
46
+ puts ContractFile.dump(contract.to_minimal_hash)
47
+ else
48
+ puts Reports::Contract.new(contract).render
49
+ end
50
+ end
51
+ end
52
+
53
+ desc "baseline [PATH]", "Measure a read-only proof-of-life baseline"
54
+ long_desc <<~DESC
55
+ Runs the effective contract's acceptance criteria and reports a
56
+ proof-of-life state. Native checks (integrity, documentation, support
57
+ matrix) always run; command and boot checks only plan unless --execute is
58
+ given, and even then execution is bounded and never edits source.
59
+ DESC
60
+ option :execute, type: :boolean, default: false, desc: "Actually run discovered checks (bounded, read-only)"
61
+ option :intent, desc: "Override the inferred lifecycle intent"
62
+ option :format, default: "text", enum: %w[text check json], desc: "Output format"
63
+ def baseline(path = Dir.pwd)
64
+ guard do
65
+ baseline = Reanimator.baseline(path, execute: options[:execute], intent: options[:intent]&.to_sym)
66
+ puts render_baseline(baseline, options[:format])
67
+ end
68
+ end
69
+
70
+ desc "portfolio [PATH...]", "Baseline several repositories into one report (Appendix A)"
71
+ option :execute, type: :boolean, default: false, desc: "Actually run discovered checks (bounded, read-only)"
72
+ option :name, default: "portfolio", desc: "Portfolio name shown in the report header"
73
+ def portfolio(*paths)
74
+ guard do
75
+ paths = [Dir.pwd] if paths.empty?
76
+ baselines = paths.filter_map do |path|
77
+ Reanimator.baseline(path, execute: options[:execute])
78
+ rescue RepositoryError => e
79
+ warn "skipping #{path}: #{e.message}"
80
+ nil
81
+ end
82
+ puts Reports::PortfolioBaseline.new(baselines, name: options[:name]).render
83
+ end
84
+ end
85
+
86
+ desc "agents", "List coding-agent providers and whether each is available"
87
+ def agents
88
+ guard { puts Reports::Agents.new(Reanimator.adapters).render }
89
+ end
90
+
91
+ desc "commission", "Prove a coding agent is ready via a synthetic repair (no repository writes)"
92
+ long_desc <<~DESC
93
+ Stage 5 of onboarding. Runs the commissioning test from section 08: a
94
+ synthetic repair against a disposable fixture, graded independently by
95
+ Reanimator's own evaluators. The default local agent always runs; hosted
96
+ providers report themselves unavailable until connected.
97
+ DESC
98
+ option :provider, default: "local", desc: "Provider to commission (local, codex, claude, amp)"
99
+ def commission
100
+ guard do
101
+ report = Reanimator.commission(provider: options[:provider].to_sym)
102
+ puts Reports::Commissioning.new(report).render
103
+ exit 1 unless report.ready? || report.readiness == :unavailable
104
+ end
105
+ end
106
+
107
+ desc "repair [PATH]", "Attempt a bounded repair and propose a draft PR (no repository writes)"
108
+ long_desc <<~DESC
109
+ Stage toward bounded stewardship (section 11 repair rung). Finds the first
110
+ failing required criterion, checks the permission model, routes to a ready
111
+ agent, and lets it propose a patch in an isolated workspace. The patch is
112
+ applied only to a separate clean copy and re-verified independently; on
113
+ success a draft PR is materialized but NOT submitted. Nothing is written to
114
+ the repository.
115
+ DESC
116
+ option :provider, default: "local", desc: "Provider to use (local, codex, claude, amp)"
117
+ option :task_class, default: "patch_dependency", desc: "Task class governing autonomy"
118
+ def repair(path = Dir.pwd)
119
+ guard do
120
+ run = Reanimator.repair(path, provider: options[:provider].to_sym, task_class: options[:task_class].to_sym)
121
+ puts Reports::Run.new(run).render
122
+ end
123
+ end
124
+
125
+ desc "drift [PATH...]", "Record contract snapshots, or detect drift against them"
126
+ long_desc <<~DESC
127
+ Section 06. Configuration drift is the difference between the contract a
128
+ repository resolved to when it was last recorded and the one its evidence
129
+ resolves to now. Differences are separated into policy changes - less is
130
+ being proven than before, so a human should look - and benign ones, such
131
+ as a renamed test command. The exit status is non-zero only for a policy
132
+ change, so CI can gate on it.
133
+
134
+ A repository's snapshot normally lives in the repository, committed as
135
+ .reanimator-snapshot.json, so a policy change arrives as a diff in a
136
+ pull request rather than as state hidden in a tool; --record writes it
137
+ there. For repositories you cannot commit a file into, --snapshots DIR
138
+ keeps them side by side, keyed by repository. Pass several paths for a
139
+ portfolio triage list. With nothing recorded yet, shows what the
140
+ repository is currently required to prove. The only files Reanimator
141
+ ever writes are those snapshots, and only when asked.
142
+ DESC
143
+ option :since, desc: "Path to a recorded snapshot to compare against (one repository)"
144
+ option :save, desc: "Path to write the current snapshot to (one repository)"
145
+ option :snapshots, desc: "Directory of recorded snapshots, keyed by repository"
146
+ option :record, type: :boolean, default: false, desc: "Record each current snapshot where it belongs"
147
+ option :intent, desc: "Override the inferred lifecycle intent"
148
+ option :format, default: "text", enum: %w[text json], desc: "Output format"
149
+ def drift(*paths)
150
+ guard do
151
+ paths = [Dir.pwd] if paths.empty?
152
+ (paths.size > 1) ? portfolio_drift(paths) : single_drift(paths.first)
153
+ end
154
+ end
155
+
156
+ desc "permissions [PATH]", "Show resolved autonomy per task class for a repository"
157
+ option :intent, desc: "Override the inferred lifecycle intent"
158
+ def permissions(path = Dir.pwd)
159
+ guard do
160
+ contract = Reanimator.contract(path, intent: options[:intent]&.to_sym)
161
+ puts Reports::Permissions.new(contract).render
162
+ end
163
+ end
164
+
165
+ desc "families [PATH...]", "Show the compatibility graph: repositories that must stay alive together"
166
+ long_desc <<~DESC
167
+ Section 10. Groups related repositories into families from their structure -
168
+ a project and its examples, deployment samples, docs, or plugins - and shows
169
+ the typed edge between each pair plus the gate that proves it.
170
+ DESC
171
+ def families(*paths)
172
+ guard do
173
+ paths = [Dir.pwd] if paths.empty?
174
+ puts Reports::Families.new(Reanimator.families(paths)).render
175
+ end
176
+ end
177
+
178
+ desc "gates [PATH...]", "Evaluate cross-repository family gates (read-only)"
179
+ long_desc <<~DESC
180
+ Proves each family edge by running the downstream member's own check. A
181
+ candidate change upstream can trigger these downstream checks without
182
+ opening anything downstream: with --execute each check runs bounded, in a
183
+ disposable copy, never in the downstream working tree. A failure names the
184
+ exact edge that broke.
185
+ DESC
186
+ option :family, desc: "Limit to one family id"
187
+ option :execute, type: :boolean, default: false, desc: "Actually run downstream checks (bounded, read-only)"
188
+ def gates(*paths)
189
+ guard do
190
+ paths = [Dir.pwd] if paths.empty?
191
+ evaluated = Reanimator.family_gates(paths, family: options[:family], execute: options[:execute])
192
+ if evaluated.empty?
193
+ puts "No repository families detected."
194
+ else
195
+ puts evaluated.map { |family, results| Reports::FamilyGates.new(family, results).render }.join("\n\n")
196
+ end
197
+ end
198
+ end
199
+
200
+ private
201
+
202
+ # Several paths: a triage list across the portfolio, not one verdict.
203
+ def portfolio_drift(paths)
204
+ %w[since save].each do |single|
205
+ next unless options[single]
206
+
207
+ raise ContractError,
208
+ "--#{single} names one file, so it cannot be used with several repositories; use --snapshots DIR."
209
+ end
210
+
211
+ drift = Reanimator.portfolio_drift(
212
+ paths, snapshots: options[:snapshots], record: options[:record], intent: options[:intent]&.to_sym
213
+ )
214
+ puts((options[:format] == "json") ? JSON.pretty_generate(drift.to_h) : Reports::PortfolioDrift.new(drift).render)
215
+ exit 1 if drift.requires_review?
216
+ end
217
+
218
+ def single_drift(path)
219
+ store = SnapshotStore.new(directory: options[:snapshots])
220
+ repository = Repository.at(path)
221
+ snapshot = Reanimator.snapshot(path, intent: options[:intent]&.to_sym)
222
+ previous = options[:since] ? ContractSnapshot.load(options[:since]) : store.load(repository)
223
+ report = previous && DriftDetector.between(previous, snapshot)
224
+
225
+ recorded_to = record_snapshot(snapshot, repository, store)
226
+ puts render_drift(report, snapshot, recorded_to, store)
227
+ exit 1 if report&.requires_review?
228
+ end
229
+
230
+ # A snapshot is written only where the maintainer asked for it: --save names
231
+ # an exact file, --record puts it where the store says it belongs. Nothing
232
+ # is written without one of them - detecting drift is not licence to start
233
+ # writing files.
234
+ def record_snapshot(snapshot, repository, store)
235
+ return unless options[:save] || options[:record]
236
+
237
+ written = options[:save] ? save_snapshot(snapshot, options[:save]) : store.save(snapshot, repository)
238
+ warn "Recorded contract snapshot for #{snapshot.repository} → #{written}"
239
+ written
240
+ end
241
+
242
+ def render_drift(report, snapshot, recorded_to, store)
243
+ return JSON.pretty_generate(report ? report.to_h : snapshot.to_h) if options[:format] == "json"
244
+ return Reports::Drift.new(report, record_with: store.record_command(options[:since])).render if report
245
+
246
+ Reports::Snapshot.new(snapshot, saved_to: recorded_to, record_with: store.record_command).render
247
+ end
248
+
249
+ def save_snapshot(snapshot, destination)
250
+ File.write(destination, snapshot.to_json)
251
+ destination
252
+ rescue SystemCallError => e
253
+ raise ContractError, "Could not write the contract snapshot to #{destination}: #{e.message}"
254
+ end
255
+
256
+ def render_baseline(baseline, format)
257
+ case format
258
+ when "check" then Reports::GitHubCheck.new(baseline).render
259
+ when "json" then JSON.pretty_generate(baseline.to_h)
260
+ else Reports::Baseline.new(baseline).render
261
+ end
262
+ end
263
+
264
+ # Turns library errors into a clean, single-line CLI failure instead of a
265
+ # backtrace - the tool is for maintainers, not for reading stack traces.
266
+ def guard
267
+ yield
268
+ rescue Reanimator::Error => e
269
+ warn "reanimator: #{e.message}"
270
+ exit 1
271
+ end
272
+ end
273
+ end