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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/docs/usage.md +225 -1
- data/handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md +88 -0
- data/handbook/workflow-instructions/overseer/cold-cycle-acceptance.wf.md +95 -0
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +82 -0
- data/handbook/workflow-instructions/overseer/start.wf.md +79 -0
- data/lib/ace/overseer/atoms/start_contract.rb +128 -0
- data/lib/ace/overseer/cli/commands/start.rb +94 -0
- data/lib/ace/overseer/cli.rb +2 -0
- data/lib/ace/overseer/molecules/event_observation.rb +83 -5
- data/lib/ace/overseer/molecules/local_sources.rb +141 -0
- data/lib/ace/overseer/molecules/project_bundle.rb +119 -0
- data/lib/ace/overseer/molecules/project_root_resolver.rb +66 -0
- data/lib/ace/overseer/molecules/remote_sources.rb +139 -0
- data/lib/ace/overseer/organisms/start_orchestrator.rb +666 -0
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +25 -0
- metadata +17 -12
|
@@ -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
|
data/lib/ace/overseer/cli.rb
CHANGED
|
@@ -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
|
|
8
|
-
# private task tree owns report
|
|
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 =
|
|
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
|
-
|
|
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
|