ace-overseer 0.27.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5204d8b317b630a73e16ddc196fc90c3d6efa1e0f39a62406e1b6f7bd6de4c2e
4
- data.tar.gz: 5293bfde27b37ca690442f2ffcc60446913ec54052b2426b47d82c55d48c1614
3
+ metadata.gz: c0228801ebed27e7b1fca7fe8f580dc4fdc2a5a5be9e8d76d3c6cf59b1f8cf48
4
+ data.tar.gz: 2fae06ba1fc9174fc41f53f3ddcbcc1ee8dddd0152faaaa7e894a862d6f0e8c5
5
5
  SHA512:
6
- metadata.gz: 96747e1a1d70f884fb302110f0f12197b81a4b6a7701ce0ef0aeb000581daaae7bc6ccb7660f15fdf1ef067e6c879c0e3c86e774881b99ffdd351ec82204a740
7
- data.tar.gz: 61e8da9b71790c8f7f5252e61652d4d7af4dd881d4bb8e716504c84ee60cfc1af3e784f0c5d8172d096e471d353d010e674984a9cee99d1a0b63e1e40d0c9eac
6
+ metadata.gz: 2ede68b20a4104eeb494014ca34a05627e8367aa5d1b6cf71cbd5f8fcfa0e4168e2029c3ca78be121e353e3e24a25bc0dd0c5ae153850b9797a7bff5bf504e6c
7
+ data.tar.gz: 611af27c6c2b7febc096cefeaff5545d8f3ce7fd1dd9e377932371359c850ec0e1f757476d9ffba937135f3ea021bd87f4c268007ab81b0e571c1723b48c4d88
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.28.0] - 2026-10-31
11
+
12
+ ### Fixed
13
+
14
+ - Delegate lab bootstrap to workers and independent verification; distinguish
15
+ preparation/printed briefs from running agents and confirmed first turns.
16
+ - Preserve installed operational runtime across project starts.
17
+ - Normalize local observation identities for the eyes wire contract.
18
+
19
+ ## [0.28.0.pre.3] - 2026-10-31
20
+
21
+ ### Fixed
22
+
23
+ - Child project bundles no longer inherit a forced Lab `ACE_TOOLCHAIN` mode;
24
+ project-selected path/source dependencies and explicit child policies remain
25
+ authoritative. The Lab role may perform routine project startup setup without
26
+ taking over project deliverables.
27
+ - `ace-overseer start` bootstraps missing start settings and becomes the Lab
28
+ coordinator in the current terminal without Herdr preflight. Explicit
29
+ `start --reconcile` manages project children after agent initialization. The coordinator can configure itself using the packaged guide.
30
+ - Lab and project workspaces use `overseer` and topology ids such as `ace`,
31
+ independently of session ownership labels. Project startup executes each
32
+ project's own Gemfile/lock and binstub in a separate process; project panes
33
+ use their own bundle instead of inheriting the Lab's gem runtime.
34
+
8
35
  ## [0.27.0] - 2026-10-30
9
36
 
10
37
  ### Fixed (review rounds 3/3b)
data/docs/usage.md CHANGED
@@ -32,14 +32,21 @@ authority execution model. tmux and Herdr are transports, never state owners.
32
32
 
33
33
  ## `ace-overseer start`
34
34
 
35
- Start or reuse the PROJECT overseer session from the directory's own
36
- configuration (task 8xs.t.pu0.1). The start contract is explicit — nothing is
37
- guessed and no interim default model applies:
35
+ Run `ace-overseer start` at a shell in the Lab's existing Herdr `overseer`
36
+ space. The command becomes the Lab's Pi agent directly in that same terminal
37
+ and cwd, without any Herdr environment, agent, owner or layout preflight. The agent receives the packaged Lab guide
38
+ and a first-turn prompt to delegate configuration of commissioned projects
39
+ using installed CLI, skills and normal configuration editing.
40
+
41
+ No local start YAML is required for first run. The packaged bootstrap profile
42
+ is `pi`, `zai/glm-5.3-flash`, thinking `high`, in Lab scope. An explicit start
43
+ section overrides bootstrap and is validated without fallback. For an
44
+ explicit project start use:
38
45
 
39
46
  ```yaml
40
47
  # .ace/overseer/config.yml (project tier of the cascade)
41
48
  start:
42
- scope: project # project | lab (lab: not implemented yet)
49
+ scope: project # project | lab
43
50
  profile: overseer-default # reference into the profiles map below
44
51
  instructions: wfi://overseer/start # handbook source (or absolute file path)
45
52
  profiles:
@@ -53,6 +60,8 @@ profiles:
53
60
  |--------|---------|
54
61
  | `--dry-run` | Show the start plan; no session, no sync, no model call |
55
62
  | `--format` | `table` (default) or `json` |
63
+ | `--reconcile` | Explicitly reconcile project children after the agent configures the Lab; never restart the coordinator |
64
+ | `--scope` | Explicit `lab` or `project` role; an unconfigured bare start bootstraps the Lab |
56
65
 
57
66
  Behavior:
58
67
 
@@ -86,15 +95,37 @@ Behavior:
86
95
  - The report carries `status` (`started|reused|blocked|dry_run`), `scope`,
87
96
  `root`, `label`, `workspace`, `pane`, `agent`, `profile`, `instructions`
88
97
  (`source` + resolved `path`) and a `message`. Exit 0 only for a ready
89
- start/reuse (dry-run included); a blocked start is a CLI error naming the
90
- missing configuration.
98
+ start/reuse (dry-run included); a blocked start is a CLI error naming the concrete invalid configuration
99
+ or unavailable capability.
91
100
  - The started overseer coordinates and delegates (guide:
92
101
  `wfi://overseer/start`); it never substitutes for a missing coworker.
93
102
 
103
+ ### Project-local bundles
104
+
105
+ In auto mode each project owns its Gemfile/lock and binstubs. Child starts
106
+ clear parent Bundler/Ruby injection and select the project's bundle.
107
+
108
+ Explicit `ACE_TOOLCHAIN=installed` is preserved instead: the installed
109
+ Toolchain launcher and gem closure start the child without reading its
110
+ Gemfile or executing its binstub. Missing installed dependencies fail visibly;
111
+ there is no fallback to checkout code. Terminal backend selection does not
112
+ change toolchain mode.
113
+
114
+ The lab overseer delegates bundle/configuration preparation to a lab worker;
115
+ it never edits the project's binstub or installs gems itself. Follow
116
+ `wfi://overseer/bootstrap-delegation` for the task, worker, independent
117
+ verification and observed completion cycle. `ace-coworker start` prints a
118
+ brief; a separate Herdr agent launch and first prompt are required.
119
+
94
120
  ### Lab scope (8xs.t.pu0.a)
95
121
 
96
122
  `scope: lab` in the LAB coordination directory's own `.ace/overseer/config.yml`
97
- starts the whole lab from one external entry point:
123
+ selects the Lab role. Bare `start` launches the foreground harness;
124
+ `start --reconcile` is the explicit tool the agent uses after configuring its
125
+ projects. Reconciliation manages project children without restarting or probing
126
+ the Lab coordinator. The older full orchestration operation remains available
127
+ explicitly through `start --scope lab`:
128
+
98
129
 
99
130
  - The project list and CANONICAL locations come from the existing ace-lab
100
131
  topology (`topology.projects` id/path, read internally — the public
@@ -105,17 +136,14 @@ starts the whole lab from one external entry point:
105
136
  per-project (a nested lab start never recurses). An unreadable topology
106
137
  (missing or unparseable `.ace/lab/config.yml`) is a blocked start with
107
138
  the concrete cause.
108
- - The lab creates/reuses its own herdr space (`lab-overseer` — namespaced
109
- so it can never collide with a session/space title derived from a project
110
- directory name, e.g. a project rooted at a directory named `lab` owns
111
- `overseer-lab`) and boots or
139
+ - The lab creates/reuses its own herdr space (`overseer`, reserved for the Lab) and boots or
112
140
  reuses the lab's OWN overseer session there (label `overseer-<lab-root>`,
113
141
  cwd at the lab root, the lab guide as standing instructions, first-turn
114
142
  prompt included) — the lab-start instructions always reach a real agent,
115
143
  and an empty configured project list still runs the lab overseer (idle).
116
- - EACH project then starts or is found through the SAME operation as a
144
+ - EACH project then starts or is found through its own bundle and the SAME operation as a
117
145
  standalone start (its own cascade, root, and session identity) in its OWN
118
- herdr space, titled by its session label (`overseer-<project>`) and
146
+ herdr space, titled by its topology id (for example `ace`) and
119
147
  created/reused by title by the lab flow — project sessions are isolated
120
148
  from each other and from the lab overseer, never share one space, and
121
149
  never re-resolve the caller's ambient workspace (a plain terminal without
@@ -532,3 +560,15 @@ ace-overseer prune --dry-run
532
560
  ace-overseer prune --yes
533
561
  ace-overseer prune --dry-run
534
562
  ```
563
+
564
+ ### Workspace names and foreground instructions
565
+
566
+ Topology IDs reserve workspace titles for the commissioned lab projects.
567
+ Reconciliation may reuse an existing workspace with that title, but never
568
+ replaces its manual panes; project ownership is the stable overseer tab label
569
+ plus canonical cwd, not the mutable terminal title. Ambiguous ownership blocks.
570
+
571
+ Foreground bootstrap pins the two built-in start guides to their packaged
572
+ files. An explicit custom instruction source remains configurable; missing
573
+ files block before launching Pi. Project development dependencies do not select
574
+ the operational runtime when installed mode is chosen.
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: overseer-bootstrap-delegation
3
+ description: Delegate project startup to lab workers and accept their observed results
4
+ allowed-tools: Bash, Read
5
+ doc-type: workflow
6
+ title: Delegated Project Bootstrap
7
+ purpose: Complete one lab-owned startup commission without the overseer doing the repair.
8
+ ---
9
+ # Delegated project bootstrap
10
+
11
+ ## Commission and ownership
12
+
13
+ Read existing tasks and assignments first. Reuse the open commission for the
14
+ same project and goal; unknown execution state never means free capacity.
15
+ Create one lab coordination task in `_overseer`, with three direct children:
16
+ prepare, independently verify, confirm start. The latter two depend on their
17
+ predecessor. Only the lab overseer updates this private tree.
18
+
19
+ Create a separate executable lab task through `ace-task` for the bootstrap
20
+ work, linking it as the manager's `project_task`. Keep its specification in
21
+ its task folder. Include the canonical target repository path, required
22
+ configuration, allowed changes, runtime mode, acceptance checks and report
23
+ recipient. This is lab infrastructure work, not a project product task.
24
+ Workers must not edit the private coordination tree.
25
+
26
+ Explicit `ACE_TOOLCHAIN=installed` remains installed in every subprocess,
27
+ worker and resumed session. The project checkout is domain data. Never modify
28
+ tracked binstubs to bypass this selection. In auto mode retain the project's
29
+ own dependency model. If package code is defective, commission its repair;
30
+ do not patch installed gems. Preserve unrelated changes.
31
+
32
+ ## Arm eyes before dispatch
33
+
34
+ Use the lab's existing eyes JSON configuration. The `ace_eyes` tool accepts
35
+ `{"action":"start","configPath":"<absolute eyes JSON path>"}`.
36
+ Inspect the returned state: enabled=true, breaker=null, errors=0 and at least
37
+ one observation. Read pending revisions from their original owners, reconcile
38
+ existing commissions, and ack each exact key/revision only after acceptance.
39
+ A missing extension, invalid configuration or degraded source blocks dispatch;
40
+ report that capability gap. Do not claim that mentioning `/eyes` armed it.
41
+
42
+ ## Prepare and actually launch a worker
43
+
44
+ Use `ace-coworker prepare --task <executable-task> --roles bootstrap-worker,bootstrap-verifier
45
+ --to <configured-overseer-address> --format json`. Read its returned worktree,
46
+ assignment IDs and start-config paths; record these on the matching private
47
+ steps before launch. A preparation readiness report is not worker completion.
48
+ The task must be present in the execution worktree before any agent starts.
49
+
50
+ `ace-coworker start --config <returned-path>` validates and prints a brief;
51
+ it does NOT launch an agent. Use the configured Herdr provider/profile to
52
+ start a separate worker agent in the returned worktree, load the returned
53
+ role instructions, and send its first prompt. The prompt commissions only
54
+ that task and pinned assignment, requires the worker to read its start brief,
55
+ drive that assignment, and finish with `ace-coworker report`. These operational roles explicitly allow the commissioned canonical target
56
+ outside the lab task worktree; ordinary product worker/reviewer roles do not.
57
+ Use CLI help for
58
+ Herdr's current arguments. Record the returned pane/agent identity and verify
59
+ first-turn delivery. Never replace a missing executor with your own work.
60
+
61
+ End the overseer's turn immediately after dispatch. The eyes timer observes
62
+ assignment and HITL revisions without invoking the model on unchanged state.
63
+
64
+ ## Receive, verify, start
65
+
66
+ On wake, read the original report and current assignment. Require matching
67
+ report ID, task, assignment and role; a preparation report, process exit or
68
+ unmatched message is not success. Use `ace-overseer process-reports --dry-run`
69
+ to inspect the matching and continuation before applying it. Only accept
70
+ worker success with a completed non-empty assignment and the promised evidence.
71
+ Record receipt through task APIs before acknowledging the eyes revision.
72
+
73
+ Use the existing role handoff gate to prepare the independent reviewer after
74
+ worker completion. As with the worker, a printed start command is not a live
75
+ agent: launch the reviewer separately with its own returned role/config and
76
+ confirm first-turn delivery. Do not launch a second executor while an earlier
77
+ one is active or its state is unknown. On repeated delivery, reconcile existing
78
+ assignments and agents instead of dispatching again.
79
+
80
+ After accepted independent verification, the overseer performs its coordination
81
+ operation `ace-overseer start --reconcile`. Confirm the target's session, first
82
+ turn and active eyes from actual session evidence. A ready process alone is
83
+ not initialized observation. Close the confirmation step and managing task
84
+ only with these proofs; the worker report alone cannot close the family.
85
+
86
+ On error, keep the step open and route repair to the worker. Missing authority
87
+ or an actual Captain decision becomes a precise HITL question; end the turn
88
+ armed for its answer. Never self-repair or use sleep-poll while waiting.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: overseer-lab-start
3
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
4
+ allowed-tools: Bash, Read, Write, Edit
5
5
  doc-type: workflow
6
6
  title: Lab Overseer Start Guide
7
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.
@@ -20,21 +20,53 @@ aggregating their status and escalations, and routing the Captain's goals to
20
20
  the right project. You never take over a project's tasks, worktrees, or
21
21
  private `_overseer` state — those belong to each project's overseer.
22
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
+
23
30
  ## First turn after start
24
31
 
25
- 1. Read your own coordination tasks: `ace-task --in _overseer list`.
26
- 2. Read per-project readiness from the start report and the state owners
27
- (`ace-overseer status`); a project reported `blocked` is an escalation
28
- with its concrete reason, never something to work around by starting a
29
- duplicate session or doing the project's work yourself.
30
- 3. Re-run `ace-overseer start` to repair missing pieces only — it reuses
31
- healthy sessions and never restarts them or closes manual panes.
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.
32
63
 
33
64
  ## Delegation and escalation
34
65
 
35
66
  - Route goals to a project by working through THAT project's overseer (its
36
67
  session is labeled `overseer-<project-root>`); you do not dispatch its
37
- coworkers directly.
68
+ coworkers directly for product work. Lab bootstrap workers are the explicit
69
+ exception: they prepare a commissioned project whose overseer cannot start.
38
70
  - Cross-project blockers (shared forge access, missing host capability, a
39
71
  project repeatedly failing) escalate to the Captain through `ace-hitl`
40
72
  with the per-project evidence.
@@ -3,14 +3,9 @@
3
3
  module Ace
4
4
  module Overseer
5
5
  module Atoms
6
- # Pure validation and normalization of the `start` contract
7
- # (task 8xs.t.pu0.1): the `.ace/overseer/config.yml` cascade must
8
- # carry an EXPLICIT start section — `scope: project|lab`, a
9
- # `profile` reference into the same document's `profiles` map and
10
- # an `instructions` source (wfi:// or an absolute file path).
11
- # Nothing is guessed: a missing or ambiguous section is an error
12
- # BEFORE any session is created, and no interim default model is
13
- # applied anywhere. Pure — no IO, value-free messages.
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.
14
9
  class StartContract
15
10
  SCOPES = %w[project lab].freeze
16
11
  SECTION_KEYS = %w[scope profile instructions].freeze
@@ -23,7 +18,25 @@ module Ace
23
18
  # thinking:}, instructions:}
24
19
  # @raise [Ace::Overseer::Error] missing/ambiguous/invalid start
25
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
+
26
38
  def self.normalize(config)
39
+ config = bootstrap(config)
27
40
  start = config["start"]
28
41
  unless start.is_a?(Hash)
29
42
  raise Ace::Overseer::Error,
@@ -36,13 +49,13 @@ module Ace
36
49
  unless unknown.empty?
37
50
  raise Ace::Overseer::Error,
38
51
  "start section has unknown key '#{unknown.sort.first}' " \
39
- "(allowed: #{SECTION_KEYS.join(', ')})"
52
+ "(allowed: #{SECTION_KEYS.join(", ")})"
40
53
  end
41
54
 
42
55
  scope = required(start, "scope")
43
56
  unless SCOPES.include?(scope)
44
57
  raise Ace::Overseer::Error,
45
- "start scope must be one of: #{SCOPES.join(', ')} (got '#{scope}')"
58
+ "start scope must be one of: #{SCOPES.join(", ")} (got '#{scope}')"
46
59
  end
47
60
 
48
61
  profile_name = required(start, "profile")
@@ -50,7 +63,7 @@ module Ace
50
63
 
51
64
  instructions = required(start, "instructions")
52
65
  unless INSTRUCTION_PROTOCOLS.any? { |prefix| instructions.start_with?(prefix) } ||
53
- instructions.start_with?("/")
66
+ instructions.start_with?("/")
54
67
  raise Ace::Overseer::Error,
55
68
  "start instructions must be a wfi:// (or prompt://, tmpl://) " \
56
69
  "source or an absolute file path"
@@ -81,7 +94,7 @@ module Ace
81
94
  unless unknown.empty?
82
95
  raise Ace::Overseer::Error,
83
96
  "profile '#{profile_name}' has unknown key '#{unknown.sort.first}' " \
84
- "(allowed: #{PROFILE_KEYS.join(', ')})"
97
+ "(allowed: #{PROFILE_KEYS.join(", ")})"
85
98
  end
86
99
 
87
100
  harness = required_value(raw, "harness", "profile '#{profile_name}'")
@@ -16,19 +16,36 @@ module Ace
16
16
 
17
17
  desc "Start or reuse the project overseer session from .ace/overseer/config.yml"
18
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"
19
21
  option :dry_run, type: :boolean, default: false, desc: "Show the start plan without creating a session"
20
22
  option :format, default: "table", desc: "Output format (table, json)"
21
23
  option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
22
24
  option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
23
25
 
24
- def initialize(orchestrator: nil, config: nil)
26
+ def initialize(orchestrator: nil, config: nil, launcher: nil)
25
27
  super()
26
28
  @orchestrator = orchestrator
27
29
  @config = config
30
+ @launcher = launcher || ->(argv) { Kernel.exec(*argv) }
28
31
  end
29
32
 
30
- def call(dry_run: false, format: "table", **options)
31
- report = orchestrator.call(dry_run: dry_run)
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"
32
49
 
33
50
  if options[:quiet]
34
51
  # quiet still fails loudly on a blocked start
@@ -51,24 +68,24 @@ module Ace
51
68
 
52
69
  private
53
70
 
54
- def orchestrator
55
- @orchestrator || Organisms::StartOrchestrator.new(config: @config || Ace::Overseer.config)
71
+ def orchestrator(scope: nil)
72
+ @orchestrator || Organisms::StartOrchestrator.new(config: @config || Ace::Overseer.config, scope: scope)
56
73
  end
57
74
 
58
75
  def render_table(report)
59
- puts "ace-overseer start: #{report['status']}"
60
- puts " scope: #{report['scope'] || '-'}"
61
- puts " root: #{report['root'] || '-'}"
62
- puts " label: #{report['label'] || '-'}"
63
- puts " profile: #{report['profile'] || '-'}"
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"] || "-"}"
64
81
  if report["instructions"]
65
- puts " instructions: #{report['instructions']['source']} -> #{report['instructions']['path']}"
82
+ puts " instructions: #{report["instructions"]["source"]} -> #{report["instructions"]["path"]}"
66
83
  end
67
- puts " workspace: #{report['workspace'] || '-'}"
68
- puts " pane: #{report['pane'] || '-'}"
69
- puts " agent: #{report['agent']}"
70
- puts " replaced pane: #{report['replaced_pane'] || '-'}" if report["replaced_pane"]
71
- puts " #{report['message']}" if report["message"]
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"]
72
89
  end
73
90
  end
74
91
  end
@@ -44,8 +44,9 @@ module Ace
44
44
  sources = local.fetch("sources").merge(remote.fetch("sources")).merge(
45
45
  "hitl" => {"status" => "degraded", "error" => "#{hitl_error.class}: #{hitl_error.message}"}
46
46
  )
47
+ ensure_budget!(events.length, limit)
47
48
  return {"schema" => "ace.hitl.observation/v1",
48
- "events" => events.sort_by { |row| row["key"] },
49
+ "events" => wire_events(events).sort_by { |row| row["key"] },
49
50
  "settled" => [], "sources" => sources}
50
51
  end
51
52
 
@@ -60,7 +61,8 @@ module Ace
60
61
  manager && ([manager] + Array(manager.subtasks)).all? { |task| Ace::Task::Atoms::TaskValidationRules.terminal_status?(task.status.to_s) }
61
62
  end
62
63
 
63
- projection.merge("events" => (pending + local.fetch("events") + remote.fetch("events"))
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")))
64
66
  .sort_by { |row| row["key"] },
65
67
  "settled" => settled,
66
68
  "sources" => local.fetch("sources").merge(remote.fetch("sources"))
@@ -69,6 +71,23 @@ module Ace
69
71
 
70
72
  private
71
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
+
72
91
  # Configured remote observation (8xs.t.pu0.4): the project's own
73
92
  # observe.remote.pull_requests list — never a scan of accessible
74
93
  # accounts. No configuration means no remote rows and no remote
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/herdr/molecules/bounded_process"
5
+ require "shellwords"
6
+ require "ace/support/cli/toolchain"
7
+
8
+ module Ace
9
+ module Overseer
10
+ module Molecules
11
+ # A project's own Gemfile/lock and binstub select its ACE version.
12
+ # Never load child project gems into the lab coordinator's process.
13
+ class ProjectBundle
14
+ # A new Herdr shell inherits the server environment, which may differ
15
+ # from this CLI's environment. Clear known Bundler selectors even
16
+ # when the requesting process did not have them set.
17
+ BUNDLE_SELECTORS = %w[
18
+ BUNDLE_ALLOW_OFFLINE_INSTALL BUNDLE_APP_CONFIG BUNDLE_AUTO_INSTALL
19
+ BUNDLE_BIN BUNDLE_BIN_PATH BUNDLE_CACHE_ALL BUNDLE_CACHE_ALL_PLATFORMS
20
+ BUNDLE_CACHE_PATH BUNDLE_CLEAN BUNDLE_CONSOLE BUNDLE_DEPLOYMENT
21
+ BUNDLE_DISABLE_CHECKSUM_VALIDATION BUNDLE_DISABLE_EXEC_LOAD
22
+ BUNDLE_DISABLE_LOCAL_BRANCH_CHECK BUNDLE_DISABLE_LOCAL_REVISION_CHECK
23
+ BUNDLE_DISABLE_SHARED_GEMS BUNDLE_DISABLE_VERSION_CHECK
24
+ BUNDLE_FORCE_RUBY_PLATFORM BUNDLE_FORGET_CLI_OPTIONS BUNDLE_FROZEN
25
+ BUNDLE_GEMFILE BUNDLE_GEM__BUNDLE BUNDLE_GEM__CHANGELOG BUNDLE_GEM__CI
26
+ BUNDLE_GEM__COC BUNDLE_GEM__GITHUB_USERNAME BUNDLE_GEM__LINTER
27
+ BUNDLE_GEM__MIT BUNDLE_GEM__RUBOCOP BUNDLE_GEM__TEST
28
+ BUNDLE_GIT__ALLOW_INSECURE BUNDLE_GLOBAL_GEM_CACHE BUNDLE_IGNORE_CONFIG
29
+ BUNDLE_IGNORE_MESSAGES BUNDLE_INIT_GEMS_RB BUNDLE_INLINE BUNDLE_JOBS
30
+ BUNDLE_LOCKFILE_CHECKSUMS BUNDLE_NO_INSTALL BUNDLE_NO_PRUNE BUNDLE_ONLY
31
+ BUNDLE_PATH BUNDLE_PATH__SYSTEM BUNDLE_PLUGINS BUNDLE_PREFER_PATCH
32
+ BUNDLE_REDIRECT BUNDLE_RETRY BUNDLE_SHEBANG BUNDLE_SILENCE_DEPRECATIONS
33
+ BUNDLE_SILENCE_ROOT_WARNING BUNDLE_SIMULATE_VERSION BUNDLE_SSL_CA_CERT
34
+ BUNDLE_SSL_CLIENT_CERT BUNDLE_SSL_VERIFY_MODE BUNDLE_SYSTEM_BINDIR
35
+ BUNDLE_TIMEOUT BUNDLE_TRUST___POLICY BUNDLE_UPDATE_REQUIRES_ALL_FLAG
36
+ BUNDLE_USER_HOME BUNDLE_USER_CONFIG BUNDLE_USER_CACHE BUNDLE_USER_PLUGIN
37
+ BUNDLE_VERBOSE BUNDLE_VERSION BUNDLE_WITH BUNDLE_WITHOUT
38
+ BUNDLER_SETUP BUNDLER_VERSION
39
+ ].freeze
40
+ RUBY_SELECTORS = %w[RUBYOPT RUBYLIB GEM_HOME GEM_PATH ACE_TOOLCHAIN].freeze
41
+
42
+ def self.environment(root, env: ENV)
43
+ if Ace::Support::Cli::Toolchain.installed?
44
+ # Reuse the installed closure; the project checkout is domain data.
45
+ # Do not let a project binstub or Gemfile select source dependencies.
46
+ return Ace::Support::Cli::Toolchain.child_environment.merge(
47
+ "HERDR_PANE" => nil, "HERDR_TAB" => nil,
48
+ "HERDR_PANE_ID" => nil, "HERDR_TAB_ID" => nil
49
+ )
50
+ end
51
+ known = BUNDLE_SELECTORS + RUBY_SELECTORS
52
+ names = env.keys.grep(/\ABUNDLER?_/) + known +
53
+ (known + %w[PATH]).map { |key| "BUNDLER_ORIG_#{key}" } +
54
+ %w[HERDR_PANE HERDR_TAB HERDR_PANE_ID HERDR_TAB_ID]
55
+ names.to_h { |name| [name, nil] }.merge(
56
+ "BUNDLE_GEMFILE" => File.file?(File.join(root, "Gemfile")) ? File.join(root, "Gemfile") : nil,
57
+ "PATH" => [File.join(root, "bin"), env.fetch("PATH", "/usr/bin:/bin")].join(File::PATH_SEPARATOR)
58
+ )
59
+ end
60
+
61
+ def self.shell_setup(root, env: ENV)
62
+ if Ace::Support::Cli::Toolchain.installed?
63
+ # A server-created shell may contain selectors absent from this
64
+ # caller. Clear the same known selectors before applying the
65
+ # installed closure; never inherit server-side Bundler injection.
66
+ names = BUNDLE_SELECTORS + BUNDLE_SELECTORS.map { |key| "BUNDLER_ORIG_#{key}" }
67
+ return "unset #{names.map { |name| Shellwords.escape(name) }.join(' ')}; " +
68
+ Ace::Support::Cli::Toolchain.shell_setup
69
+ end
70
+
71
+ values = environment(root, env: env).reject { |name, _| %w[HERDR_PANE HERDR_TAB HERDR_PANE_ID HERDR_TAB_ID].include?(name) }
72
+ cleared, assigned = values.partition { |_, value| value.nil? }
73
+ "unset #{cleared.map { |name, _| Shellwords.escape(name) }.join(" ")}; export " +
74
+ assigned.map { |name, value| "#{name}=#{Shellwords.escape(value)}" }.join(" ")
75
+ end
76
+
77
+ def initialize(timeout_s: 240)
78
+ @timeout_s = timeout_s
79
+ end
80
+
81
+ def call(root:, workspace:, env: ENV)
82
+ installed = Ace::Support::Cli::Toolchain.installed?
83
+ unless installed
84
+ %w[Gemfile Gemfile.lock].each do |name|
85
+ unless File.file?(File.join(root, name))
86
+ raise Ace::Overseer::Error, "project bundle missing #{name}; prepare this project's own bundle first"
87
+ end
88
+ end
89
+ end
90
+ binary = installed ? Ace::Support::Cli::Toolchain.executable("ace-overseer") : File.join(root, "bin", "ace-overseer")
91
+ command = File.executable?(binary) ? [binary] : ["bundle", "exec", "ace-overseer"]
92
+ command += ["start", "--scope", "project", "--format", "json"]
93
+ child_env = self.class.environment(root, env: env).merge("HERDR_WORKSPACE_ID" => workspace)
94
+ result = Ace::Herdr::Molecules::BoundedProcess.call(command,
95
+ environment: ENV.to_h.merge(child_env).compact, chdir: root,
96
+ timeout_s: @timeout_s, output_limit: 1_048_576)
97
+ raise Ace::Overseer::Error, "project start output exceeded its limit" if result.oversized
98
+ out, err, status = result.stdout, result.stderr, result.status
99
+ report = JSON.parse(out)
100
+ unless report.is_a?(Hash) && report["scope"] == "project" && report["status"].is_a?(String)
101
+ raise Ace::Overseer::Error, "project start returned no project-scope report"
102
+ end
103
+ if !status.success? && %w[started reused ready].include?(report["status"])
104
+ raise Ace::Overseer::Error, "project start exited #{status.exitstatus} despite a ready report"
105
+ end
106
+ report
107
+ rescue Timeout::Error
108
+ raise Ace::Overseer::Error, "project start timed out after #{@timeout_s}s; inspect the existing session before retrying"
109
+ rescue Ace::Herdr::Molecules::BoundedProcess::PostLaunchError => e
110
+ raise Ace::Overseer::Error, "project start outcome is uncertain: #{e.message}"
111
+ rescue JSON::ParserError
112
+ raise Ace::Overseer::Error, "project bundle start returned invalid JSON#{": " + err.strip[0, 300] unless err.to_s.empty?}"
113
+ rescue SystemCallError => e
114
+ raise Ace::Overseer::Error, "project bundle could not start: #{e.message}"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../atoms/start_contract"
4
4
  require_relative "../molecules/project_root_resolver"
5
+ require_relative "../molecules/project_bundle"
5
6
  require "ace/herdr"
6
7
  require "ace/support/cli/toolchain"
7
8
  require "json"
@@ -9,13 +10,10 @@ require "json"
9
10
  module Ace
10
11
  module Overseer
11
12
  module Organisms
12
- # `ace-overseer start` (task 8xs.t.pu0.1): boots or reuses the
13
- # PROJECT overseer session from the directory's own configuration
14
- # — an explicit `start` section (scope/profile/instructions) in
15
- # the `.ace/overseer/config.yml` cascade, a profile from the same
16
- # document's `profiles` map, and the guide resolved through the
17
- # normal handbook override chain. No interim defaults: a missing
18
- # or ambiguous configuration is a blocked start, never a guess.
13
+ # `ace-overseer start` boots the Lab in the calling Herdr terminal,
14
+ # or starts/reuses configured project sessions. Missing start config
15
+ # uses the packaged bootstrap profile so the coordinator can configure
16
+ # itself. Explicit malformed configuration remains a blocked start.
19
17
  #
20
18
  # Session identity is Herdr's: the tab/pane label
21
19
  # `overseer-<project>` derives from the canonical repository root
@@ -34,12 +32,9 @@ module Ace
34
32
  # turn must BEGIN within this window (herdr enforces its own
35
33
  # 5s activity observation inside the wait).
36
34
  FIRST_TURN_TIMEOUT_MS = 120_000
37
- # The lab's own herdr space title. Namespaced ("lab-overseer",
38
- # never "overseer-…") so it can never collide with a session or
39
- # per-project space title derived from a directory name — e.g. a
40
- # project rooted at a directory literally named "lab" owns the
41
- # session/space title "overseer-lab".
42
- LAB_SPACE_TITLE = "lab-overseer"
35
+ # Workspace display names are separate from session identities.
36
+ # `overseer` belongs to the Lab; project spaces use topology ids.
37
+ LAB_SPACE_TITLE = "overseer"
43
38
  # `herdr agent start` success only means the interactive agent is
44
39
  # READY for input — the session begins its first turn when this
45
40
  # prompt arrives, exactly as the standing instructions' "First
@@ -48,9 +43,10 @@ module Ace
48
43
  Begin your first turn now. Follow the "First turn after start"
49
44
  section of your standing instructions: read your private
50
45
  _overseer tasks and the project state from its owners, continue
51
- existing commissioned work before choosing anything new, arm
52
- /eyes for standing observation, and remain idle when there is
53
- nothing to do.
46
+ existing commissioned work before choosing anything new. Arm
47
+ standing observation with the ace_eyes tool, action start and
48
+ configPath naming your eyes configuration; verify its returned
49
+ observation state. Remain idle when there is nothing to do.
54
50
  PROMPT
55
51
 
56
52
  attr_reader :cwd
@@ -69,14 +65,18 @@ module Ace
69
65
  # lazily, only when a session actually boots.
70
66
  # @param cwd [String] invocation directory
71
67
  def initialize(config: nil, executor: nil, root_resolver: nil, nav_runner: nil,
72
- workspace: nil, workspace_factory: nil, cwd: Dir.pwd)
68
+ workspace: nil, workspace_factory: nil, cwd: Dir.pwd, env: ENV, scope: nil, project_runner: nil)
73
69
  @config = config || Ace::Overseer.config
74
70
  @executor = executor || Ace::Herdr::Molecules::HerdrExecutor.new
71
+ @control_surface = Ace::Herdr::Organisms::ControlSurface.new(executor: @executor)
75
72
  @root_resolver = root_resolver || Molecules::ProjectRootResolver.new
76
73
  @nav_runner = nav_runner || method(:run_nav)
77
74
  @workspace = workspace
78
75
  @workspace_factory = workspace_factory
79
76
  @cwd = cwd
77
+ @env = env
78
+ @scope = scope
79
+ @project_runner = project_runner || Molecules::ProjectBundle.new
80
80
  end
81
81
 
82
82
  # @param dry_run [Boolean] report the plan without creating a
@@ -86,7 +86,10 @@ module Ace
86
86
  # replaced_pane, message}; scope lab additionally carries
87
87
  # {projects: [{id, path, status, ...}], project_count, ready}
88
88
  def call(dry_run: false)
89
- plan = Atoms::StartContract.normalize(@config)
89
+ plan = Atoms::StartContract.normalize(Atoms::StartContract.bootstrap(@config, scope: @scope || "lab"))
90
+ if @scope && plan["scope"] != @scope
91
+ raise Ace::Overseer::Error, "configured start scope '#{plan["scope"]}' conflicts with requested '#{@scope}'; nested lab scopes are refused"
92
+ end
90
93
  return lab_start(plan, dry_run) if plan["scope"] == "lab"
91
94
 
92
95
  root = @root_resolver.resolve(cwd: @cwd)
@@ -94,8 +97,10 @@ module Ace
94
97
  instructions_path = resolve_instructions(plan["instructions"], root)
95
98
 
96
99
  report = base_report(plan, root, label, instructions_path)
97
- return report.merge("status" => "dry_run",
98
- "message" => "dry-run: session would start with this plan; nothing was created") if dry_run
100
+ if dry_run
101
+ return report.merge("status" => "dry_run",
102
+ "message" => "dry-run: session would start with this plan; nothing was created")
103
+ end
99
104
 
100
105
  owner = owner_pane(label)
101
106
  if owner
@@ -144,6 +149,41 @@ module Ace
144
149
  blocked("herdr failed while preparing the overseer session: #{e.message}")
145
150
  end
146
151
 
152
+ # Bare start is just the foreground harness entrypoint. It does
153
+ # not discover a terminal, inspect agents or analyze topology.
154
+ def bootstrap(dry_run: false)
155
+ plan = Atoms::StartContract.normalize(@config)
156
+ unless plan["profile"]["harness"] == "pi"
157
+ raise Ace::Overseer::Error, "foreground start requires the configured pi harness"
158
+ end
159
+ root = File.realpath(@cwd)
160
+ source = plan["instructions"]
161
+ guide = if %w[wfi://overseer/lab-start wfi://overseer/start].include?(source)
162
+ filename = source.end_with?("lab-start") ? "lab-start.wf.md" : "start.wf.md"
163
+ File.join(Ace::Overseer.gem_root, "handbook/workflow-instructions/overseer", filename)
164
+ else
165
+ resolve_instructions(source, root)
166
+ end
167
+ raise Ace::Overseer::Error, "standing instructions file is missing: #{guide}" unless File.file?(guide)
168
+ command = ["pi", "--model", plan["profile"]["model"]]
169
+ thinking = plan["profile"]["thinking"]
170
+ command += ["--thinking", thinking] if thinking
171
+ command += ["--append-system-prompt", guide, FIRST_TURN_PROMPT]
172
+ {"status" => dry_run ? "dry_run" : "foreground", "scope" => plan["scope"],
173
+ "root" => root, "profile" => plan["profile_name"],
174
+ "instructions" => {"source" => source, "path" => guide},
175
+ "command" => command, "message" => "launch the coordinator in the current terminal"}
176
+ end
177
+
178
+ # Explicit tool available to the running Lab agent. This operation
179
+ # manages configured project children, never the coordinator itself.
180
+ def reconcile(dry_run: false)
181
+ plan = Atoms::StartContract.normalize(@config)
182
+ raise Ace::Overseer::Error, "project reconciliation requires lab scope" unless plan["scope"] == "lab"
183
+
184
+ lab_start(plan, dry_run, reconcile: true)
185
+ end
186
+
147
187
  private
148
188
 
149
189
  # --- lab scope (task 8xs.t.pu0.a) -----------------------------------
@@ -161,16 +201,16 @@ module Ace
161
201
  # healthy projects continue; a repeated start repairs only what
162
202
  # is missing and never restarts healthy sessions or closes
163
203
  # manual panes.
164
- def lab_start(plan, dry_run)
204
+ def lab_start(plan, dry_run, reconcile: false)
165
205
  lab_root = File.realpath(@cwd)
166
206
  base = {"status" => nil, "scope" => "lab", "root" => lab_root,
167
- "label" => Atoms::StartContract.session_label(lab_root),
168
- "workspace" => nil, "pane" => nil, "agent" => AGENT_NAME,
169
- "profile" => plan["profile_name"],
170
- "instructions" => {"source" => plan["instructions"], "path" => nil},
171
- "replaced_pane" => nil, "projects" => [], "project_count" => 0,
172
- "ready" => nil, "message" => nil}
173
- base["instructions"]["path"] = resolve_instructions(plan["instructions"], lab_root)
207
+ "label" => Atoms::StartContract.session_label(lab_root),
208
+ "workspace" => nil, "pane" => nil, "agent" => AGENT_NAME,
209
+ "profile" => plan["profile_name"],
210
+ "instructions" => {"source" => plan["instructions"], "path" => nil},
211
+ "replaced_pane" => nil, "projects" => [], "project_count" => 0,
212
+ "ready" => nil, "message" => nil}
213
+ base["instructions"]["path"] = resolve_instructions(plan["instructions"], lab_root) unless reconcile
174
214
 
175
215
  projects = begin
176
216
  Ace::Lab::Molecules::ProjectLocations.read(lab_root)
@@ -179,42 +219,56 @@ module Ace
179
219
  "message" => "lab topology could not be read: #{e.message}")
180
220
  end
181
221
 
182
- if projects.any? { |project| project["path"].to_s.empty? == false &&
183
- File.directory?(project["path"]) && File.realpath(project["path"]) == lab_root }
222
+ if projects.any? { |project|
223
+ project["path"].to_s.empty? == false &&
224
+ File.directory?(project["path"]) && File.realpath(project["path"]) == lab_root
225
+ }
184
226
  return base.merge("status" => "blocked",
185
227
  "message" => "the lab root '#{lab_root}' is itself declared as a project; " \
186
228
  "conflicting lab/project roles are refused")
187
229
  end
188
230
 
189
- return base.merge("status" => "dry_run", "project_count" => projects.size,
190
- "projects" => projects.map { |project| {"id" => project["id"], "path" => project["path"],
191
- "status" => "would_start"}},
192
- "message" => "dry-run: the lab space with the lab overseer session and " \
193
- "#{projects.size} project space(s)/overseer(s) would start; nothing was created") if dry_run
194
-
195
- workspace = lab_workspace
196
- base["workspace"] = workspace
231
+ if dry_run
232
+ return base.merge("status" => "dry_run", "project_count" => projects.size,
233
+ "projects" => projects.map { |project|
234
+ {"id" => project["id"], "path" => project["path"],
235
+ "status" => "would_start"}
236
+ },
237
+ "message" => "dry-run: the lab space with the lab overseer session and " \
238
+ "#{projects.size} project space(s)/overseer(s) would start; nothing was created")
239
+ end
197
240
 
198
- # The lab's OWN overseer session (the wfi://overseer/lab-start
199
- # guide assumes it): booted or reused in the lab space before
200
- # any project, so the lab-start instructions reach an agent.
201
- session = boot_lab_session(workspace, plan, base["instructions"]["path"], lab_root)
202
- return base.merge("status" => "blocked", "pane" => session["pane"],
203
- "message" => session["message"]) if session["status"] == "blocked"
241
+ if reconcile
242
+ base["coordinator_managed"] = false
243
+ session = {}
244
+ lab_ready = true
245
+ else
246
+ workspace = lab_workspace
247
+ base["workspace"] = workspace
248
+
249
+ # The lab's OWN overseer session (the wfi://overseer/lab-start
250
+ # guide assumes it): booted or reused in the lab space before
251
+ # any project, so the lab-start instructions reach an agent.
252
+ session = boot_lab_session(workspace, plan, base["instructions"]["path"], lab_root)
253
+ if session["status"] == "blocked"
254
+ return base.merge("status" => "blocked", "pane" => session["pane"],
255
+ "message" => session["message"])
256
+ end
204
257
 
205
- base["pane"] = session["pane"]
206
- base["replaced_pane"] = session["replaced_pane"]
207
- base["prompt_failed"] = !session["prompt_failed"].nil? && session["prompt_failed"]
258
+ base["pane"] = session["pane"]
259
+ base["replaced_pane"] = session["replaced_pane"]
260
+ base["prompt_failed"] = !session["prompt_failed"].nil? && session["prompt_failed"]
208
261
 
209
- # Readiness REQUIRES a confirmed first turn (review R7): a
210
- # started/reused session whose first-turn delivery failed or
211
- # stays unconfirmed is NOT ready, whatever its process state.
212
- lab_ready = !session["prompt_failed"]
262
+ # Readiness REQUIRES a confirmed first turn (review R7): a
263
+ # started/reused session whose first-turn delivery failed or
264
+ # stays unconfirmed is NOT ready, whatever its process state.
265
+ lab_ready = !session["prompt_failed"]
266
+ end
213
267
 
214
268
  # An empty configured list is a valid idle lab: the lab
215
269
  # overseer itself still runs (and must be initialized).
216
270
  if projects.empty?
217
- message = "no projects configured; the lab overseer is idle"
271
+ message = reconcile ? "no project children configured" : "no projects configured; the lab overseer is idle"
218
272
  message += "; #{session["message"]}" if session["message"]
219
273
  return base.merge("status" => lab_ready ? "ready" : "partial",
220
274
  "ready" => lab_ready, "project_count" => 0, "message" => message)
@@ -264,12 +318,10 @@ module Ace
264
318
  # workspace reference), never treated as absent: creating a
265
319
  # second space with the same title would duplicate the space.
266
320
  def find_workspace(title)
267
- existing = @executor.workspace_list.parsed_json
268
- list = existing.is_a?(Hash) ? Array(existing["result"] || existing["workspaces"]) : Array(existing)
269
- found = list.find { |entry| entry["title"] == title || entry["label"] == title }
321
+ found = @control_surface.list_workspaces.find { |entry| entry[:title] == title }
270
322
  return nil unless found
271
323
 
272
- found["id"] || found["workspace_id"] || found["title"] || found["label"]
324
+ found[:id] || found[:title]
273
325
  rescue Ace::Herdr::ExecutorUnavailableError => e
274
326
  raise Ace::Overseer::Error, "herdr is unavailable: #{e.message}"
275
327
  end
@@ -287,17 +339,21 @@ module Ace
287
339
  if owner
288
340
  owner_cwd = owner["cwd"].to_s
289
341
  matching = !owner_cwd.empty? &&
290
- (File.realpath(owner_cwd) == lab_root rescue false)
342
+ begin
343
+ File.realpath(owner_cwd) == lab_root
344
+ rescue
345
+ false
346
+ end
291
347
  unless matching
292
348
  return {"status" => "blocked", "pane" => nil, "replaced_pane" => nil,
293
- "message" => "owner pane '#{owner["id"]}' works in '#{owner_cwd}', which is " \
349
+ "message" => "owner pane '#{owner["id"]}' works in '#{owner_cwd}', which is " \
294
350
  "not the lab root '#{lab_root}'; refusing to take over a foreign session"}
295
351
  end
296
352
  state = agent_state(owner["id"])
297
353
  if state
298
354
  warning = repair_reused_initialization(owner["id"], state)
299
355
  return {"status" => "reused", "pane" => owner["id"], "replaced_pane" => nil,
300
- "prompt_failed" => !warning.nil?, "message" => warning}
356
+ "prompt_failed" => !warning.nil?, "message" => warning}
301
357
  end
302
358
 
303
359
  replaced = owner["id"]
@@ -306,7 +362,7 @@ module Ace
306
362
  pane = boot_session(workspace, label, lab_root, plan, instructions_path)
307
363
  prompt_warning = first_prompt(pane)
308
364
  {"status" => "started", "pane" => pane, "replaced_pane" => replaced,
309
- "prompt_failed" => !prompt_warning.nil?, "message" => prompt_warning}
365
+ "prompt_failed" => !prompt_warning.nil?, "message" => prompt_warning}
310
366
  rescue Ace::Overseer::Error => e
311
367
  {"status" => "blocked", "pane" => nil, "replaced_pane" => nil, "message" => e.message}
312
368
  end
@@ -320,29 +376,33 @@ module Ace
320
376
  path = project["path"]
321
377
  if path.nil? || path.to_s.strip.empty?
322
378
  return {"id" => id, "path" => nil, "status" => "blocked",
323
- "message" => "project '#{id}' declares no path in the lab topology"}
379
+ "message" => "project '#{id}' declares no path in the lab topology"}
324
380
  end
325
381
  unless File.directory?(path)
326
382
  return {"id" => id, "path" => path, "status" => "blocked",
327
- "message" => "project '#{id}' directory '#{path}' does not exist"}
383
+ "message" => "project '#{id}' directory '#{path}' does not exist"}
328
384
  end
329
385
 
330
- project_config = Ace::Overseer.config_at(path)
331
- # a project cascade that itself declares scope lab would
332
- # recurse (a nested lab start) — refused, never executed
333
- if project_config.is_a?(Hash) && project_config.dig("start", "scope") == "lab"
386
+ if id == LAB_SPACE_TITLE
334
387
  return {"id" => id, "path" => path, "status" => "blocked",
335
- "message" => "project '#{id}' declares scope lab in its own " \
336
- ".ace/overseer/config.yml; nested lab scopes are refused"}
388
+ "message" => "project id '#{id}' is reserved for the lab workspace"}
389
+ end
390
+ root = @root_resolver.resolve(cwd: path)
391
+ label = Atoms::StartContract.session_label(root)
392
+ owner = owner_pane(label)
393
+ workspace = if owner && agent_state(owner["id"])
394
+ reference = owner["workspace"] || owner["workspace_id"]
395
+ if reference.to_s.strip.empty?
396
+ raise Ace::Overseer::Error,
397
+ "live project owner '#{owner["id"]}' reports no workspace; " \
398
+ "refusing ambient fallback or a separate project workspace"
399
+ end
400
+ reference
401
+ else
402
+ find_or_create_workspace(id)
337
403
  end
404
+ report = @project_runner.call(root: root, workspace: workspace, env: @env)
338
405
 
339
- orchestrator = self.class.new(config: project_config, executor: @executor,
340
- root_resolver: @root_resolver, nav_runner: @nav_runner,
341
- workspace_factory: ->(label) { find_or_create_workspace(label) }, cwd: path)
342
- report = orchestrator.call
343
- # a reused session keeps its reported space: the lab-managed
344
- # space titled by the project's session label, when it exists
345
- report["workspace"] ||= find_workspace(report["label"])
346
406
  report.merge("id" => id, "path" => path)
347
407
  rescue Ace::Overseer::Error, Ace::Herdr::ExecutorError => e
348
408
  {"id" => id, "path" => path, "status" => "blocked", "message" => e.message}
@@ -383,7 +443,7 @@ module Ace
383
443
  "cannot resolve the ace-nav executable for the start instructions: #{e.message}"
384
444
  end
385
445
  result = @nav_runner.call([binary, "resolve", source], root)
386
- path = result["status"] == "ok" ? result["out"].to_s.lines.first.to_s.strip : nil
446
+ path = (result["status"] == "ok") ? result["out"].to_s.lines.first.to_s.strip : nil
387
447
  if path.nil? || path.empty?
388
448
  raise Ace::Overseer::Error,
389
449
  "start instructions '#{source}' do not resolve through ace-nav " \
@@ -426,8 +486,32 @@ module Ace
426
486
 
427
487
  # The live pane owning this project's overseer label.
428
488
  def owner_pane(label)
429
- panes = extract_panes(@executor.pane_list.parsed_json)
430
- panes.find { |pane| pane["title"] == label || pane["label"] == label }
489
+ # boot_session names the TAB, not the terminal title. Pi and shell
490
+ # applications may change terminal titles at any time.
491
+ tabs = @control_surface.list_tabs.select { |entry| entry[:title] == label }
492
+ return nil if tabs.empty?
493
+
494
+ panes = @control_surface.list_panes
495
+ owners = tabs.map do |tab|
496
+ matches = panes.select { |pane| pane[:tab] == tab[:id] }
497
+ if tab[:id].to_s.empty? || matches.length != 1 || matches.first[:id].to_s.empty?
498
+ raise Ace::Overseer::Error,
499
+ "overseer tab '#{label}' has no unique owner pane; refusing an ambiguous session"
500
+ end
501
+ matches.first
502
+ end
503
+ if owners.length > 1
504
+ # A replaced dead owner may retain its tab. Only a single live
505
+ # agent identifies an unambiguous current owner; never pick the
506
+ # first of multiple live or dead candidates.
507
+ live = owners.select { |pane| !agent_state(pane[:id]).nil? }
508
+ unless live.length == 1
509
+ raise Ace::Overseer::Error,
510
+ "multiple overseer tabs '#{label}' have no unique live owner; refusing an ambiguous session"
511
+ end
512
+ owners = live
513
+ end
514
+ owners.first.transform_keys(&:to_s)
431
515
  rescue Ace::Herdr::ExecutorUnavailableError => e
432
516
  raise Ace::Overseer::Error, "herdr is unavailable: #{e.message}"
433
517
  end
@@ -492,6 +576,7 @@ module Ace
492
576
  "herdr created tab '#{label}' but reported no root pane"
493
577
  end
494
578
 
579
+ @executor.pane_run(pane, Molecules::ProjectBundle.shell_setup(root, env: @env))
495
580
  args = ["--model", plan["profile"]["model"]]
496
581
  if plan["profile"]["thinking"]
497
582
  args += ["--thinking", plan["profile"]["thinking"]]
@@ -544,14 +629,6 @@ module Ace
544
629
  end
545
630
  end
546
631
 
547
- def extract_panes(json)
548
- case json
549
- when Array then json
550
- when Hash then Array(json["result"] || json["panes"] || [])
551
- else []
552
- end
553
- end
554
-
555
632
  def dig_pane(json)
556
633
  return nil unless json.is_a?(Hash)
557
634
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Overseer
5
- VERSION = "0.27.0"
5
+ VERSION = "0.28.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-overseer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.0
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-30 00:00:00.000000000 Z
10
+ date: 2026-10-31 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -139,14 +139,14 @@ dependencies:
139
139
  requirements:
140
140
  - - "~>"
141
141
  - !ruby/object:Gem::Version
142
- version: '0.7'
142
+ version: '0.8'
143
143
  type: :runtime
144
144
  prerelease: false
145
145
  version_requirements: !ruby/object:Gem::Requirement
146
146
  requirements:
147
147
  - - "~>"
148
148
  - !ruby/object:Gem::Version
149
- version: '0.7'
149
+ version: '0.8'
150
150
  - !ruby/object:Gem::Dependency
151
151
  name: ace-hitl
152
152
  requirement: !ruby/object:Gem::Requirement
@@ -195,20 +195,14 @@ dependencies:
195
195
  requirements:
196
196
  - - "~>"
197
197
  - !ruby/object:Gem::Version
198
- version: '0.6'
199
- - - ">="
200
- - !ruby/object:Gem::Version
201
- version: 0.8.1
198
+ version: '0.9'
202
199
  type: :runtime
203
200
  prerelease: false
204
201
  version_requirements: !ruby/object:Gem::Requirement
205
202
  requirements:
206
203
  - - "~>"
207
204
  - !ruby/object:Gem::Version
208
- version: '0.6'
209
- - - ">="
210
- - !ruby/object:Gem::Version
211
- version: 0.8.1
205
+ version: '0.9'
212
206
  - !ruby/object:Gem::Dependency
213
207
  name: ace-lab
214
208
  requirement: !ruby/object:Gem::Requirement
@@ -319,6 +313,7 @@ files:
319
313
  - exe/ace-overseer
320
314
  - handbook/skills/as-overseer/SKILL.md
321
315
  - handbook/workflow-instructions/overseer.wf.md
316
+ - handbook/workflow-instructions/overseer/bootstrap-delegation.wf.md
322
317
  - handbook/workflow-instructions/overseer/cold-cycle-acceptance.wf.md
323
318
  - handbook/workflow-instructions/overseer/lab-start.wf.md
324
319
  - handbook/workflow-instructions/overseer/queue.wf.md
@@ -354,6 +349,7 @@ files:
354
349
  - lib/ace/overseer/molecules/hitl_inbox.rb
355
350
  - lib/ace/overseer/molecules/local_sources.rb
356
351
  - lib/ace/overseer/molecules/preservation_manifest.rb
352
+ - lib/ace/overseer/molecules/project_bundle.rb
357
353
  - lib/ace/overseer/molecules/project_root_resolver.rb
358
354
  - lib/ace/overseer/molecules/prune_safety_checker.rb
359
355
  - lib/ace/overseer/molecules/remote_sources.rb