ace-coworker 0.6.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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/coworker/config.yml +6 -0
  3. data/.ace-defaults/coworker/roles/integrator.yml +15 -0
  4. data/.ace-defaults/coworker/roles/reviewer.yml +15 -0
  5. data/.ace-defaults/coworker/roles/spec.yml +14 -0
  6. data/.ace-defaults/coworker/roles/worker.yml +13 -0
  7. data/.ace-defaults/coworker/sequences/integrator.yml +20 -0
  8. data/.ace-defaults/coworker/sequences/reviewer.yml +20 -0
  9. data/.ace-defaults/coworker/sequences/spec.yml +16 -0
  10. data/.ace-defaults/coworker/sequences/worker.yml +21 -0
  11. data/.ace-defaults/nav/protocols/wfi-sources/ace-coworker.yml +19 -0
  12. data/CHANGELOG.md +255 -0
  13. data/README.md +98 -0
  14. data/Rakefile +13 -0
  15. data/docs/usage.md +564 -0
  16. data/exe/ace-coworker +16 -0
  17. data/handbook/workflow-instructions/coworker/integrator.wf.md +162 -0
  18. data/handbook/workflow-instructions/coworker/reviewer.wf.md +211 -0
  19. data/handbook/workflow-instructions/coworker/spec.wf.md +100 -0
  20. data/handbook/workflow-instructions/coworker/worker.wf.md +103 -0
  21. data/lib/ace/coworker/atoms/active_executor_rule.rb +71 -0
  22. data/lib/ace/coworker/atoms/brief_formatter.rb +33 -0
  23. data/lib/ace/coworker/atoms/config_schema.rb +121 -0
  24. data/lib/ace/coworker/atoms/integration_gate.rb +92 -0
  25. data/lib/ace/coworker/atoms/outcome_planner.rb +79 -0
  26. data/lib/ace/coworker/atoms/prepare_decision.rb +75 -0
  27. data/lib/ace/coworker/atoms/prompt_template.rb +28 -0
  28. data/lib/ace/coworker/atoms/report_record_formatter.rb +60 -0
  29. data/lib/ace/coworker/atoms/resume_brief_formatter.rb +84 -0
  30. data/lib/ace/coworker/atoms/resume_planner.rb +78 -0
  31. data/lib/ace/coworker/atoms/role_preset_schema.rb +91 -0
  32. data/lib/ace/coworker/atoms/round_record_schema.rb +62 -0
  33. data/lib/ace/coworker/atoms/round_rule.rb +105 -0
  34. data/lib/ace/coworker/cli/commands/commission.rb +100 -0
  35. data/lib/ace/coworker/cli/commands/gate.rb +91 -0
  36. data/lib/ace/coworker/cli/commands/prepare.rb +138 -0
  37. data/lib/ace/coworker/cli/commands/report.rb +79 -0
  38. data/lib/ace/coworker/cli/commands/resume.rb +57 -0
  39. data/lib/ace/coworker/cli/commands/roles.rb +60 -0
  40. data/lib/ace/coworker/cli/commands/rounds.rb +99 -0
  41. data/lib/ace/coworker/cli/commands/start.rb +39 -0
  42. data/lib/ace/coworker/cli/commands/status.rb +95 -0
  43. data/lib/ace/coworker/cli.rb +83 -0
  44. data/lib/ace/coworker/errors.rb +10 -0
  45. data/lib/ace/coworker/models/assignment_snapshot.rb +65 -0
  46. data/lib/ace/coworker/models/brief.rb +38 -0
  47. data/lib/ace/coworker/models/config.rb +54 -0
  48. data/lib/ace/coworker/models/outcome.rb +39 -0
  49. data/lib/ace/coworker/models/preparation.rb +67 -0
  50. data/lib/ace/coworker/models/resume_plan.rb +47 -0
  51. data/lib/ace/coworker/molecules/assignment_metadata.rb +57 -0
  52. data/lib/ace/coworker/molecules/assignment_probe.rb +57 -0
  53. data/lib/ace/coworker/molecules/config_loader.rb +119 -0
  54. data/lib/ace/coworker/molecules/git_state_probe.rb +63 -0
  55. data/lib/ace/coworker/molecules/hitl_reporter.rb +145 -0
  56. data/lib/ace/coworker/molecules/instruction_resolver.rb +82 -0
  57. data/lib/ace/coworker/molecules/project_root.rb +26 -0
  58. data/lib/ace/coworker/molecules/report_state_store.rb +53 -0
  59. data/lib/ace/coworker/molecules/review_round_store.rb +115 -0
  60. data/lib/ace/coworker/molecules/role_preset_loader.rb +95 -0
  61. data/lib/ace/coworker/molecules/role_sequence_composer.rb +245 -0
  62. data/lib/ace/coworker/molecules/step_evidence_reader.rb +60 -0
  63. data/lib/ace/coworker/molecules/step_report_reader.rb +39 -0
  64. data/lib/ace/coworker/molecules/task_probe.rb +40 -0
  65. data/lib/ace/coworker/molecules/task_record_writer.rb +44 -0
  66. data/lib/ace/coworker/molecules/worktree_executor_probe.rb +139 -0
  67. data/lib/ace/coworker/molecules/worktree_provision_probe.rb +137 -0
  68. data/lib/ace/coworker/organisms/commission_orchestrator.rb +148 -0
  69. data/lib/ace/coworker/organisms/prepare_orchestrator.rb +374 -0
  70. data/lib/ace/coworker/organisms/report_orchestrator.rb +192 -0
  71. data/lib/ace/coworker/organisms/resume_orchestrator.rb +91 -0
  72. data/lib/ace/coworker/organisms/start_config_writer.rb +127 -0
  73. data/lib/ace/coworker/organisms/start_orchestrator.rb +45 -0
  74. data/lib/ace/coworker/organisms/status_orchestrator.rb +62 -0
  75. data/lib/ace/coworker/version.rb +7 -0
  76. data/lib/ace/coworker.rb +86 -0
  77. metadata +319 -0
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Renders the executor brief (table or json). Pure formatting.
7
+ class BriefFormatter
8
+ def self.format_table(brief)
9
+ h = brief.to_h
10
+ lines = []
11
+ lines << "Coworker brief"
12
+ lines << " role: #{h['role']}"
13
+ lines << " instructions: #{h['instructions']}"
14
+ lines << " instructions src: #{h['instructions_source']}"
15
+ lines << " start prompt: #{h['start_prompt']}"
16
+ lines << " task: #{h['task']} (#{h['task_path']})"
17
+ lines << " assignment: drive with --assignment #{h['assignment']}"
18
+ lines << " assignment state: #{h['assignment_state']} (#{h['assignment_progress']})"
19
+ lines << " overseer: #{h['overseer']} (channel: #{h['reporting_channel']})"
20
+ lines << " on failure: #{h['on_failure']}"
21
+ lines << " report with: ace-coworker report --assignment #{h['assignment']}"
22
+ lines << " project root: #{h['project_root']}"
23
+ lines.join("\n")
24
+ end
25
+
26
+ def self.format_json(brief)
27
+ require "json"
28
+ JSON.pretty_generate(brief.to_h)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Pure validation of a raw coworker config hash.
7
+ #
8
+ # Produces field-specific errors; no IO, no guesses, no defaults
9
+ # beyond the documented ones (reporting.channel, errors.on_failure).
10
+ class ConfigSchema
11
+ REQUIRED_FIELDS = %w[role instructions task assignment overseer].freeze
12
+ PROTOCOL_PREFIXES = %w[wfi:// prompt:// tmpl://].freeze
13
+ CHANNELS = %w[hitl].freeze
14
+ ON_FAILURE = %w[report stop].freeze
15
+
16
+ # @param raw [Hash, nil] parsed config hash
17
+ # @return [Array<String>] field-specific error messages (empty when valid)
18
+ def self.errors(raw)
19
+ return ["config is empty"] if raw.nil? || (!raw.is_a?(Hash) && raw.to_s.strip.empty?)
20
+ return ["config must be a mapping, got #{raw.class}"] unless raw.is_a?(Hash)
21
+
22
+ errors = []
23
+ REQUIRED_FIELDS.each do |field|
24
+ value = raw[field]
25
+ if value.nil? || value.to_s.strip.empty?
26
+ errors << "missing required field '#{field}'"
27
+ end
28
+ end
29
+
30
+ errors.concat(prompt_errors(raw))
31
+ errors.concat(nested_mapping_errors(raw, "reporting"))
32
+ errors.concat(nested_mapping_errors(raw, "errors"))
33
+ errors.concat(reporting_errors(raw))
34
+ errors.concat(error_rules_errors(raw))
35
+ errors
36
+ end
37
+
38
+ # @param raw [Hash] validated config hash
39
+ # @return [Hash] normalized hash with defaults applied.
40
+ # start_prompt stays literal; the start_prompt_file CONTENT is read
41
+ # by the loader (IO does not belong in this pure atom).
42
+ def self.normalize(raw)
43
+ {
44
+ "role" => str(raw["role"]),
45
+ "instructions" => str(raw["instructions"]),
46
+ "start_prompt" => str(raw["start_prompt"]),
47
+ "start_prompt_file" => str(raw["start_prompt_file"]),
48
+ "task" => str(raw["task"]),
49
+ "assignment" => str(raw["assignment"]),
50
+ "overseer" => str(raw["overseer"]),
51
+ "reporting" => {"channel" => nested_str(raw, "reporting", "channel").empty? ? "hitl" : nested_str(raw, "reporting", "channel")},
52
+ "errors" => {"on_failure" => nested_str(raw, "errors", "on_failure").empty? ? "report" : nested_str(raw, "errors", "on_failure")}
53
+ }
54
+ end
55
+
56
+ # Protocol instructions reference an external resource; plain paths
57
+ # are files resolved against the project root.
58
+ def self.protocol?(instructions)
59
+ PROTOCOL_PREFIXES.any? { |prefix| instructions.to_s.start_with?(prefix) }
60
+ end
61
+
62
+ def self.errors_for_field(raw, field)
63
+ errors(raw).select { |e| e.include?("'#{field}'") }
64
+ end
65
+
66
+ def self.prompt_errors(raw)
67
+ prompt = str(raw["start_prompt"])
68
+ prompt_file = str(raw["start_prompt_file"])
69
+ if prompt.empty? && prompt_file.empty?
70
+ ["missing required field 'start_prompt' (or 'start_prompt_file')"]
71
+ elsif !prompt.empty? && !prompt_file.empty?
72
+ ["'start_prompt' and 'start_prompt_file' are mutually exclusive"]
73
+ else
74
+ []
75
+ end
76
+ end
77
+ private_class_method :prompt_errors
78
+
79
+ def self.reporting_errors(raw)
80
+ channel = nested_str(raw, "reporting", "channel")
81
+ return [] if channel.empty?
82
+
83
+ CHANNELS.include?(channel) ? [] : ["reporting.channel '#{channel}' is not one of: #{CHANNELS.join(', ')}"]
84
+ end
85
+ private_class_method :reporting_errors
86
+
87
+ def self.error_rules_errors(raw)
88
+ on_failure = nested_str(raw, "errors", "on_failure")
89
+ return [] if on_failure.empty?
90
+
91
+ ON_FAILURE.include?(on_failure) ? [] : ["errors.on_failure '#{on_failure}' is not one of: #{ON_FAILURE.join(', ')}"]
92
+ end
93
+ private_class_method :error_rules_errors
94
+
95
+ # Nested sections must be mappings (or absent); scalars such as
96
+ # `reporting: hitl` would raise TypeError in #dig.
97
+ def self.nested_mapping_errors(raw, section)
98
+ value = raw[section]
99
+ return [] if value.nil? || value.is_a?(Hash)
100
+
101
+ ["'#{section}' must be a mapping, got #{value.class}"]
102
+ end
103
+ private_class_method :nested_mapping_errors
104
+
105
+ # TypeError-safe nested string read for validated mappings.
106
+ def self.nested_str(raw, section, key)
107
+ value = raw[section]
108
+ return "" unless value.is_a?(Hash)
109
+
110
+ str(value[key])
111
+ end
112
+ private_class_method :nested_str
113
+
114
+ def self.str(value)
115
+ value.to_s.strip
116
+ end
117
+ private_class_method :str
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Pure pre-merge integration gate (8xg.t.sbz.4.3 / contract): the
7
+ # integrator merges only a candidate whose (a) dependencies are
8
+ # integrated predecessors, (b) current head matches the reviewed
9
+ # head (no drift), (c) tests were executed and passed, and (d) a
10
+ # fresh independent review verdict accepts the EXACT candidate
11
+ # head. Any unknown or contradictory fact is a blocking reason -
12
+ # uncertainty must be verified against the real worktree/reports,
13
+ # never guessed or retried blind. Explicit :blocked outcome with
14
+ # reasons, never an exception. No IO, no writes, no merges: the
15
+ # gate decides readiness only; the role drives git/ace-git.
16
+ module IntegrationGate
17
+ ACCEPT_VERDICTS = %w[accept].freeze
18
+
19
+ # @param input [Hash] string-keyed facts:
20
+ # "candidate_head" => String (current head of the candidate)
21
+ # "review" => {"head" => String reviewed head, "verdict" => String}
22
+ # "tests" => {"executed" => bool, "passed" => bool}
23
+ # "dependencies" => {name => bool (integrated predecessor)}
24
+ # @return [Hash] {verdict: :ready | :blocked, reasons: [String],
25
+ # failed_checks: [:dependencies|:candidate|:review|:tests|:input]}
26
+ def self.evaluate(input)
27
+ unless input.is_a?(Hash) && input.keys.all? { |k| k.is_a?(String) }
28
+ return {verdict: :blocked,
29
+ reasons: ["gate input must be a Hash with string keys, got #{input.class}"],
30
+ failed_checks: [:input]}
31
+ end
32
+
33
+ reasons = []
34
+ failed = []
35
+
36
+ candidate_head = str(input["candidate_head"])
37
+ if candidate_head.empty?
38
+ reasons << "candidate head unknown: verify the actual worktree head instead of guessing"
39
+ failed << :candidate
40
+ end
41
+
42
+ dependencies = hash(input["dependencies"])
43
+ unsatisfied = dependencies.reject { |_, integrated| integrated == true }
44
+ unless unsatisfied.empty?
45
+ reasons << "dependencies not integrated: #{unsatisfied.keys.sort.join(', ')}"
46
+ failed << :dependencies
47
+ end
48
+
49
+ review = hash(input["review"])
50
+ review_head = str(review["head"])
51
+ verdict = str(review["verdict"]).downcase
52
+ if review_head.empty?
53
+ reasons << "review evidence missing: no reviewed head recorded - the verdict covers no verifiable candidate"
54
+ failed << :review
55
+ elsif !candidate_head.empty? && review_head.downcase != candidate_head.downcase
56
+ reasons << "stale review: candidate #{candidate_head} is not the reviewed head #{review_head}"
57
+ failed << :review
58
+ end
59
+ unless ACCEPT_VERDICTS.include?(verdict)
60
+ reasons << "review verdict is '#{verdict.empty? ? 'unknown' : verdict}', not accept"
61
+ failed << :review
62
+ end
63
+
64
+ tests = hash(input["tests"])
65
+ unless tests["executed"] == true
66
+ reasons << "tests not executed: real executed-test evidence is required before merge"
67
+ failed << :tests
68
+ end
69
+ unless tests["passed"] == true
70
+ reasons << "tests not passing: executed tests failed or their outcome is unknown"
71
+ failed << :tests
72
+ end
73
+
74
+ {verdict: reasons.empty? ? :ready : :blocked,
75
+ reasons: reasons, failed_checks: failed.uniq}
76
+ end
77
+
78
+ class << self
79
+ private
80
+
81
+ def str(value)
82
+ value.to_s.strip
83
+ end
84
+
85
+ def hash(value)
86
+ value.is_a?(Hash) ? value : {}
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Pure derivation of the reporting Outcome from an AssignmentSnapshot.
7
+ # ace-assign is the only truth: a success report requires a complete
8
+ # verdict; an error report requires a cause and never claims work done.
9
+ class OutcomePlanner
10
+ # @param snapshot [Models::AssignmentSnapshot]
11
+ # @param explicit_result [String, nil] --result flag
12
+ # @param message [String, nil] explicit cause
13
+ # @param step_evidence [String, nil] failed-step report content
14
+ # @param step_override [String, nil] explicit --step flag
15
+ # @return [Models::Outcome]
16
+ # @raise [Coworker::Error] on refused inputs (false success, missing cause)
17
+ def self.plan(snapshot, explicit_result: nil, message: nil, step_evidence: nil, step_override: nil)
18
+ if explicit_result && !%w[success error].include?(explicit_result)
19
+ raise Error, "invalid --result '#{explicit_result}': expected success or error"
20
+ end
21
+
22
+ complete = snapshot.completion_verdict == :complete
23
+ result = explicit_result || (complete ? "success" : "error")
24
+
25
+ if result == "success" && !complete
26
+ raise Error,
27
+ "assignment '#{snapshot.id}' is not complete: #{snapshot.incomplete_reasons.join('; ')}; refusing to record work done"
28
+ end
29
+
30
+ if result == "error"
31
+ return error_outcome(snapshot, message, step_evidence, step_override)
32
+ end
33
+
34
+ success_outcome(snapshot, step_override)
35
+ end
36
+
37
+ def self.success_outcome(snapshot, step_override)
38
+ step = if step_override
39
+ Models::Outcome::StepRef.new(number: step_override, name: step_name(snapshot, step_override))
40
+ else
41
+ last = snapshot.steps.last
42
+ Models::Outcome::StepRef.new(number: last&.number, name: last&.name)
43
+ end
44
+
45
+ Models::Outcome.new(result: "success", verdict: :complete, step: step,
46
+ unfinished_steps: [],
47
+ evidence: {"progress" => snapshot.progress})
48
+ end
49
+
50
+ def self.error_outcome(snapshot, message, step_evidence, step_override)
51
+ failed = snapshot.steps.reverse.find { |s| s.status == "failed" } ||
52
+ snapshot.steps.find { |s| s.status == "active" } ||
53
+ snapshot.steps.find { |s| s.status == "pending" } ||
54
+ snapshot.steps.last
55
+ step = if step_override
56
+ Models::Outcome::StepRef.new(number: step_override, name: step_name(snapshot, step_override))
57
+ else
58
+ Models::Outcome::StepRef.new(number: failed&.number, name: failed&.name)
59
+ end
60
+
61
+ cause = message.to_s.strip
62
+ cause = step_evidence.to_s.strip if cause.empty?
63
+ if cause.empty?
64
+ raise Error,
65
+ "error outcome for assignment '#{snapshot.id}' needs a cause: pass --message or provide a failed-step report"
66
+ end
67
+
68
+ unfinished = snapshot.steps.reject(&:done?).map { |s| "#{s.number} #{s.name} (#{s.status})" }
69
+ Models::Outcome.new(result: "error", verdict: :incomplete, cause: cause, step: step,
70
+ unfinished_steps: unfinished)
71
+ end
72
+
73
+ def self.step_name(snapshot, number)
74
+ snapshot.steps.find { |s| s.number.to_s == number.to_s }&.name
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git/worktree/atoms/task_id_extractor"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module Atoms
8
+ # Pure disposition rules for the preparation decision procedure.
9
+ # Inputs are the structured hashes returned by the worktree owner
10
+ # (ace-git-worktree switch/create results); outputs are dispositions
11
+ # with reasons. No IO, no writes.
12
+ module PrepareDecision
13
+ # @param switch_result [Hash] WorktreeManager#switch result; with the
14
+ # real owner, a worktree whose directory is missing never resolves
15
+ # here (switch fails with a "directory does not exist" error).
16
+ # @param expected_task_id [String] id of the task being prepared
17
+ # @param stale_registration [Hash, nil] association-registered
18
+ # worktree for the expected task whose directory is missing on
19
+ # disk ({task_id:, worktree_path:, branch:}); recovered via prune
20
+ # + recreate instead of a plain create
21
+ # @return [Hash] {disposition:, reason:, worktree_path:, branch:, task_id:}
22
+ # disposition: :reuse | :conflict_mismatch | :stale_recovery | :miss
23
+ def self.from_switch(switch_result, expected_task_id:, stale_registration: nil)
24
+ if switch_result[:success]
25
+ task_id = switch_result[:task_id].to_s
26
+ base = {worktree_path: switch_result[:worktree_path],
27
+ branch: switch_result[:branch], task_id: task_id}
28
+ if expected_ids(expected_task_id).include?(task_id)
29
+ base.merge(disposition: :reuse, reason: "worktree already prepared for this task")
30
+ else
31
+ base.merge(disposition: :conflict_mismatch,
32
+ reason: "resolved worktree belongs to task '#{task_id}', not '#{expected_task_id}'")
33
+ end
34
+ elsif stale_registration
35
+ {disposition: :stale_recovery,
36
+ reason: "worktree metadata resolves to a missing directory: #{stale_registration[:worktree_path]}",
37
+ worktree_path: stale_registration[:worktree_path],
38
+ branch: stale_registration[:branch],
39
+ task_id: stale_registration[:task_id]}
40
+ else
41
+ {disposition: :miss, reason: switch_result[:error].to_s,
42
+ worktree_path: nil, branch: nil, task_id: nil}
43
+ end
44
+ end
45
+
46
+ # ace-git-worktree stores the worktree association under the task's
47
+ # short id (e.g. "aaa") while ace-task resolves the full id
48
+ # ("8xh.t.aaa"); accept every recognized form of the expected id.
49
+ def self.expected_ids(expected_task_id)
50
+ full = expected_task_id.to_s
51
+ [full, Ace::Git::Worktree::Atoms::TaskIDExtractor.short_id(full),
52
+ Ace::Git::Worktree::Atoms::TaskIDExtractor.normalize(full)].compact.uniq
53
+ end
54
+
55
+ # @param create_result [Hash] WorktreeManager#create_task result
56
+ # @param occupied_directory [Boolean] whether the task's expected
57
+ # worktree directory exists on disk (a foreign occupant when the
58
+ # create failed)
59
+ # @return [Hash] {disposition:, reason:, worktree_path:, branch:}
60
+ # disposition: :created | :recovered | :reuse | :conflict_mismatch | :error
61
+ def self.from_create(create_result, occupied_directory: false)
62
+ if create_result[:success]
63
+ disposition = create_result[:existing] ? :reuse : (create_result[:recovered] ? :recovered : :created)
64
+ return {disposition: disposition, reason: create_result[:message].to_s,
65
+ worktree_path: create_result[:worktree_path], branch: create_result[:branch]}
66
+ end
67
+
68
+ disposition = occupied_directory ? :conflict_mismatch : :error
69
+ {disposition: disposition, reason: create_result[:error].to_s,
70
+ worktree_path: nil, branch: nil}
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Pure start-prompt template rendering. Substitutes the known
7
+ # placeholders from a context hash; unknown placeholders stay literal.
8
+ # No IO.
9
+ class PromptTemplate
10
+ PLACEHOLDERS = %w[task assignment role overseer].freeze
11
+ PLACEHOLDER_PATTERN = /\{\{(#{PLACEHOLDERS.map { |p| Regexp.escape(p) }.join('|')})\}\}/.freeze
12
+
13
+ # @param template [String] preset start_prompt with {{placeholders}}
14
+ # @param context [Hash{String => String}] commission identity values
15
+ # @return [String] rendered prompt
16
+ def self.render(template, context)
17
+ # Single pass over PLACEHOLDER_PATTERN: only known placeholders
18
+ # match, and substituted values are never re-expanded (a value
19
+ # shaped like a later placeholder stays literal). Block form: the
20
+ # replacement is treated literally, so values containing
21
+ # backslashes or gsub escape sequences (\1, \0, \&) interpolate
22
+ # verbatim.
23
+ template.to_s.gsub(PLACEHOLDER_PATTERN) { context.fetch(Regexp.last_match(1), "").to_s }.strip
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module Atoms
8
+ # Pure rendering of the durable task-folder record markdown.
9
+ class ReportRecordFormatter
10
+ # @param outcome [Models::Outcome]
11
+ # @param cfg [Models::Config]
12
+ # @param extras [Hash] event_id:, delivery:, record_path:
13
+ # @return [String] markdown document
14
+ def self.format(outcome, cfg, event_id: nil, delivery: nil, record_path: nil)
15
+ lines = []
16
+ lines << "---"
17
+ lines << "task: #{cfg.task}"
18
+ lines << "assignment: #{cfg.assignment}"
19
+ lines << "result: #{outcome.result}"
20
+ lines << "verdict: #{outcome.verdict}"
21
+ lines << "event_id: #{event_id}" if event_id
22
+ # Delivery can carry free-form failure detail ("failed: <cause>");
23
+ # serialize the value so the frontmatter stays parseable YAML.
24
+ lines << "delivery: #{delivery.to_s.inspect}" if delivery
25
+ lines << "updated_at: #{Time.now.utc.iso8601}"
26
+ lines << "---"
27
+ lines << ""
28
+ lines << "# #{outcome.success? ? "Work done" : "Assignment failed"} - #{cfg.task}/#{cfg.assignment}"
29
+ lines << ""
30
+ if outcome.success?
31
+ lines << "Assignment completed (#{outcome.evidence["progress"]})."
32
+ else
33
+ lines << "## Cause"
34
+ lines << ""
35
+ lines << outcome.cause
36
+ lines << ""
37
+ lines << "## Unfinished steps"
38
+ lines << ""
39
+ if outcome.unfinished_steps.empty?
40
+ lines << "- (none recorded)"
41
+ else
42
+ outcome.unfinished_steps.each { |step| lines << "- #{step}" }
43
+ end
44
+ end
45
+ lines << ""
46
+ lines << "## Evidence"
47
+ lines << ""
48
+ lines << "- record: #{record_path}" if record_path
49
+ lines << "- reports: .ace-local/assign/#{cfg.assignment}/reports/"
50
+ lines << "- event: .ace-local/hitl/ (#{event_id})" if event_id
51
+ lines << ""
52
+ lines << "## Delivery"
53
+ lines << ""
54
+ lines << (delivery ? "- #{delivery}" : "- (not attempted)")
55
+ lines.join("\n") + "\n"
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Pure rendering of a ResumePlan (table format). The brief is the
7
+ # guidance the next role or tool reads to continue safely.
8
+ class ResumeBriefFormatter
9
+ # @param plan [Models::ResumePlan]
10
+ # @param result [Organisms::ResumeOrchestrator::ResumeResult, nil]
11
+ # @return [String]
12
+ def self.format_table(plan, result = nil)
13
+ lines = ["Coworker resume"]
14
+ if plan.disposition == :report
15
+ lines << " assignment complete; report delivery missing"
16
+ if plan.redeliver_event_id
17
+ lines << " resend event: #{plan.redeliver_event_id} (same ID; retrying delivery never retries the assignment)"
18
+ end
19
+ lines << " #{result.message}" if result.message
20
+ return lines.join("\n")
21
+ end
22
+
23
+ if plan.disposition == :complete
24
+ lines << " assignment complete and delivered; nothing to resume"
25
+ return lines.join("\n")
26
+ end
27
+
28
+ lines.concat(identity_lines(plan))
29
+ lines.concat(preserved_lines(plan))
30
+ lines.concat(remaining_lines(plan))
31
+ lines.concat(in_flight_lines(plan))
32
+ lines.concat(local_changes_lines(plan))
33
+ lines.concat(effect_caution_lines(plan))
34
+ lines.join("\n")
35
+ end
36
+
37
+ def self.identity_lines(plan)
38
+ branch = plan.git["branch"] || "(detached)"
39
+ [" branch: #{branch}"]
40
+ end
41
+
42
+ def self.preserved_lines(plan)
43
+ return [] if plan.preserved_steps.empty?
44
+
45
+ [" preserved steps:"] + plan.preserved_steps.map { |s| " done: #{s}" }
46
+ end
47
+
48
+ def self.remaining_lines(plan)
49
+ return [] if plan.remaining_steps.empty?
50
+
51
+ [" remaining work:"] + plan.remaining_steps.map { |s| " #{s}" }
52
+ end
53
+
54
+ def self.in_flight_lines(plan)
55
+ return [] unless plan.in_flight
56
+
57
+ step = plan.in_flight
58
+ lines = [" in-flight step: #{step["number"]} #{step["name"]} (#{step["status"]})"]
59
+ lines << " recorded cause: #{plan.in_flight_cause}" if plan.in_flight_cause
60
+ lines << " step report: #{plan.step_report_path}" if plan.step_report_path
61
+ lines << " verify the effect from the current files/Git state and the step's own report before repeating it; do not reset local changes"
62
+ lines
63
+ end
64
+
65
+ def self.local_changes_lines(plan)
66
+ dirty = plan.git["dirty_entries"] || []
67
+ return [] if dirty.empty?
68
+
69
+ [" local changes (preserved, never reset):"] + dirty.map { |entry| " #{entry}" }
70
+ end
71
+
72
+ def self.effect_caution_lines(plan)
73
+ return [] if plan.effect_caution_steps.empty?
74
+
75
+ [" effect caution: uncertain external effect (merge/publish/deploy/release/push)?"] +
76
+ plan.effect_caution_steps.map { |s| " step: #{s}" } +
77
+ [" check the actual state with existing tools (e.g. ace-git status, PR evidence) or report a blocker;",
78
+ " never replay blindly; same task, assignment and worktree"]
79
+ # never replay blindly; same task, assignment and worktree
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Atoms
6
+ # Pure derivation of the resume plan: ace-assign is the only step
7
+ # truth, the worktree git state is the only change truth, and the
8
+ # report helper state decides create-vs-resend. Never plans a new
9
+ # task/assignment/worktree and never plans resetting local changes.
10
+ class ResumePlanner
11
+ EFFECT_PATTERN = /merge|publish|deploy|release|push/i.freeze
12
+
13
+ # @param snapshot [Models::AssignmentSnapshot]
14
+ # @param report_state [Hash, nil] .ace-local/coworker/reports/<id>.json
15
+ # @param git_state [Hash] branch/ahead_behind/dirty_entries/last_commit
16
+ # @param step_cause [String, nil] recorded error of the in-flight step
17
+ # @param step_report_path [String, nil] report file of the in-flight step
18
+ # @return [Models::ResumePlan]
19
+ def self.plan(snapshot:, report_state: nil, git_state: {},
20
+ step_cause: nil, step_report_path: nil)
21
+ if snapshot.completion_verdict == :complete
22
+ return complete_plan(report_state, git_state)
23
+ end
24
+
25
+ continue_plan(snapshot, report_state, git_state,
26
+ step_cause: step_cause, step_report_path: step_report_path)
27
+ end
28
+
29
+ # Only a DELIVERED SUCCESS report matches a completed assignment:
30
+ # a delivered error report describes an outcome the assignment has
31
+ # since left behind, so resume must still report the success.
32
+ def self.complete_plan(report_state, git_state)
33
+ delivered = report_state.is_a?(Hash) &&
34
+ report_state["result"] == "success" &&
35
+ report_state["delivery"].to_s.start_with?("delivered")
36
+ if delivered
37
+ Models::ResumePlan.new(disposition: :complete, git: git_state)
38
+ else
39
+ Models::ResumePlan.new(disposition: :report, git: git_state,
40
+ redeliver_event_id: report_state.is_a?(Hash) ? report_state["event_id"] : nil)
41
+ end
42
+ end
43
+
44
+ # Single source of truth for the in-flight step: last active, else
45
+ # last failed, else first remaining. The orchestrator uses this to
46
+ # decide whose evidence to read; the planner renders the same step.
47
+ def self.in_flight_step(snapshot)
48
+ remaining = snapshot.steps.reject(&:done?)
49
+ remaining.reverse.find { |s| s.status == "active" } ||
50
+ remaining.reverse.find { |s| s.status == "failed" } ||
51
+ remaining.first
52
+ end
53
+
54
+ def self.continue_plan(snapshot, report_state, git_state, step_cause:, step_report_path:)
55
+ preserved = snapshot.steps.select(&:done?).map { |s| "#{s.number} #{s.name}" }
56
+ remaining = snapshot.steps.reject(&:done?)
57
+ in_flight_step = in_flight_step(snapshot)
58
+
59
+ remaining_lines = remaining.map { |s| "#{s.number} #{s.name} (#{s.status})" }
60
+ caution = remaining_lines.select { |line| line.match?(EFFECT_PATTERN) }.uniq
61
+
62
+ Models::ResumePlan.new(disposition: :continue,
63
+ preserved_steps: preserved,
64
+ remaining_steps: remaining_lines,
65
+ in_flight: in_flight_step && {
66
+ "number" => in_flight_step.number, "name" => in_flight_step.name,
67
+ "status" => in_flight_step.status
68
+ },
69
+ in_flight_cause: step_cause,
70
+ step_report_path: step_report_path,
71
+ effect_caution_steps: caution,
72
+ git: git_state,
73
+ redeliver_event_id: report_state.is_a?(Hash) ? report_state["event_id"] : nil)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end