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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A policy-constrained execution configuration for one provider, model,
5
+ # sandbox, tool set, and data-handling posture (specification section 02 and
6
+ # section 08). A profile is what commissioning proves *ready*, *degraded*,
7
+ # *incompatible*, or *unavailable* - the readiness verdict is set from a
8
+ # commissioning report, never assumed.
9
+ class AgentProfile
10
+ READINESS = %i[unknown ready degraded incompatible unavailable].freeze
11
+
12
+ attr_reader :connection, :model, :sandbox, :data_handling, :capabilities
13
+ attr_accessor :readiness
14
+
15
+ def initialize(connection:, model: nil, sandbox: :ephemeral, data_handling: nil,
16
+ capabilities: [], readiness: :unknown)
17
+ @connection = connection
18
+ @model = model || connection.models.first
19
+ @sandbox = sandbox
20
+ @data_handling = data_handling || connection.code_egress
21
+ @capabilities = capabilities
22
+ @readiness = readiness.to_sym
23
+ raise UnknownError, "Unknown readiness #{readiness.inspect}." unless READINESS.include?(@readiness)
24
+ end
25
+
26
+ # Builds a profile straight from an adapter, seeding its capabilities.
27
+ def self.from_adapter(adapter, **overrides)
28
+ new(connection: adapter.connection, capabilities: adapter.capabilities, **overrides)
29
+ end
30
+
31
+ def supports?(capability) = capabilities.include?(capability.to_sym)
32
+
33
+ def provider = connection.provider
34
+
35
+ def ready? = readiness == :ready
36
+
37
+ # Two profiles share a data-handling policy when their egress posture
38
+ # matches - the precondition for any context transfer during fallback
39
+ # (section 08 fallback rules).
40
+ def same_data_handling?(other)
41
+ data_handling == other.data_handling
42
+ end
43
+
44
+ def to_h
45
+ {
46
+ provider: provider, model: model, sandbox: sandbox,
47
+ data_handling: data_handling, readiness: readiness
48
+ }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ # The contract every coding-agent provider implements (specification
6
+ # section 08). Keeping this surface tiny is the whole point: swapping
7
+ # providers must never require redefining acceptance criteria, so an
8
+ # adapter only ever *enumerates capabilities* and *proposes a repair* - it
9
+ # does not grade, merge, or decide policy.
10
+ class Adapter
11
+ # @return [AgentConnection]
12
+ attr_reader :connection
13
+
14
+ def initialize(connection)
15
+ @connection = connection
16
+ end
17
+
18
+ # @return [Symbol] provider name
19
+ def provider = connection.provider
20
+
21
+ # @return [Boolean] whether the adapter can actually run right now
22
+ # (credentials present and integration available)
23
+ def available?
24
+ raise NotImplementedError, "#{self.class} must implement #available?"
25
+ end
26
+
27
+ # @return [Array<Symbol>] the {Capability} values this adapter offers
28
+ def capabilities
29
+ raise NotImplementedError, "#{self.class} must implement #capabilities"
30
+ end
31
+
32
+ # @param capability [Symbol]
33
+ def supports?(capability) = capabilities.include?(capability.to_sym)
34
+
35
+ # The provider's code-egress posture, inherited from the connection.
36
+ def code_egress = connection.code_egress
37
+
38
+ # Attempt a repair. Returns an untrusted {Result}; never applies it.
39
+ # @param task [RepairTask]
40
+ # @return [Result]
41
+ def repair(task)
42
+ raise NotImplementedError, "#{self.class} must implement #repair"
43
+ end
44
+
45
+ # @return [String, nil] why the adapter is unavailable, for reporting
46
+ def unavailable_reason = nil
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ # The capabilities an adapter can declare and a task can require
6
+ # (specification section 08 commissioning step 1: "enumerate allowed
7
+ # capabilities"). Tools are capability-scoped and deny-by-default (section
8
+ # 12), so a task that needs a capability the profile does not offer is
9
+ # routed elsewhere or refused - never silently attempted.
10
+ module Capability
11
+ # Produce a structured remediation plan.
12
+ PLAN = :plan
13
+ # Produce a patch (a set of file edits).
14
+ PATCH = :patch
15
+ # Edit repository files in a workspace.
16
+ REPOSITORY_EDIT = :repository_edit
17
+ # Execute checks in a sandbox.
18
+ SANDBOX_EXEC = :sandbox_exec
19
+ # Reach the network (subject to egress policy).
20
+ NETWORK = :network
21
+
22
+ ALL = [PLAN, PATCH, REPOSITORY_EDIT, SANDBOX_EXEC, NETWORK].freeze
23
+
24
+ module_function
25
+
26
+ def valid?(capability) = ALL.include?(capability.to_sym)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ # The immutable envelope a repair must stay inside: which paths it may edit,
6
+ # which are protected, what it may spend, and where code may travel.
7
+ #
8
+ # These four values always travel together, so they are one object rather
9
+ # than a hash handed from caller to caller - and the decision that depends
10
+ # on them ("may I edit this path?") lives here, with the data, instead of
11
+ # being re-derived by whoever happens to hold the hash.
12
+ class Constraints
13
+ attr_reader :allowed_paths, :protected_paths, :max_cost_usd, :code_egress
14
+
15
+ def initialize(allowed_paths: [], protected_paths: [], max_cost_usd: nil, code_egress: nil)
16
+ @allowed_paths = Array(allowed_paths)
17
+ @protected_paths = Array(protected_paths)
18
+ @max_cost_usd = max_cost_usd
19
+ @code_egress = code_egress
20
+ freeze
21
+ end
22
+
23
+ # Accepts either a Constraints or the plain Hash shape, so callers may
24
+ # pass whichever reads better at the call site.
25
+ #
26
+ # @param value [Constraints, Hash, nil]
27
+ # @return [Constraints]
28
+ def self.from(value)
29
+ return value if value.is_a?(self)
30
+
31
+ hash = value || {}
32
+ new(
33
+ allowed_paths: hash[:allowed_paths] || [],
34
+ protected_paths: hash[:protected_paths] || [],
35
+ max_cost_usd: hash[:max_cost_usd],
36
+ code_egress: hash[:code_egress]
37
+ )
38
+ end
39
+
40
+ # Whether a repository-relative path may be edited: never a protected
41
+ # path, and inside the allowed set whenever one is declared. Matching is
42
+ # depth-correct, so a protected `dir/**` cannot be evaded by nesting.
43
+ def may_edit?(path)
44
+ return false if SafetyFloor.protects?(path, protected_paths)
45
+
46
+ allowed_paths.empty? || allowed_paths.any? { |glob| SafetyFloor.matches?(glob, path) }
47
+ end
48
+
49
+ def to_h
50
+ {
51
+ allowed_paths: allowed_paths, protected_paths: protected_paths,
52
+ max_cost_usd: max_cost_usd, code_egress: code_egress
53
+ }.compact
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ # A placeholder for hosted providers (Codex, Claude, Amp). It declares the
6
+ # capabilities such a provider would offer so routing and reports can reason
7
+ # about it, but it is honest that no hosted integration ships in this slice:
8
+ # it reports itself unavailable rather than pretending to run. A real
9
+ # adapter - potentially backed by the codenamev/agentic orchestrator -
10
+ # implements #repair by calling its model and returns the same {Result}.
11
+ class Hosted < Adapter
12
+ def available? = false
13
+
14
+ def capabilities
15
+ [Capability::PLAN, Capability::PATCH, Capability::REPOSITORY_EDIT,
16
+ Capability::SANDBOX_EXEC, Capability::NETWORK]
17
+ end
18
+
19
+ def credentialed?
20
+ ref = connection.credentials_ref
21
+ ref && !ENV[ref].to_s.empty?
22
+ end
23
+
24
+ def unavailable_reason
25
+ if credentialed?
26
+ "Credentials are present, but the #{provider} hosted adapter is not implemented in this version."
27
+ else
28
+ "No credentials found#{" (set #{connection.credentials_ref})" if connection.credentials_ref}; " \
29
+ "connect the #{provider} provider first."
30
+ end
31
+ end
32
+
33
+ def repair(_task)
34
+ raise Error, "The #{provider} adapter cannot run a repair: #{unavailable_reason}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ # The reference local agent: deterministic, no network, always available.
6
+ # It runs a list of rule-based {Strategies} and proposes the first patch
7
+ # that matches. Because it never reaches the network and keeps code on the
8
+ # user's runner, it satisfies the "local" security posture (section 12) and
9
+ # is the adapter commissioning can always prove end to end.
10
+ class Local < Adapter
11
+ DEFAULT_STRATEGIES = [Strategies::ArithmeticFix].freeze
12
+
13
+ def initialize(connection, strategies: nil)
14
+ super(connection)
15
+ @strategies = strategies || DEFAULT_STRATEGIES.map(&:new)
16
+ end
17
+
18
+ def available? = true
19
+
20
+ # Local execution never touches the network by design.
21
+ def capabilities
22
+ [Capability::PLAN, Capability::PATCH, Capability::REPOSITORY_EDIT, Capability::SANDBOX_EXEC]
23
+ end
24
+
25
+ def repair(task)
26
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ strategy = @strategies.find { |s| s.applies?(task) }
28
+
29
+ if strategy
30
+ plan, patch = strategy.call(task)
31
+ Result.new(plan: plan, patch: patch, cost_usd: 0.0,
32
+ latency_s: elapsed(started), tool_use: [Capability::REPOSITORY_EDIT, Capability::PATCH], network: [])
33
+ else
34
+ Result.new(plan: nil, patch: Patch.new, cost_usd: 0.0, latency_s: elapsed(started),
35
+ message: "No local strategy matched the objective #{task.objective.id.inspect}.")
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def elapsed(started)
42
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(3)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Reanimator
6
+ module Agents
7
+ # A proposed change expressed as data, not as a mutation the agent performs
8
+ # itself. Representing a repair as a set of file edits is what lets the
9
+ # harness - never the agent - apply it into an isolated evaluation
10
+ # workspace (specification section 09: evaluation runs separately from the
11
+ # agent workspace). An agent proposes a Patch; Reanimator decides whether
12
+ # and where to apply it.
13
+ class Patch
14
+ # A single file's new contents (or deletion).
15
+ Edit = Struct.new(:path, :contents, :delete) do
16
+ def delete? = delete
17
+ end
18
+
19
+ attr_reader :edits
20
+
21
+ def initialize(edits = [])
22
+ @edits = edits
23
+ end
24
+
25
+ def self.write(path, contents)
26
+ new([Edit.new(path: path, contents: contents, delete: false)])
27
+ end
28
+
29
+ def empty? = edits.empty?
30
+
31
+ def paths = edits.map(&:path)
32
+
33
+ # Applies the patch beneath a root directory. The harness calls this on a
34
+ # disposable copy; agents never call it. Refuses to escape the root.
35
+ #
36
+ # @param root [String]
37
+ # @raise [PolicyViolation] when an edit path escapes the root
38
+ def apply(root)
39
+ edits.each do |edit|
40
+ full = File.expand_path(File.join(root, edit.path))
41
+ unless full.start_with?(File.expand_path(root) + File::SEPARATOR)
42
+ raise PolicyViolation, "Patch edit #{edit.path.inspect} escapes the workspace root."
43
+ end
44
+
45
+ if edit.delete?
46
+ File.delete(full) if File.exist?(full)
47
+ else
48
+ FileUtils.mkdir_p(File.dirname(full))
49
+ File.write(full, edit.contents)
50
+ end
51
+ end
52
+ root
53
+ end
54
+
55
+ def to_h
56
+ {edits: edits.map { |e| {path: e.path, delete: e.delete?} }}
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ # The immutable objective handed to an agent for one repair (specification
6
+ # section 08 fallback rules and section 17 run ledger). Everything here is
7
+ # frozen: the agent receives the original task, the constraints it may not
8
+ # exceed, and *verified evidence* - never mutable conclusions it could talk
9
+ # itself out of. A fallback agent receives this very same object, not the
10
+ # previous agent's narrative.
11
+ class RepairTask
12
+ # @return [AcceptanceCriterion] the failing criterion to satisfy
13
+ attr_reader :objective
14
+ # @return [String] absolute path to the agent's isolated workspace copy
15
+ attr_reader :workspace
16
+ # @return [Constraints] the immutable envelope this repair must stay
17
+ # inside (allowed paths, protected paths, budget, code egress)
18
+ attr_reader :constraints
19
+ # @return [Baseline] the verified pre-repair evidence
20
+ attr_reader :evidence
21
+
22
+ def initialize(objective:, workspace:, evidence:, constraints: {})
23
+ @objective = objective
24
+ @workspace = workspace
25
+ @evidence = evidence
26
+ @constraints = Constraints.from(constraints)
27
+ freeze
28
+ end
29
+
30
+ # @return [String] a plain-language statement of the goal
31
+ def goal = objective.claim
32
+
33
+ # --- read-only workspace access for adapters -----------------------------
34
+
35
+ def read(relative)
36
+ full = File.join(workspace, relative)
37
+ File.read(full) if File.file?(full)
38
+ end
39
+
40
+ def glob(pattern)
41
+ Dir.glob(File.join(workspace, pattern))
42
+ .map { |absolute| absolute.delete_prefix("#{workspace}/") }
43
+ .sort
44
+ end
45
+
46
+ # Whether a path is inside the constraints' allowed set (and outside the
47
+ # protected set). Adapters should honor this; the harness enforces it.
48
+ def may_edit?(path) = constraints.may_edit?(path)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ # What an adapter returns from a repair attempt (specification section 08
6
+ # commissioning steps 3 and 5, section 17 run ledger). A result is an
7
+ # untrusted *proposal*: a structured plan, a patch expressed as data, and
8
+ # the observable cost/latency/tool-use/network the harness records. It is
9
+ # never a verdict - Reanimator computes pass/fail itself.
10
+ class Result
11
+ attr_reader :plan, :patch, :cost_usd, :latency_s, :tool_use, :network, :message
12
+
13
+ def initialize(plan: nil, patch: Patch.new, cost_usd: 0.0, latency_s: 0.0,
14
+ tool_use: [], network: [], message: nil)
15
+ @plan = plan
16
+ @patch = patch
17
+ @cost_usd = cost_usd
18
+ @latency_s = latency_s
19
+ @tool_use = tool_use
20
+ @network = network
21
+ @message = message
22
+ end
23
+
24
+ # A well-formed repair proposal has both a structured plan and a
25
+ # non-empty patch (section 08 step 3: "require a structured plan and
26
+ # patch").
27
+ def structured? = !plan.to_s.strip.empty? && !patch.empty?
28
+
29
+ def to_h
30
+ {
31
+ plan: plan,
32
+ patch: patch.to_h,
33
+ cost_usd: cost_usd,
34
+ latency_s: latency_s,
35
+ tool_use: tool_use,
36
+ network: network,
37
+ message: message
38
+ }.compact
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ module Strategies
6
+ # A narrow but genuine automated fixer: when an addition helper (a method
7
+ # named add/sum/plus) subtracts its operands, rewrite the subtraction as
8
+ # an addition. It is intentionally small - its job is to exercise the full
9
+ # commissioning pipeline (plan, patch, isolated apply, independent
10
+ # grading) with a real, deterministic repair rather than a stubbed answer.
11
+ class ArithmeticFix < Base
12
+ DEF = /def\s+(?:add|sum|plus)\b/
13
+ SUBTRACTION = /\b([a-z_]\w*)\s*-\s*([a-z_]\w*)\b/
14
+
15
+ def applies?(task) = !locate(task).nil?
16
+
17
+ def call(task)
18
+ path, _source, fixed = locate(task)
19
+ plan = <<~PLAN.strip
20
+ Objective: #{task.goal}
21
+ 1. Locate the addition helper that is subtracting its operands (#{path}).
22
+ 2. Replace the subtraction with an addition.
23
+ 3. Re-run the failing check to confirm the arithmetic is correct.
24
+ PLAN
25
+ [plan, Patch.write(path, fixed)]
26
+ end
27
+
28
+ private
29
+
30
+ # Finds the first editable Ruby file whose addition helper subtracts,
31
+ # returning [path, original, fixed] or nil.
32
+ def locate(task)
33
+ task.glob("**/*.rb").each do |relative|
34
+ next unless task.may_edit?(relative)
35
+
36
+ source = task.read(relative)
37
+ next unless source&.match?(DEF)
38
+
39
+ index = source.index(DEF)
40
+ after = source[index..]
41
+ next unless after.match?(SUBTRACTION)
42
+
43
+ fixed = source[0...index] + after.sub(SUBTRACTION, '\1 + \2')
44
+ return [relative, source, fixed]
45
+ end
46
+ nil
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Agents
5
+ # Repair strategies for the local, rule-based agent. A "local agent" is a
6
+ # first-class provider in the specification (section 01: "Support Codex,
7
+ # Claude, Amp, local agents, and future providers"). Each strategy is a
8
+ # deterministic, no-network fixer that inspects a workspace and, when it
9
+ # recognizes a defect, proposes a plan and a patch.
10
+ module Strategies
11
+ class Base
12
+ # @param task [RepairTask]
13
+ # @return [Boolean] whether this strategy recognizes the defect
14
+ def applies?(task)
15
+ raise NotImplementedError, "#{self.class} must implement #applies?"
16
+ end
17
+
18
+ # @param task [RepairTask]
19
+ # @return [Array(String, Patch)] a plan and a patch
20
+ def call(task)
21
+ raise NotImplementedError, "#{self.class} must implement #call"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Coding-agent adapters. The agent is a replaceable life-force adapter
5
+ # (specification section 08): the repository's definition of life is defined
6
+ # by its contract, never by whichever provider happens to perform a repair.
7
+ # Every provider - Codex, Claude, Amp, or a local rule-based agent - speaks
8
+ # the same {Agents::Adapter} contract, so profiles are portable and the
9
+ # acceptance criteria never change when the provider does.
10
+ #
11
+ # This module is the registry and factory. It ships one fully working adapter
12
+ # ({Agents::Local}, a deterministic no-network agent) and stubs for hosted
13
+ # providers that honestly report themselves unavailable until credentials and
14
+ # a real integration exist.
15
+ module Agents
16
+ module_function
17
+
18
+ # Built-in adapters keyed by provider name.
19
+ def registry
20
+ {
21
+ local: Local,
22
+ codex: Hosted,
23
+ claude: Hosted,
24
+ amp: Hosted
25
+ }
26
+ end
27
+
28
+ # Builds an adapter for a connection.
29
+ # @param connection [AgentConnection]
30
+ # @return [Agents::Adapter]
31
+ def for(connection)
32
+ klass = registry.fetch(connection.provider, Hosted)
33
+ klass.new(connection)
34
+ end
35
+
36
+ def providers = registry.keys
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # One rung of the autonomy ladder (specification section 11): trust grows one
5
+ # bounded capability at a time, from read-only observation to narrowly bounded
6
+ # merge authority. Each rung names the behavior it allows and the GitHub
7
+ # access it requires, so a permission grant can always be tied to the exact
8
+ # action it enables.
9
+ class AutonomyLevel
10
+ attr_reader :key, :rank, :behavior, :github_access
11
+
12
+ def initialize(key:, rank:, behavior:, github_access:)
13
+ @key = key
14
+ @rank = rank
15
+ @behavior = behavior
16
+ @github_access = github_access
17
+ freeze
18
+ end
19
+
20
+ def to_sym = key
21
+
22
+ # The ladder in ascending order of authority.
23
+ LADDER = [
24
+ [:observe, "Inventory, detect drift, and report.", "Metadata and read-only contents."],
25
+ [:diagnose, "Run sandboxed checks and produce plans.", "Read-only plus ephemeral execution."],
26
+ [:repair, "Create a bounded patch in an isolated workspace.", "No repository write required until submission."],
27
+ [:submit, "Create or update a branch and pull request.", "Contents and pull-request write for selected repositories."],
28
+ [:merge, "Merge explicitly eligible changes through branch rules.", "Merge capability only where GitHub and policy allow."],
29
+ [:release, "Publish packages or deploy.", "Not part of the initial product promise."]
30
+ ].each_with_index.each_with_object({}) do |((key, behavior, access), index), all|
31
+ all[key] = new(key: key, rank: index, behavior: behavior, github_access: access)
32
+ end.freeze
33
+
34
+ def self.fetch(key)
35
+ LADDER.fetch(key.to_sym) { raise UnknownError, "Unknown autonomy level #{key.inspect}." }
36
+ end
37
+
38
+ # @return [Array<Symbol>] the ladder keys in ascending authority
39
+ def self.keys = LADDER.keys
40
+ end
41
+ end