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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module CLI
8
+ module Commands
9
+ class Resume < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Assess interrupted work in this worktree and resume it"
13
+
14
+ option :config, desc: "Path to coworker config (default: .ace-local/coworker/config.yml)"
15
+ option :assignment, desc: "Assignment ID (default: config assignment)"
16
+ option :"dry-run", type: :boolean, desc: "Derive the resume plan without redelivering reports"
17
+ option :format, default: "table", desc: "Output format (table, json)"
18
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
19
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
20
+
21
+ def call(**options)
22
+ orchestrator = Organisms::ResumeOrchestrator.new
23
+ result = orchestrator.call(
24
+ config_path: options[:config],
25
+ assignment: options[:assignment],
26
+ dry_run: options[:"dry-run"] == true
27
+ )
28
+
29
+ # Render the plan first: on a delegated report failure the
30
+ # resume guidance still matters; then keep the non-zero exit.
31
+ unless options[:quiet]
32
+ if options[:format] == "json"
33
+ puts JSON.pretty_generate(to_h(result))
34
+ else
35
+ puts Atoms::ResumeBriefFormatter.format_table(result.plan, result)
36
+ end
37
+ end
38
+
39
+ if result.exit_code != 0
40
+ raise Ace::Support::Cli::Error, result.message
41
+ end
42
+ rescue Ace::Coworker::Error => e
43
+ raise Ace::Support::Cli::Error, e.message
44
+ end
45
+
46
+ private
47
+
48
+ def to_h(result)
49
+ payload = result.plan.to_h
50
+ payload["message"] = result.message
51
+ payload
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module CLI
8
+ module Commands
9
+ class Roles < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "List the role preset catalog (gem defaults + project overrides)"
13
+
14
+ option :format, default: "table", desc: "Output format (table, json)"
15
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
16
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
17
+
18
+ def initialize(preset_loader: nil)
19
+ super()
20
+ @preset_loader = preset_loader
21
+ end
22
+
23
+ # Read-only catalog query; exit 0 for any valid catalog state (an
24
+ # empty catalog is a state, not an error). An invalid preset file
25
+ # surfaces as a CLI error (non-zero exit).
26
+ def call(**options)
27
+ loader = @preset_loader || begin
28
+ project_root = Molecules::ProjectRoot.new.resolve
29
+ Molecules::RolePresetLoader.new(project_root: project_root)
30
+ end
31
+ presets = loader.list
32
+
33
+ return if options[:quiet]
34
+
35
+ if options[:format] == "json"
36
+ puts JSON.pretty_generate("roles" => presets)
37
+ else
38
+ puts render_table(presets)
39
+ end
40
+ rescue Ace::Coworker::Error => e
41
+ raise Ace::Support::Cli::Error, e.message
42
+ end
43
+
44
+ private
45
+
46
+ def render_table(presets)
47
+ return "Coworker roles: none found (gem .ace-defaults/coworker/roles/ and project .ace/coworker/roles/)" if presets.empty?
48
+
49
+ lines = ["Coworker roles:"]
50
+ presets.each do |preset|
51
+ lines << " #{preset['name'].ljust(10)} #{preset['source'].ljust(8)} #{preset['description']}"
52
+ lines << " #{" " * 10} instructions: #{preset['instructions']}"
53
+ end
54
+ lines.join("\n")
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module CLI
8
+ module Commands
9
+ class Rounds < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Record a review round for an assignment and print the 3/2/10 verdict"
13
+
14
+ example [
15
+ "record --assignment asg01 --type full --status completed",
16
+ "record --assignment asg01 --type full --status completed \\",
17
+ " --confirmed f-1:high,f-2:low --resolved f-3",
18
+ "record --assignment asg01 --type delta --status aborted"
19
+ ]
20
+
21
+ argument :action, required: true, desc: "Subcommand (record)"
22
+
23
+ option :assignment, required: true, desc: "Assignment ID"
24
+ option :type, required: true, desc: "Round type (full, delta)"
25
+ option :status, required: true, desc: "Round status (completed, aborted)"
26
+ option :confirmed, type: :array, default: [], desc: "Confirmed findings as id:severity pairs (e.g. f-1:high)"
27
+ option :resolved, type: :array, default: [], desc: "Finding ids resolved by this round"
28
+ option :format, default: "table", desc: "Output format (table, json)"
29
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
30
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
31
+
32
+ def initialize(store: nil)
33
+ super()
34
+ @store = store
35
+ end
36
+
37
+ def call(action:, assignment:, type:, status:, confirmed: [], resolved: [],
38
+ format: "table", **options)
39
+ raise Ace::Support::Cli::Error, "unknown subcommand '#{action}' (expected: record)" unless action == "record"
40
+
41
+ store = @store || begin
42
+ project_root = Molecules::ProjectRoot.new.resolve
43
+ Molecules::ReviewRoundStore.new(project_root: project_root)
44
+ end
45
+ verdict = store.record!(assignment, build_record(type, status, confirmed, resolved))
46
+
47
+ return if options[:quiet]
48
+
49
+ if format == "json"
50
+ puts JSON.pretty_generate(json_payload(assignment, verdict))
51
+ else
52
+ puts render(assignment, verdict)
53
+ end
54
+ rescue Ace::Coworker::Error => e
55
+ raise Ace::Support::Cli::Error, e.message
56
+ end
57
+
58
+ private
59
+
60
+ def build_record(type, status, confirmed, resolved)
61
+ {
62
+ "type" => type,
63
+ "status" => status,
64
+ "confirmed" => Array(confirmed).map { |pair| parse_finding(pair) },
65
+ "resolved" => Array(resolved).map(&:to_s)
66
+ }
67
+ end
68
+
69
+ def parse_finding(pair)
70
+ id, severity = pair.to_s.split(":", 2)
71
+ {"id" => id.to_s, "severity" => severity.to_s}
72
+ end
73
+
74
+ def json_payload(assignment, verdict)
75
+ {
76
+ "assignment" => assignment,
77
+ "verdict" => verdict[:verdict].to_s,
78
+ "full_rounds" => verdict[:full_rounds],
79
+ "completed_full_rounds" => verdict[:completed_full_rounds],
80
+ "consecutive_clean" => verdict[:consecutive_clean],
81
+ "unresolved_high_critical" => verdict[:unresolved_high_critical]
82
+ }
83
+ end
84
+
85
+ def render(assignment, verdict)
86
+ lines = ["Coworker rounds record"]
87
+ lines << " assignment: #{assignment}"
88
+ lines << " verdict: #{verdict[:verdict]}"
89
+ lines << " full rounds: #{verdict[:full_rounds]}"
90
+ lines << " completed full rounds: #{verdict[:completed_full_rounds]}"
91
+ lines << " consecutive clean: #{verdict[:consecutive_clean]}"
92
+ lines << " unresolved High/Critical: #{verdict[:unresolved_high_critical].join(', ')}"
93
+ lines.join("\n")
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module CLI
6
+ module Commands
7
+ class Start < Ace::Support::Cli::Command
8
+ include Ace::Support::Cli::Base
9
+
10
+ desc "Validate prepared run config and render the executor brief"
11
+
12
+ option :config, desc: "Path to coworker config (default: .ace-local/coworker/config.yml)"
13
+ option :format, default: "table", desc: "Output format (table, json)"
14
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
15
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
16
+
17
+ def initialize(orchestrator: nil)
18
+ super()
19
+ @orchestrator = orchestrator
20
+ end
21
+
22
+ def call(config: nil, format: "table", **options)
23
+ orchestrator = @orchestrator || Organisms::StartOrchestrator.new
24
+ brief = orchestrator.call(config_path: config)
25
+ return if options[:quiet]
26
+
27
+ if format == "json"
28
+ puts Atoms::BriefFormatter.format_json(brief)
29
+ else
30
+ puts Atoms::BriefFormatter.format_table(brief)
31
+ end
32
+ rescue Ace::Coworker::Error, StandardError => e
33
+ raise Ace::Support::Cli::Error, e.message
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Coworker
7
+ module CLI
8
+ module Commands
9
+ class Status < Ace::Support::Cli::Command
10
+ include Ace::Support::Cli::Base
11
+
12
+ desc "Show readiness, identity, and assignment progress"
13
+
14
+ option :config, desc: "Path to coworker config (default: .ace-local/coworker/config.yml)"
15
+ option :assignment, desc: "Show status for a specific assignment ID (default: config assignment)"
16
+ option :format, default: "table", desc: "Output format (table, json)"
17
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
18
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
19
+
20
+ def initialize(orchestrator: nil)
21
+ super()
22
+ @orchestrator = orchestrator
23
+ end
24
+
25
+ def call(config: nil, assignment: nil, format: "table", **options)
26
+ orchestrator = @orchestrator || Organisms::StatusOrchestrator.new
27
+ result = orchestrator.call(config_path: config, assignment: assignment)
28
+ return if options[:quiet]
29
+
30
+ if format == "json"
31
+ puts JSON.pretty_generate(to_h(result))
32
+ else
33
+ puts render_table(result)
34
+ end
35
+ rescue Ace::Coworker::Error, StandardError => e
36
+ raise Ace::Support::Cli::Error, e.message
37
+ end
38
+
39
+ private
40
+
41
+ def effective_assignment(result)
42
+ result.assignment || result.config.assignment
43
+ end
44
+
45
+ def to_h(result)
46
+ if result.is_a?(Organisms::StatusOrchestrator::NotReady)
47
+ {"ready" => false, "message" => result.message}
48
+ else
49
+ cfg = result.config
50
+ payload = {
51
+ "ready" => true,
52
+ "role" => cfg.role,
53
+ "task" => cfg.task,
54
+ "assignment" => effective_assignment(result),
55
+ "overseer" => cfg.overseer,
56
+ "on_failure" => cfg.on_failure
57
+ }
58
+ if result.snapshot
59
+ payload["assignment_status"] = result.snapshot.to_h
60
+ elsif result.probe_error
61
+ payload["assignment_error"] = result.probe_error
62
+ end
63
+ payload
64
+ end
65
+ end
66
+
67
+ def render_table(result)
68
+ if result.is_a?(Organisms::StatusOrchestrator::NotReady)
69
+ return result.message
70
+ end
71
+
72
+ cfg = result.config
73
+ lines = ["Coworker status"]
74
+ lines << " role: #{cfg.role}"
75
+ lines << " task: #{cfg.task}"
76
+ lines << " assignment: #{effective_assignment(result)}"
77
+ lines << " overseer: #{cfg.overseer}"
78
+ if result.snapshot
79
+ snapshot = result.snapshot
80
+ verdict = snapshot.completion_verdict == :complete ? "complete" : "incomplete"
81
+ lines << " state: #{snapshot.state} (#{snapshot.progress}) — #{verdict}"
82
+ unless verdict == "complete"
83
+ reasons = snapshot.incomplete_reasons
84
+ lines.concat(reasons.map { |r| " remaining: #{r}" })
85
+ end
86
+ elsif result.probe_error
87
+ lines << " assignment error: #{result.probe_error}"
88
+ end
89
+ lines.join("\n")
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "cli/commands/start"
6
+ require_relative "cli/commands/status"
7
+ require_relative "cli/commands/report"
8
+ require_relative "cli/commands/resume"
9
+ require_relative "cli/commands/prepare"
10
+ require_relative "cli/commands/commission"
11
+ require_relative "cli/commands/roles"
12
+ require_relative "cli/commands/rounds"
13
+ require_relative "cli/commands/gate"
14
+
15
+ module Ace
16
+ module Coworker
17
+ module CLI
18
+ extend Ace::Support::Cli::RegistryDsl
19
+
20
+ PROGRAM_NAME = "ace-coworker"
21
+
22
+ REGISTERED_COMMANDS = [
23
+ ["start", "Validate prepared run config and render the executor brief"],
24
+ ["status", "Show readiness, identity, and assignment progress"],
25
+ ["report", "Record the assignment outcome and report it through ace-hitl"],
26
+ ["resume", "Assess interrupted work in this worktree and resume it"],
27
+ ["prepare", "Accept a preparation commission and ensure the task worktree"],
28
+ ["commission", "Instantiate a role preset into the coworker run config"],
29
+ ["roles", "List the role preset catalog (gem defaults + project overrides)"],
30
+ ["rounds", "Record a review round for an assignment and print the 3/2/10 verdict"],
31
+ ["gate", "Evaluate the pre-merge integration gate (:ready | :blocked with reasons)"]
32
+ ].freeze
33
+
34
+ HELP_EXAMPLES = [
35
+ "ace-coworker start # Read .ace-local/coworker/config.yml",
36
+ "ace-coworker start --config PATH # Use an explicit config",
37
+ "ace-coworker status --format json # Readiness + assignment verdict",
38
+ "ace-coworker report # Record outcome + HITL report",
39
+ "ace-coworker report --result error --message 'cause'",
40
+ "ace-coworker resume # Resume interrupted work",
41
+ "ace-coworker resume --dry-run # Plan only, no redelivery",
42
+ "ace-coworker prepare --task 148 --roles worker,reviewer",
43
+ "ace-coworker prepare --task 148 --roles worker --dry-run",
44
+ "ace-coworker roles # List role presets",
45
+ "ace-coworker commission --role worker --task 148 --assignment abc123 \\",
46
+ " --overseer overseer@lab",
47
+ "ace-coworker rounds record --assignment abc123 --type full --status completed",
48
+ "ace-coworker rounds record --assignment abc123 --type full --status completed \\",
49
+ " --confirmed f-1:high --resolved f-2",
50
+ "ace-coworker gate --candidate-head 1a2b3c4 --review-head 1a2b3c4 \\",
51
+ " --review-verdict accept --tests executed,passed \\",
52
+ " --dep 8xg.t.sbz.4.2=integrated"
53
+ ].freeze
54
+
55
+ register "start", Commands::Start
56
+ register "status", Commands::Status
57
+ register "report", Commands::Report
58
+ register "resume", Commands::Resume
59
+ register "prepare", Commands::Prepare
60
+ register "commission", Commands::Commission
61
+ register "roles", Commands::Roles
62
+ register "rounds", Commands::Rounds
63
+ register "gate", Commands::Gate
64
+
65
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
66
+ gem_name: "ace-coworker",
67
+ version: Ace::Coworker::VERSION
68
+ )
69
+ register "version", version_cmd
70
+ register "--version", version_cmd
71
+
72
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
73
+ program_name: PROGRAM_NAME,
74
+ version: Ace::Coworker::VERSION,
75
+ commands: REGISTERED_COMMANDS,
76
+ examples: HELP_EXAMPLES
77
+ )
78
+ register "help", help_cmd
79
+ register "--help", help_cmd
80
+ register "-h", help_cmd
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+
5
+ module Ace
6
+ module Coworker
7
+ class Error < Ace::Support::Cli::Error
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Models
6
+ # Pure view of `ace-assign status --format json` for one assignment.
7
+ class AssignmentSnapshot
8
+ Step = Struct.new(:number, :name, :status, keyword_init: true) do
9
+ def done?
10
+ status == "done"
11
+ end
12
+ end
13
+
14
+ attr_reader :id, :name, :state, :steps, :progress
15
+
16
+ def initialize(id:, name:, state:, steps:, progress:)
17
+ @id = id.freeze
18
+ @name = name.freeze
19
+ @state = state.freeze
20
+ @steps = steps.map { |s| Step.new(number: s[:number], name: s[:name], status: s[:status]) }.freeze
21
+ @progress = progress.freeze
22
+ end
23
+
24
+ def self.from_status_json(payload)
25
+ assignment = payload["assignment"] || {}
26
+ steps = (payload["steps"] || []).map { |s| {number: s["number"], name: s["name"], status: s["status"]} }
27
+ new(
28
+ id: assignment["id"].to_s,
29
+ name: assignment["name"].to_s,
30
+ state: assignment["state"].to_s,
31
+ steps: steps,
32
+ progress: payload["progress"].to_s
33
+ )
34
+ end
35
+
36
+ # Deterministic completion verdict: complete only when the assignment
37
+ # state is completed, the plan is non-empty, and every step is done.
38
+ # Empty plans, failed steps, or pending/active steps are not complete.
39
+ def completion_verdict
40
+ return :complete if state == "completed" && !steps.empty? && steps.all?(&:done?)
41
+
42
+ :incomplete
43
+ end
44
+
45
+ def incomplete_reasons
46
+ reasons = []
47
+ reasons << "assignment state is '#{state}' (not completed)" if state != "completed"
48
+ reasons << "assignment plan is empty" if steps.empty?
49
+ reasons << "#{steps.count { |s| s.status == 'failed' }} failed step(s)" if steps.any? { |s| s.status == "failed" }
50
+ pending = steps.count { |s| s.status == "pending" }
51
+ active = steps.count { |s| s.status == "active" }
52
+ reasons << "#{pending} pending step(s)" if pending.positive?
53
+ reasons << "#{active} active step(s)" if active.positive?
54
+ reasons
55
+ end
56
+
57
+ def to_h
58
+ {"id" => id, "name" => name, "state" => state, "progress" => progress,
59
+ "steps" => steps.map { |s| {"number" => s.number, "name" => s.name, "status" => s.status} },
60
+ "complete" => completion_verdict == :complete}
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Models
6
+ # Executor brief rendered by `ace-coworker start`.
7
+ class Brief
8
+ attr_reader :config, :instructions_source, :task_path, :assignment_snapshot, :project_root
9
+
10
+ def initialize(config:, instructions_source:, task_path:, assignment_snapshot:, project_root:)
11
+ @config = config
12
+ @instructions_source = instructions_source.freeze
13
+ @task_path = task_path.freeze
14
+ @assignment_snapshot = assignment_snapshot
15
+ @project_root = project_root.freeze
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ "role" => config.role,
21
+ "instructions" => config.instructions,
22
+ "instructions_source" => instructions_source,
23
+ "start_prompt" => config.start_prompt,
24
+ "task" => config.task,
25
+ "task_path" => task_path,
26
+ "assignment" => config.assignment,
27
+ "assignment_state" => assignment_snapshot&.state,
28
+ "assignment_progress" => assignment_snapshot&.progress,
29
+ "overseer" => config.overseer,
30
+ "reporting_channel" => config.reporting_channel,
31
+ "on_failure" => config.on_failure,
32
+ "project_root" => project_root
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Models
6
+ # Immutable validated coworker run configuration.
7
+ class Config
8
+ attr_reader :role, :instructions, :start_prompt, :start_prompt_file, :task, :assignment,
9
+ :overseer, :reporting_channel, :on_failure, :source_path
10
+
11
+ def initialize(role:, instructions:, start_prompt:, task:, assignment:, overseer:,
12
+ start_prompt_file: nil, reporting_channel: "hitl", on_failure: "report", source_path: nil)
13
+ @role = role.freeze
14
+ @instructions = instructions.freeze
15
+ @start_prompt = start_prompt.freeze
16
+ @start_prompt_file = start_prompt_file.freeze
17
+ @task = task.freeze
18
+ @assignment = assignment.freeze
19
+ @overseer = overseer.freeze
20
+ @reporting_channel = reporting_channel.freeze
21
+ @on_failure = on_failure.freeze
22
+ @source_path = source_path.freeze
23
+ end
24
+
25
+ # A copy with a different assignment, e.g. a --assignment CLI
26
+ # override threaded through report/resume. Single place so new
27
+ # fields can never drift between the two identities.
28
+ def with(assignment:)
29
+ return self if assignment == @assignment
30
+
31
+ self.class.new(role: role, instructions: instructions,
32
+ start_prompt: start_prompt, start_prompt_file: start_prompt_file,
33
+ task: task, assignment: assignment, overseer: overseer,
34
+ reporting_channel: reporting_channel, on_failure: on_failure,
35
+ source_path: source_path)
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ "role" => role,
41
+ "instructions" => instructions,
42
+ "start_prompt" => start_prompt,
43
+ "start_prompt_file" => start_prompt_file,
44
+ "task" => task,
45
+ "assignment" => assignment,
46
+ "overseer" => overseer,
47
+ "reporting" => {"channel" => reporting_channel},
48
+ "errors" => {"on_failure" => on_failure}
49
+ }.compact
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Coworker
5
+ module Models
6
+ # Immutable derived outcome of one reporting run.
7
+ class Outcome
8
+ StepRef = Struct.new(:number, :name, keyword_init: true)
9
+
10
+ attr_reader :result, :verdict, :cause, :step, :unfinished_steps, :evidence, :snapshot
11
+
12
+ def initialize(result:, verdict:, cause: nil, step: nil, unfinished_steps: [], evidence: {}, snapshot: nil)
13
+ @result = result.freeze
14
+ @verdict = verdict
15
+ @cause = cause.freeze
16
+ @step = step
17
+ @unfinished_steps = unfinished_steps.map(&:freeze).freeze
18
+ @evidence = evidence.freeze
19
+ @snapshot = snapshot
20
+ end
21
+
22
+ def success?
23
+ result == "success"
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ "result" => result,
29
+ "verdict" => verdict.to_s,
30
+ "cause" => cause,
31
+ "step" => step && {"number" => step.number, "name" => step.name},
32
+ "unfinished_steps" => unfinished_steps,
33
+ "evidence" => evidence
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end