ace-overseer 0.26.1 → 0.28.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.
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: overseer-lab-start
3
+ description: Standing instructions for a started LAB overseer session (aggregate project readiness, escalate across projects, delegate to project overseers)
4
+ allowed-tools: Bash, Read, Write, Edit
5
+ doc-type: workflow
6
+ title: Lab Overseer Start Guide
7
+ purpose: Define the lab overseer's coordination role across projects — readiness aggregation, cross-project escalation, delegation to project overseers — without taking over any project's tasks or private state.
8
+ ace-docs:
9
+ last-updated: '2026-10-30'
10
+ last-checked: '2026-10-30'
11
+ ---
12
+ # Lab Overseer Start Guide
13
+
14
+ ## Goal
15
+
16
+ You are the LAB overseer started by `ace-overseer start` with `scope: lab`
17
+ in the lab coordination directory. Your role is coordination ACROSS
18
+ projects: ensuring each configured project has a healthy overseer session,
19
+ aggregating their status and escalations, and routing the Captain's goals to
20
+ the right project. You never take over a project's tasks, worktrees, or
21
+ private `_overseer` state — those belong to each project's overseer.
22
+
23
+ You own the OUTCOME of routine startup, never its execution. Configuration,
24
+ Bundler installation, dependency repairs and binstub generation are WORKER
25
+ work. Create a commission and dispatch a separate lab worker even when the
26
+ project overseer cannot start yet. Do not edit project configuration, install
27
+ packages or run repair commands yourself. Missing workers are a concrete
28
+ capability gap, not a reason to become the worker.
29
+
30
+ ## First turn after start
31
+
32
+ `ace-overseer start` is the bootstrap entry point. It launches your Pi
33
+ process directly in the current terminal and directory, with this Lab role
34
+ and context. It performs no Herdr agent, owner or layout preflight. A missing `.ace/overseer/config.yml` is first-run setup, not a request
35
+ for the Captain to write YAML. The packaged bootstrap profile is
36
+ `pi`, `zai/glm-5.3-flash`, thinking `high`; explicit profiles take precedence
37
+ and an invalid explicit configuration never silently falls back.
38
+
39
+ The Captain's latest instruction outranks this guide and stored tasks or plans.
40
+ On this first turn, establish your own configuration and the commissioned
41
+ project overseers before resuming unrelated stored backlog. Read old
42
+ `_overseer` tasks as context; do not dispatch old auth, publication or full
43
+ acceptance work while the current instruction is bootstrap and project start.
44
+ Report scope conflicts to the host operator instead of negotiating away a new
45
+ Captain order.
46
+
47
+ 1. Read the lab topology, current private tasks and real assignment/session
48
+ state. Resume this commissioned bootstrap before unrelated backlog. Resolve
49
+ actual project paths; ask only about ambiguous scope or missing authority.
50
+ 2. Read `wfi://overseer/bootstrap-delegation` through `ace-bundle` and follow
51
+ its complete task → worker → observation → verification → start procedure.
52
+ 3. Arm observation with the `ace_eyes` tool, action `start`, using the lab's
53
+ eyes configuration path. Check enabled=true, breaker=null, errors=0 and
54
+ a successful observation before dispatch. A missing tool/configuration is
55
+ a readiness gap. Writing `/eyes` in prose does not arm observation.
56
+ 4. Use `ace-overseer start --reconcile` to inspect/start commissioned project
57
+ sessions. Bare `ace-overseer start` launches a foreground harness; never
58
+ call it recursively from your own session. Delegate preparation or repair
59
+ of blocked projects to a lab worker through the bootstrap procedure.
60
+ 5. After dispatch, record the real assignment and agent identity and END YOUR
61
+ TURN. Do not sleep-poll, run repeated status commands, or wait synchronously
62
+ for the worker. A semantic change wakes you; unchanged state stays silent.
63
+
64
+ ## Delegation and escalation
65
+
66
+ - Route goals to a project by working through THAT project's overseer (its
67
+ session is labeled `overseer-<project-root>`); you do not dispatch its
68
+ coworkers directly for product work. Lab bootstrap workers are the explicit
69
+ exception: they prepare a commissioned project whose overseer cannot start.
70
+ - Cross-project blockers (shared forge access, missing host capability, a
71
+ project repeatedly failing) escalate to the Captain through `ace-hitl`
72
+ with the per-project evidence.
73
+ - One project's failure never blocks the healthy ones: keep their work
74
+ flowing and report the failure separately.
75
+
76
+ ## Boundaries
77
+
78
+ - The lab root is a coordination directory with its own `_overseer`; project
79
+ locations come from the lab topology (`ace-lab`), never from names.
80
+ - Idle is correct: when every project overseer is healthy and no cross-
81
+ project work exists, record that state and wait.
82
+ - Capabilities not yet delivered are reported as gaps, not simulated.
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: overseer-start
3
+ description: Standing instructions for a started project overseer session (coordination, delegation, escalation, idle) loaded by ace-overseer start
4
+ allowed-tools: Bash, Read
5
+ doc-type: workflow
6
+ title: Project Overseer Start Guide
7
+ purpose: Define the started project overseer's role boundaries, observation and delegation loop, HITL escalation, and completion semantics; the overseer coordinates and never substitutes for a missing coworker.
8
+ ace-docs:
9
+ last-updated: '2026-10-30'
10
+ last-checked: '2026-10-30'
11
+ ---
12
+ # Project Overseer Start Guide
13
+
14
+ ## Goal
15
+
16
+ You are the project overseer started by `ace-overseer start` with this guide as
17
+ your standing instructions. Your role is COORDINATION: reading real state,
18
+ choosing the next action, delegating execution to coworkers, and escalating to
19
+ the Captain through HITL. You do not implement, review, or publish in place of
20
+ a missing coworker — you report the gap and escalate.
21
+
22
+ ## First turn after start
23
+
24
+ 1. Read your private task list: `ace-task --in _overseer list` (your
25
+ coordination tasks live there; you own them).
26
+ 2. Read project truth from the state owners — never a second cache:
27
+ `ace-task list`, `ace-overseer status`, `ace-git-worktree list`,
28
+ `git status --short`, and pending escalations with `ace-hitl list`.
29
+ 3. Continue EXISTING commissioned work before choosing anything new.
30
+ 4. When the lists are empty and no report awaits processing: record that state
31
+ and remain idle. Silence is a correct state; do not invent work.
32
+
33
+ ## Delegation
34
+
35
+ - Prepare work through the owners: task (`ace-task`), worktree
36
+ (`ace-git-worktree`), assignment (`ace-assign`), and the coworker roles
37
+ (`ace-coworker prepare`). Never hand-edit another owner's files.
38
+ - Delegate to the coworker roles with scope, context, completion criteria, and
39
+ the access the task grants. A missing coworker or missing permission is
40
+ REPORTED (and escalated when blocking) — never filled by doing the work
41
+ yourself.
42
+ - Merge gate: executed tests plus an independent reviewer verdict. CI is
43
+ advisory.
44
+
45
+ ## Escalation (HITL)
46
+
47
+ - Decisions outside your granted autonomy go to the Captain:
48
+ `ace-hitl create` with a precise question, then end your turn armed for the
49
+ wake. Blocking wait is an emergency path only.
50
+ - Answers return through the same case; apply them once and close the case.
51
+
52
+ ## Standing observation
53
+
54
+ After start, arm `/eyes` with the project's eyes configuration (interval 15s is
55
+ the default reconcile cadence). `ace-overseer observe` — what eyes polls —
56
+ projects the six local source classes from their owners: project tasks,
57
+ private `_overseer` tasks, worktrees, coworker availability (existing run
58
+ identifiers, never pane names), assignment progress and HITL events. A real
59
+ revision wakes you once; identical state stays silent; a degraded source is
60
+ visible data (`source-health` rows and the `sources` map), never an empty
61
+ healthy list. After a restart, reconcile pending revisions first — a report
62
+ that arrived in your absence surfaces as an unacknowledged revision — then
63
+ choose work by the existing plan/dependency order (`ace-overseer
64
+ process-reports`, `ace-overseer work-on`). Acknowledge (`/eyes ack KEY
65
+ REVISION`) only AFTER the proper task/assignment accepted the work: model
66
+ settlement alone is not proof of handling.
67
+
68
+ ## Boundaries
69
+
70
+ - One overseer per project: you own the session label you were started with.
71
+ Another start reuses you; you never spawn a second whole-project overseer.
72
+ - Access is limited to what the project configuration and the delegated task
73
+ grant. Credentials and OTP values never enter prompts, reports, or files.
74
+ - Remote PR observation (observe.remote.pull_requests) and the HITL reply
75
+ receiver (`ace-hitl reply CASE --stdin`) are delivered surfaces: watch the
76
+ configured remote sources through them and report what you actually see.
77
+ Capabilities genuinely not yet delivered (e.g. the Hermes transport
78
+ producer on the host) are explicitly not available in this slice; do not
79
+ simulate them. Missing capability is a reported gap, not a workaround.
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Overseer
5
+ module Atoms
6
+ # Validate explicit start configuration, or select the packaged
7
+ # first-run profile when no start section exists. A malformed explicit
8
+ # configuration never falls back to another model or harness.
9
+ class StartContract
10
+ SCOPES = %w[project lab].freeze
11
+ SECTION_KEYS = %w[scope profile instructions].freeze
12
+ PROFILE_KEYS = %w[harness model thinking].freeze
13
+ INSTRUCTION_PROTOCOLS = %w[wfi:// prompt:// tmpl://].freeze
14
+
15
+ # @param config [Hash] resolved overseer config (cascade output)
16
+ # @return [Hash] normalized start plan fragment:
17
+ # {scope:, profile_name:, profile: {harness:, model:,
18
+ # thinking:}, instructions:}
19
+ # @raise [Ace::Overseer::Error] missing/ambiguous/invalid start
20
+ # configuration
21
+ BOOTSTRAP_PROFILE = {"harness" => "pi", "model" => "zai/glm-5.3-flash",
22
+ "thinking" => "high"}.freeze
23
+
24
+ # A missing start configuration is first-run setup, not a reason to
25
+ # prevent the coordinator that owns setup from starting. Explicit
26
+ # configurations are still validated without fallback.
27
+ def self.bootstrap(config, scope: "lab")
28
+ return config if config.key?("start")
29
+
30
+ profiles = config.fetch("profiles", {})
31
+ raise Ace::Overseer::Error, "profiles must be a mapping" unless profiles.is_a?(Hash)
32
+
33
+ config.merge("start" => {"scope" => scope, "profile" => "overseer-default",
34
+ "instructions" => "wfi://overseer/#{(scope == "lab") ? "lab-start" : "start"}"},
35
+ "profiles" => {"overseer-default" => BOOTSTRAP_PROFILE}.merge(profiles))
36
+ end
37
+
38
+ def self.normalize(config)
39
+ config = bootstrap(config)
40
+ start = config["start"]
41
+ unless start.is_a?(Hash)
42
+ raise Ace::Overseer::Error,
43
+ "ace-overseer start requires an explicit 'start' section in " \
44
+ ".ace/overseer/config.yml (scope, profile, instructions); " \
45
+ "none is configured for this directory"
46
+ end
47
+
48
+ unknown = start.keys - SECTION_KEYS
49
+ unless unknown.empty?
50
+ raise Ace::Overseer::Error,
51
+ "start section has unknown key '#{unknown.sort.first}' " \
52
+ "(allowed: #{SECTION_KEYS.join(", ")})"
53
+ end
54
+
55
+ scope = required(start, "scope")
56
+ unless SCOPES.include?(scope)
57
+ raise Ace::Overseer::Error,
58
+ "start scope must be one of: #{SCOPES.join(", ")} (got '#{scope}')"
59
+ end
60
+
61
+ profile_name = required(start, "profile")
62
+ profile = profile_for(config, profile_name)
63
+
64
+ instructions = required(start, "instructions")
65
+ unless INSTRUCTION_PROTOCOLS.any? { |prefix| instructions.start_with?(prefix) } ||
66
+ instructions.start_with?("/")
67
+ raise Ace::Overseer::Error,
68
+ "start instructions must be a wfi:// (or prompt://, tmpl://) " \
69
+ "source or an absolute file path"
70
+ end
71
+
72
+ {"scope" => scope, "profile_name" => profile_name,
73
+ "profile" => profile, "instructions" => instructions}
74
+ end
75
+
76
+ # The pane/tab label that OWNS this project's overseer session:
77
+ # derived from the project root so every start from the same
78
+ # repository (subdirectory, symlink, worktree) converges on the
79
+ # same owner identity.
80
+ def self.session_label(project_root)
81
+ "overseer-#{File.basename(project_root)}"
82
+ end
83
+
84
+ def self.profile_for(config, profile_name)
85
+ profiles = config["profiles"]
86
+ unless profiles.is_a?(Hash) && profiles[profile_name].is_a?(Hash)
87
+ raise Ace::Overseer::Error,
88
+ "start profile '#{profile_name}' is not configured; declare it " \
89
+ "under 'profiles' in .ace/overseer/config.yml"
90
+ end
91
+
92
+ raw = profiles[profile_name]
93
+ unknown = raw.keys - PROFILE_KEYS
94
+ unless unknown.empty?
95
+ raise Ace::Overseer::Error,
96
+ "profile '#{profile_name}' has unknown key '#{unknown.sort.first}' " \
97
+ "(allowed: #{PROFILE_KEYS.join(", ")})"
98
+ end
99
+
100
+ harness = required_value(raw, "harness", "profile '#{profile_name}'")
101
+ model = required_value(raw, "model", "profile '#{profile_name}'")
102
+ thinking = raw["thinking"]
103
+
104
+ {"harness" => harness, "model" => model, "thinking" => thinking}
105
+ end
106
+
107
+ def self.required(start, key)
108
+ value = start[key]
109
+ unless value.is_a?(String) && !value.strip.empty?
110
+ raise Ace::Overseer::Error,
111
+ "start section key '#{key}' is required and must be a non-empty string"
112
+ end
113
+
114
+ value.strip
115
+ end
116
+
117
+ def self.required_value(raw, key, owner)
118
+ value = raw[key]
119
+ unless value.is_a?(String) && !value.strip.empty?
120
+ raise Ace::Overseer::Error, "#{owner} key '#{key}' is required"
121
+ end
122
+
123
+ value.strip
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Overseer
7
+ module CLI
8
+ module Commands
9
+ # `ace-overseer start [--dry-run] [--format table|json]`
10
+ # (task 8xs.t.pu0.1): starts or reuses the project overseer
11
+ # session from the directory's explicit start configuration.
12
+ # Exit 0 only for started/reused (dry-run reports the plan with
13
+ # exit 0 but creates nothing); a blocked start is a CLI error.
14
+ class Start < Ace::Support::Cli::Command
15
+ include Ace::Support::Cli::Base
16
+
17
+ desc "Start or reuse the project overseer session from .ace/overseer/config.yml"
18
+
19
+ option :reconcile, type: :boolean, default: false, desc: "Explicitly reconcile configured project overseers without restarting the lab coordinator"
20
+ option :scope, type: :string, desc: "Coordinator role (lab or project); omitted means lab bootstrap"
21
+ option :dry_run, type: :boolean, default: false, desc: "Show the start plan without creating a session"
22
+ option :format, default: "table", desc: "Output format (table, json)"
23
+ option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
24
+ option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
25
+
26
+ def initialize(orchestrator: nil, config: nil, launcher: nil)
27
+ super()
28
+ @orchestrator = orchestrator
29
+ @config = config
30
+ @launcher = launcher || ->(argv) { Kernel.exec(*argv) }
31
+ end
32
+
33
+ def call(dry_run: false, format: "table", scope: nil, reconcile: false, **options)
34
+ unless scope.nil? || Atoms::StartContract::SCOPES.include?(scope)
35
+ raise Ace::Support::Cli::Error, "scope must be lab or project"
36
+ end
37
+ if reconcile && scope == "project"
38
+ raise Ace::Support::Cli::Error, "--reconcile requires lab scope"
39
+ end
40
+ operation = orchestrator(scope: scope)
41
+ report = if reconcile
42
+ operation.reconcile(dry_run: dry_run)
43
+ elsif scope
44
+ operation.call(dry_run: dry_run)
45
+ else
46
+ operation.bootstrap(dry_run: dry_run)
47
+ end
48
+ return @launcher.call(report.fetch("command")) if report["status"] == "foreground"
49
+
50
+ if options[:quiet]
51
+ # quiet still fails loudly on a blocked start
52
+ elsif format == "json"
53
+ puts JSON.pretty_generate(report)
54
+ else
55
+ render_table(report)
56
+ end
57
+
58
+ case report["status"]
59
+ when "started", "reused", "ready", "dry_run" then nil
60
+ else
61
+ raise Ace::Support::Cli::Error, report["message"] || "start blocked"
62
+ end
63
+ rescue Ace::Support::Cli::Error
64
+ raise
65
+ rescue => e
66
+ raise Ace::Support::Cli::Error.new(e.message)
67
+ end
68
+
69
+ private
70
+
71
+ def orchestrator(scope: nil)
72
+ @orchestrator || Organisms::StartOrchestrator.new(config: @config || Ace::Overseer.config, scope: scope)
73
+ end
74
+
75
+ def render_table(report)
76
+ puts "ace-overseer start: #{report["status"]}"
77
+ puts " scope: #{report["scope"] || "-"}"
78
+ puts " root: #{report["root"] || "-"}"
79
+ puts " label: #{report["label"] || "-"}"
80
+ puts " profile: #{report["profile"] || "-"}"
81
+ if report["instructions"]
82
+ puts " instructions: #{report["instructions"]["source"]} -> #{report["instructions"]["path"]}"
83
+ end
84
+ puts " workspace: #{report["workspace"] || "-"}"
85
+ puts " pane: #{report["pane"] || "-"}"
86
+ puts " agent: #{report["agent"]}"
87
+ puts " replaced pane: #{report["replaced_pane"] || "-"}" if report["replaced_pane"]
88
+ puts " #{report["message"]}" if report["message"]
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -10,6 +10,7 @@ require_relative "cli/commands/observe"
10
10
  require_relative "cli/commands/prune"
11
11
  require_relative "cli/commands/projects"
12
12
  require_relative "cli/commands/agents"
13
+ require_relative "cli/commands/start"
13
14
 
14
15
  module Ace
15
16
  module Overseer
@@ -37,6 +38,7 @@ module Ace
37
38
  "ace-overseer prune # Clean up finished tasks"
38
39
  ].freeze
39
40
 
41
+ register "start", Commands::Start
40
42
  register "work-on", Commands::WorkOn
41
43
  register "handoff", Commands::Handoff
42
44
  register "process-reports", Commands::ProcessReports
@@ -1,20 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
  require "ace/hitl/molecules/event_observation"
3
+ require "digest"
3
4
 
4
5
  module Ace
5
6
  module Overseer
6
7
  module Molecules
7
- # Read-only adoption filter. HITL owns semantic revisions; the existing
8
- # private task tree owns report adoption and continuation, never this view.
8
+ # Read-only standing observation (task 8xs.t.pu0.3). HITL owns
9
+ # semantic revisions; the existing private task tree owns report
10
+ # adoption and continuation, never this view. The projection arms
11
+ # the started overseer with the six local source classes — HITL
12
+ # events plus LocalSources (project tasks, _overseer tasks,
13
+ # worktrees, coworker availability, assignment progress) — every
14
+ # row read from its state owner, with per-source health so a
15
+ # failing source degrades only itself and healthy silence projects
16
+ # identical revisions (no model turns).
9
17
  class EventObservation
10
- def initialize(hitl: nil, task_manager: nil)
18
+ def initialize(hitl: nil, task_manager: nil, local_sources: nil, remote_sources: nil,
19
+ config: nil)
11
20
  @hitl = hitl || Ace::Hitl::Molecules::EventObservation.new
12
21
  @tasks = task_manager || Ace::Task::Organisms::TaskManager.new
22
+ @local_sources = local_sources || LocalSources.new(task_manager: @tasks)
23
+ @remote_sources = remote_sources || RemoteSources.new
24
+ @config = config || Ace::Overseer.config
13
25
  end
14
26
 
15
27
  def call(owner:, scope: "current", limit: 500)
16
- projection = @hitl.call(owner: owner, scope: scope, limit: limit)
28
+ projection = nil
29
+ hitl_error = nil
30
+ begin
31
+ projection = @hitl.call(owner: owner, scope: scope, limit: limit)
32
+ rescue StandardError => e
33
+ hitl_error = e
34
+ end
35
+
36
+ local = @local_sources.call
37
+ remote = remote_projection
17
38
  managers = @tasks.list(in_folder: "all").select { |task| !task.metadata["project_task"].to_s.empty? }
39
+
40
+ if projection.nil?
41
+ # A degraded HITL source degrades only itself: the local
42
+ # sources keep projecting and the failure is visible data.
43
+ events = local.fetch("events") + remote.fetch("events") + [health_marker("hitl", hitl_error)]
44
+ sources = local.fetch("sources").merge(remote.fetch("sources")).merge(
45
+ "hitl" => {"status" => "degraded", "error" => "#{hitl_error.class}: #{hitl_error.message}"}
46
+ )
47
+ ensure_budget!(events.length, limit)
48
+ return {"schema" => "ace.hitl.observation/v1",
49
+ "events" => wire_events(events).sort_by { |row| row["key"] },
50
+ "settled" => [], "sources" => sources}
51
+ end
52
+
18
53
  settled, pending = projection.fetch("events").partition do |event|
19
54
  next false unless event["type"] == "report"
20
55
  verdict = Atoms::ReportInterpretation.evaluate(event: event, managers: managers)
@@ -25,7 +60,50 @@ module Ace
25
60
  # the same attempt. Only completed trees suppress historical reports.
26
61
  manager && ([manager] + Array(manager.subtasks)).all? { |task| Ace::Task::Atoms::TaskValidationRules.terminal_status?(task.status.to_s) }
27
62
  end
28
- projection.merge("events" => pending, "settled" => settled)
63
+
64
+ ensure_budget!(pending.length + settled.length + local.fetch("events").length + remote.fetch("events").length, limit)
65
+ projection.merge("events" => (pending + wire_events(local.fetch("events") + remote.fetch("events")))
66
+ .sort_by { |row| row["key"] },
67
+ "settled" => settled,
68
+ "sources" => local.fetch("sources").merge(remote.fetch("sources"))
69
+ .merge("hitl" => {"status" => "ok"}))
70
+ end
71
+
72
+ private
73
+
74
+ def ensure_budget!(count, limit)
75
+ return if count <= limit
76
+
77
+ raise ArgumentError, "observation has #{count} rows, exceeding limit #{limit}; increase maxEvents/--limit or archive accepted history before arming eyes"
78
+ end
79
+
80
+ # Local owners expose readable keys, whereas eyes consumes the HITL
81
+ # wire envelope (hashed key, revision and a safe id). Normalize only
82
+ # local/remote rows; canonical HITL identities remain untouched.
83
+ def wire_events(rows)
84
+ rows.map do |row|
85
+ key = Digest::SHA256.hexdigest("overseer:#{row.fetch('key')}")
86
+ row.merge("source_key" => row.fetch("key"), "source_id" => row["id"],
87
+ "key" => key, "id" => key)
88
+ end
89
+ end
90
+
91
+ # Configured remote observation (8xs.t.pu0.4): the project's own
92
+ # observe.remote.pull_requests list — never a scan of accessible
93
+ # accounts. No configuration means no remote rows and no remote
94
+ # source to degrade.
95
+ def remote_projection
96
+ entries = Array(@config.dig("observe", "remote", "pull_requests"))
97
+ return {"events" => [], "sources" => {}} if entries.empty?
98
+
99
+ @remote_sources.call(pull_requests: entries)
100
+ end
101
+
102
+ def health_marker(name, error)
103
+ {"key" => "source:#{name}",
104
+ "revision" => Digest::SHA256.hexdigest("degraded:#{error.class}"),
105
+ "type" => "source-health", "status" => "degraded",
106
+ "error" => "#{error.class}: #{error.message}"}
29
107
  end
30
108
  end
31
109
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Overseer
8
+ module Molecules
9
+ # Standing local-state observation (task 8xs.t.pu0.3): projects the
10
+ # six local source classes the started overseer watches — project
11
+ # tasks, private _overseer tasks, worktrees, coworker availability,
12
+ # assignment progress and HITL events — each read from its STATE
13
+ # OWNER (ace-task, ace-git-worktree, ace-coworker probe, ace-assign
14
+ # inventory, ace-hitl). This molecule owns no second state: every
15
+ # row is a {key, revision, type} projection whose revision changes
16
+ # only when the owner's data changes, so a consumer (eyes) wakes on
17
+ # real revisions and stays silent on identical state.
18
+ #
19
+ # A failing source degrades ONLY itself: it contributes a
20
+ # `source-health` marker whose revision is stable per error class
21
+ # (an identical repeated failure never re-wakes; a new failure mode
22
+ # wakes once), while the healthy sources keep projecting.
23
+ class LocalSources
24
+ def initialize(task_manager: nil, worktree_manager: nil, probe_factory: nil)
25
+ @tasks = task_manager || Ace::Task::Organisms::TaskManager.new
26
+ @worktrees = worktree_manager || Ace::Git::Worktree::Organisms::WorktreeManager.new
27
+ @probe_factory = probe_factory || ->(path) {
28
+ Ace::Coworker::Molecules::WorktreeExecutorProbe.new(project_root: path)
29
+ }
30
+ end
31
+
32
+ # @return [Hash] {"events" => Array<{key, revision, type, ...}>,
33
+ # "sources" => {name => {"status" => "ok"|"degraded", "error"?}}}
34
+ def call
35
+ sources = {}
36
+ events = []
37
+
38
+ collect(sources, events, "project_tasks") { project_task_events }
39
+ collect(sources, events, "overseer_tasks") { overseer_task_events }
40
+ collect(sources, events, "worktrees") { worktree_events }
41
+ collect(sources, events, "coworkers") { coworker_events }
42
+
43
+ {"events" => events, "sources" => sources}
44
+ end
45
+
46
+ private
47
+
48
+ def collect(sources, events, name)
49
+ rows = yield
50
+ sources[name] = {"status" => "ok"}
51
+ events.concat(rows)
52
+ rescue StandardError => e
53
+ sources[name] = {"status" => "degraded", "error" => "#{e.class}: #{e.message}"}
54
+ events.concat([health_marker(name, e)])
55
+ end
56
+
57
+ # A degraded source surfaces as missing data — visibly, never as
58
+ # an empty healthy list. The revision is keyed by error CLASS so
59
+ # an identical repeated failure keeps the same revision (no
60
+ # wake spam) while a new failure mode revises once.
61
+ def health_marker(name, error)
62
+ {"key" => "source:#{name}",
63
+ "revision" => Digest::SHA256.hexdigest("degraded:#{error.class}"),
64
+ "type" => "source-health", "status" => "degraded",
65
+ "error" => "#{error.class}: #{error.message}"}
66
+ end
67
+
68
+ def revision(payload)
69
+ Digest::SHA256.hexdigest(JSON.generate(payload))
70
+ end
71
+
72
+ def project_task_events
73
+ task_events(@tasks.list(in_folder: "next"), "task")
74
+ end
75
+
76
+ def overseer_task_events
77
+ task_events(@tasks.list(in_folder: "_overseer"), "overseer-task")
78
+ end
79
+
80
+ # Task rows revision on real lifecycle facts (status, dependency
81
+ # set, subtask statuses) — not on cosmetic frontmatter churn.
82
+ def task_events(tasks, type)
83
+ tasks.map do |task|
84
+ payload = {"id" => task.id, "status" => task.status,
85
+ "dependencies" => Array(task.dependencies).sort,
86
+ "subtasks" => Array(task.subtasks).map { |sub| [sub.id, sub.status] }.sort}
87
+ {"key" => "#{type}:#{task.id}", "revision" => revision(payload),
88
+ "type" => type, "id" => task.id, "status" => task.status,
89
+ "title" => task.title, "dependencies" => Array(task.dependencies).sort}
90
+ end
91
+ end
92
+
93
+ def worktree_events
94
+ result = @worktrees.list_all
95
+ raise Error, result[:error] || "failed to list worktrees" unless result[:success]
96
+
97
+ Array(result[:worktrees]).reject(&:bare).map do |worktree|
98
+ payload = {"path" => worktree.path, "branch" => worktree.branch}
99
+ {"key" => "worktree:#{Digest::SHA256.hexdigest(worktree.path)}",
100
+ "revision" => revision(payload), "type" => "worktree",
101
+ "path" => worktree.path, "branch" => worktree.branch}
102
+ end
103
+ end
104
+
105
+ # Coworker availability AND assignment progress in one pass: the
106
+ # executor probe reads the worktree's commissioned config and the
107
+ # ace-assign inventory (existing run identifiers — assignment
108
+ # ids, never pane names), the ActiveExecutorRule decides live vs
109
+ # stale, and each in-flight assignment projects its own progress
110
+ # row so a step report revises that assignment only.
111
+ def coworker_events
112
+ result = @worktrees.list_all
113
+ raise Error, result[:error] || "failed to list worktrees" unless result[:success]
114
+
115
+ Array(result[:worktrees]).reject(&:bare).flat_map do |worktree|
116
+ probe = @probe_factory.call(worktree.path)
117
+ report = probe.probe(worktree.path)
118
+ verdict = Ace::Coworker::Atoms::ActiveExecutorRule.evaluate(
119
+ config: report[:config], assignments: report[:assignments]
120
+ )
121
+ identity = Digest::SHA256.hexdigest(worktree.path)
122
+ executor = {"key" => "executor:#{identity}",
123
+ "revision" => revision({"path" => worktree.path,
124
+ "active" => verdict[:active],
125
+ "evidence" => verdict[:evidence]}),
126
+ "type" => "executor", "path" => worktree.path,
127
+ "active" => verdict[:active], "evidence" => verdict[:evidence]}
128
+ assignments = report[:assignments].map do |assignment|
129
+ {"key" => "assignment:#{assignment["id"]}",
130
+ "revision" => revision({"id" => assignment["id"], "state" => assignment["state"],
131
+ "progress" => assignment["progress"]}),
132
+ "type" => "assignment", "id" => assignment["id"],
133
+ "state" => assignment["state"], "progress" => assignment["progress"]}
134
+ end
135
+ [executor] + assignments
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end