omp-conductor 0.2.2 → 0.3.2

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.
package/src/lifecycle.ts CHANGED
@@ -22,11 +22,13 @@ import { join } from "node:path";
22
22
 
23
23
  /**
24
24
  * Mirrors `DEFAULT_PORT` in ./daemon.ts. Duplicated rather than imported so
25
- * this module stays free of the dispatcher's dependency tree.
26
- * // ponytail: two constants that must agree. If a third caller ever needs it,
27
- * // move it into ./types.ts and import it in both places.
25
+ * this module stays free of the dispatcher's dependency tree; exported so the
26
+ * CLI can name the port a foreground daemon will serve on without adding a
27
+ * third literal.
28
+ * // ponytail: two constants that must agree. If a third *definition* ever
29
+ * // appears, move it into ./types.ts and import it everywhere.
28
30
  */
29
- const DEFAULT_PORT = 8787;
31
+ export const DEFAULT_PORT = 8787;
30
32
 
31
33
  /** How long `startDaemon` waits for the first successful `/healthz`. */
32
34
  const READY_TIMEOUT_MS = 15_000;
@@ -6,12 +6,9 @@
6
6
  * loop, so this extension is the heartbeat: every `intervalSeconds` it injects
7
7
  * one message that starts a turn.
8
8
  *
9
- * Four properties are worth protecting, and each one is a branch in
9
+ * Three properties are worth protecting, and each one is a branch in
10
10
  * `tickDecision()`:
11
11
  *
12
- * - **Pause is honoured.** `isPaused()` is imported from ./daemon.ts — the exact
13
- * function `/conductor pause` writes for and the dispatch loop reads. A second
14
- * spelling of "is it paused" here is how a paused fleet keeps working.
15
12
  * - **A disarmed fleet is not woken.** The arm marker is a file the operator
16
13
  * controls; missing means "not armed", and a tick then does nothing.
17
14
  * - **The human channel must still be there.** Autonomous dispatch is only
@@ -22,21 +19,25 @@
22
19
  * channel that makes running unattended safe.
23
20
  * - **Ticks coalesce.** A tick that lands while an earlier one is still queued
24
21
  * would stack prompts on a session that is already behind. `hasPendingMessages()`
25
- * makes the tick idempotent under slow turns.
22
+ * makes the tick idempotent under slow turns — and, because it is the only
23
+ * signal in the process that says whether the last prompt was ever consumed,
24
+ * it doubles as the wedge detector: see {@link STALL_MARKER_FILE}.
26
25
  *
27
- * Beyond those four gates, every tick carries the project's `reporting.scope`
26
+ * Beyond those three gates, every tick carries the project's `reporting.scope`
28
27
  * as one explicit constraint line, re-read from the conductor config on each
29
28
  * tick so a `/conductor setup` change binds the next heartbeat rather than
30
- * waiting for a session restart.
29
+ * waiting for a session restart — and one delivery rule
30
+ * ({@link TICK_DELIVERY_RULE}), because a tick is injected locally and a report
31
+ * written as end-of-turn text on such a turn reaches nobody. An operator's own
32
+ * `message` replaces both, and is re-read per tick for the same reason.
31
33
  *
32
34
  * The extension is inert unless `<cwd>/.conductor-tick.json` exists, so shipping
33
35
  * it inside `omp-conductor` costs an ordinary session nothing.
34
36
  */
35
37
 
36
- import { existsSync, readFileSync } from "node:fs";
38
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
37
39
  import { isAbsolute, join, resolve } from "node:path";
38
40
  import { findProject, loadConfig } from "./config.ts";
39
- import { isPaused } from "./daemon.ts";
40
41
  import { DEFAULT_REPORT_SCOPE, type ReportScope } from "./types.ts";
41
42
 
42
43
  /** The activation file. Absent means "this is not an orchestrator session". */
@@ -52,6 +53,43 @@ export const TICK_CUSTOM_TYPE = "omp-conductor.tick";
52
53
  */
53
54
  export const MIN_INTERVAL_SECONDS = 60;
54
55
 
56
+ /**
57
+ * The stall marker — written beside the activation file, in the session cwd —
58
+ * and the number of consecutive coalesced ticks that earn it.
59
+ *
60
+ * This detects a failure that happened. 2026-08-07: the dogfood fleet's
61
+ * orchestrator finished a turn, logged `ui.loop-blocked` immediately after an
62
+ * auto-compaction threshold decision, and never started another. The process
63
+ * stayed alive, so `herdr-conductor`'s recovery — agent listed AND a
64
+ * non-shell foreground process — read healthy, and the dispatch daemon is a
65
+ * different process entirely, so `/healthz` stayed green too. A tick injected
66
+ * two minutes later and an operator's Telegram message five minutes after that
67
+ * both sat unconsumed for 23 minutes, until a manual SIGTERM. This extension
68
+ * had the evidence and threw it away: coalescing logged `tick skipped: tick
69
+ * already pending`, which reads exactly like healthy backpressure.
70
+ *
71
+ * Two in a row is a full hour at the reference 1800s interval — generous by
72
+ * construction, because a turn that legitimately runs an hour belongs to a
73
+ * fleet with larger problems than one spurious marker. The escalation has to
74
+ * leave the session, since a wedged loop cannot report on itself: a file any
75
+ * out-of-process watchdog can stat, plus an error-level line a log scraper can
76
+ * match.
77
+ */
78
+ export const STALL_MARKER_FILE = ".conductor-stalled";
79
+ export const STALL_TICKS = 2;
80
+
81
+ /**
82
+ * The marker's one line after its ISO timestamp, and the middle of the error
83
+ * log. Shared so the file and the log can never describe different failures.
84
+ */
85
+ const STALL_DIAGNOSIS = `${STALL_TICKS} ticks queued unconsumed — the agent loop is not draining`;
86
+
87
+ /**
88
+ * The one skip reason the stall counter reacts to, shared with the decision so
89
+ * a reworded log line cannot silently disarm the detector.
90
+ */
91
+ const PENDING_REASON = "tick already pending";
92
+
55
93
  /**
56
94
  * The slice of the omp extension API this entry touches, mirroring
57
95
  * `ExtensionAPI` / `ExtensionContext` from `@oh-my-pi/pi-coding-agent`.
@@ -146,13 +184,20 @@ export type TickConfigResult =
146
184
  * The prompt when the config names none. The timestamp is what makes two
147
185
  * consecutive ticks distinguishable in the session log.
148
186
  *
187
+ * "Re-read … from disk" is an order, not colour. A 24/7 session holds a copy of
188
+ * the brief from its own start (or its last compaction), and a tick that merely
189
+ * says "run your loop" was observed acting on that cached copy first — which
190
+ * means an operator's amendment, or a dated standing task added between ticks,
191
+ * may never bind. The whole point of a file the operator can edit is that the
192
+ * next tick obeys the file, not the memory of it.
193
+ *
149
194
  * Deliberately silent about reporting volume: that clause is
150
195
  * {@link TICK_SCOPE_CONSTRAINTS}, appended per tick from the configured scope.
151
196
  * A second spelling of it here would contradict the first inside one prompt the
152
197
  * moment a fleet chose `escalations`.
153
198
  */
154
- export function defaultTickMessage(now: Date): string {
155
- return `Tick ${now.toISOString()}: run your standing loop from ORCHESTRATOR.md now.`;
199
+ export function defaultTickMessage(now: Date, briefPath = "ORCHESTRATOR.md"): string {
200
+ return `Tick ${now.toISOString()}: re-read ${briefPath} from disk, then run your standing loop from it.`;
156
201
  }
157
202
 
158
203
  /**
@@ -174,21 +219,49 @@ export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScope]: string } = {
174
219
  };
175
220
 
176
221
  /**
177
- * The scope this tick carries, and — when it had to fall back why.
222
+ * The delivery clause, appended to every default tick prompt.
223
+ *
224
+ * End-of-turn text streams to the operator's Telegram only on a turn that
225
+ * *began* as an inbound Telegram message. A heartbeat tick is injected locally,
226
+ * so it is never such a turn, and a session that believes otherwise reports
227
+ * into a void: on 2026-08-06 the fleet this extension runs produced a release
228
+ * report and two tier-2 escalations as end-of-turn text, and not one of the
229
+ * three reached anybody. What makes a report real is a tool call the session
230
+ * watched succeed, so the prompt says so on every tick rather than trusting a
231
+ * brief that can drift, be edited, or be compacted away.
232
+ */
233
+ export const TICK_DELIVERY_RULE =
234
+ "This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Deliver anything reportable this turn by calling the telegram_send tool and confirming success; never claim a report was sent otherwise.";
235
+
236
+ /**
237
+ * The scope this tick carries, where the brief actually lives, and — when the
238
+ * config could not answer — why.
178
239
  *
179
240
  * Read on every tick rather than cached at session start, for the reason the
180
241
  * channel gate is: the operator re-runs `/conductor setup` while this session
181
242
  * lives, and a heartbeat holding a startup snapshot would keep injecting the
182
243
  * old contract until somebody restarted it.
183
244
  *
184
- * Every fault collapses to {@link DEFAULT_REPORT_SCOPE}: no config written yet,
185
- * an unreadable or invalid one, or several projects with none named — the same
186
- * ambiguity `findProject` refuses to guess through for `status`. Stopping the
187
- * heartbeat over a reporting preference would be the worse trade.
245
+ * `briefPath` exists because the prompt orders a re-read, and an order must
246
+ * name a file that is really there: the brief lives at
247
+ * `<workspaceRoot>/ORCHESTRATOR.md`, not in the session cwd the cwd usually
248
+ * holds only an `AGENTS.md` symlink to it. The name is spelled here rather than
249
+ * imported from setup.ts, whose import graph drags the session SDK into an
250
+ * extension that must stay cheap to load.
251
+ *
252
+ * Every fault collapses to {@link DEFAULT_REPORT_SCOPE} and a pathless prompt:
253
+ * no config written yet, an unreadable or invalid one, or several projects with
254
+ * none named — the same ambiguity `findProject` refuses to guess through for
255
+ * `status`. Stopping the heartbeat over either preference would be the worse
256
+ * trade.
188
257
  */
189
- export function resolveTickScope(): { scope: ReportScope; fallback?: string } {
258
+ export function resolveTickScope(): { scope: ReportScope; briefPath?: string; fallback?: string } {
190
259
  try {
191
- return { scope: findProject(loadConfig()).reporting?.scope ?? DEFAULT_REPORT_SCOPE };
260
+ const project = findProject(loadConfig());
261
+ return {
262
+ scope: project.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
263
+ briefPath: join(project.workspaceRoot, "ORCHESTRATOR.md"),
264
+ };
192
265
  } catch (err) {
193
266
  return { scope: DEFAULT_REPORT_SCOPE, fallback: err instanceof Error ? err.message : String(err) };
194
267
  }
@@ -245,7 +318,7 @@ export function readTickConfig(cwd: string): TickConfigResult {
245
318
  }
246
319
 
247
320
  // Relative paths resolve against the session cwd, so the files can sit beside
248
- // the config that names them (`state/armed`) without hard-coding /root/fleet.
321
+ // the config that names them (`state/armed`) without hard-coding a deploy path.
249
322
  const armedFile = optionalPath(raw["armedFile"], "armedFile", cwd, problems);
250
323
  const accessFile = optionalPath(raw["accessFile"], "accessFile", cwd, problems);
251
324
 
@@ -276,8 +349,18 @@ export function readTickConfig(cwd: string): TickConfigResult {
276
349
  /**
277
350
  * Whether this tick sends, and why — the whole decision, with no clock, no
278
351
  * filesystem and no session in it. The interesting part of a heartbeat is the
279
- * precedence between "paused", "not armed", "channel down" and "already
280
- * pending", and that is worth being able to test without a session at all.
352
+ * precedence between "not armed", "channel down" and "already pending", and
353
+ * that is worth being able to test without a session at all.
354
+ *
355
+ * The pause sentinel is deliberately NOT consulted. `pause` is the dispatch
356
+ * daemon's flag — "stop claiming; in-flight work finishes" — and this heartbeat
357
+ * drives a different brain: the supervising session whose duties (groom the
358
+ * queue, drain escalations, report) are exactly the ones that stay useful while
359
+ * dispatch is stopped. Honouring it here shipped once, when the tick-driven
360
+ * session WAS the dispatcher; the day dispatch moved into the daemon, one flag
361
+ * silencing both brains became a starvation bug: a paused fleet's orchestrator
362
+ * could neither groom nor even say it was paused. The operator's lever for this
363
+ * session is the arm marker — `disarm` stops ticks, and only the operator arms.
281
364
  *
282
365
  * `armed` and `channelOk` are the *satisfied* gates, not the files behind them:
283
366
  * a config with no `armedFile` passes the first, and one with no `accessFile`
@@ -286,7 +369,6 @@ export function readTickConfig(cwd: string): TickConfigResult {
286
369
  * channel gate means "this session is not the fleet", not "the check is off".
287
370
  */
288
371
  export function tickDecision(input: {
289
- paused: boolean;
290
372
  armed: boolean;
291
373
  channelOk: boolean;
292
374
  hasPending: boolean;
@@ -294,10 +376,9 @@ export function tickDecision(input: {
294
376
  send: boolean;
295
377
  reason: string;
296
378
  } {
297
- if (input.paused) return { send: false, reason: "paused" };
298
379
  if (!input.armed) return { send: false, reason: "not armed" };
299
380
  if (!input.channelOk) return { send: false, reason: "escalation channel down" };
300
- if (input.hasPending) return { send: false, reason: "tick already pending" };
381
+ if (input.hasPending) return { send: false, reason: PENDING_REASON };
301
382
  return { send: true, reason: "armed, nothing pending" };
302
383
  }
303
384
 
@@ -331,18 +412,81 @@ function channelIsUp(path: string): boolean {
331
412
  }
332
413
 
333
414
  /**
334
- * One tick: gather the four facts, ask `tickDecision`, log the reason either
335
- * way. Skips are deliberately silent in the UI a paused fleet would otherwise
336
- * emit a notification every interval, forever.
415
+ * The operator's own prompt for this tick, re-read from the session cwd rather
416
+ * than taken from the startup snapshotfor the reason {@link resolveTickScope}
417
+ * and the channel gate re-read: the operator of a 24/7 session reconfigures it
418
+ * out-of-band, and a heartbeat holding a startup copy would keep injecting last
419
+ * week's prompt until somebody restarted the session.
420
+ *
421
+ * A re-read that succeeds owns the answer, "no `message` key any more" included:
422
+ * deleting the override hands the prompt back to the shipped default. A re-read
423
+ * that fails keeps the startup value — a mid-edit truncation, a file moved away
424
+ * or a fault the validator collects must not stop the heartbeat, and must not
425
+ * silently swap the operator's prompt for ours over a transient bad read.
337
426
  *
338
- * `session` holds the only thing one tick remembers for the next: whether the
339
- * scope fallback has been logged. Without it, a host with no conductor config
340
- * would repeat the same line about the same missing file every interval, for as
341
- * long as the session lives.
427
+ * `intervalSeconds` is deliberately *not* re-read here: rescheduling a live
428
+ * managed timer is a different change, so a period edit still needs a restart.
429
+ */
430
+ function currentMessage(cwd: string, startup: TickConfig): string | undefined {
431
+ const reread = readTickConfig(cwd);
432
+ return reread.kind === "ok" ? reread.config.message : startup.message;
433
+ }
434
+
435
+ /**
436
+ * Marker writes are best-effort by construction. This runs inside the loop
437
+ * whose whole job is to keep prompting a session; a read-only filesystem, a
438
+ * full disk, or a cwd deleted out from under a long-lived process is not a
439
+ * reason to stop doing that. Both directions log their own failure and carry
440
+ * on — the error line stands as the record either way, and the marker is only
441
+ * the copy an out-of-process watchdog can see.
342
442
  */
343
- function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: { scopeFallbackLogged: boolean }): void {
443
+ function writeStallMarker(pi: TickApi, cwd: string): void {
444
+ const path = join(cwd, STALL_MARKER_FILE);
445
+ try {
446
+ writeFileSync(path, `${new Date().toISOString()} ${STALL_DIAGNOSIS}\n`);
447
+ } catch (err) {
448
+ pi.logger.error(`[omp-conductor] could not write ${path}: ${err instanceof Error ? err.message : String(err)}`);
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Cleared by the first consumed tick, whether or not this process wrote it.
454
+ * Recovery normally arrives as a *new* session — the wedged one was SIGTERM'd
455
+ * and its transcript resumed — which starts with a zero counter and its
456
+ * predecessor's marker on disk. Gating removal on this session's own count
457
+ * would strand that file, and a stall marker nobody clears is a stall marker
458
+ * nobody believes.
459
+ */
460
+ function clearStallMarker(pi: TickApi, cwd: string): void {
461
+ const path = join(cwd, STALL_MARKER_FILE);
462
+ if (!existsSync(path)) return;
463
+ try {
464
+ rmSync(path, { force: true });
465
+ pi.logger.info("[omp-conductor] orchestrator stall cleared: a tick was consumed");
466
+ } catch (err) {
467
+ pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
468
+ }
469
+ }
470
+
471
+ /** Everything one tick remembers for the next. */
472
+ interface TickSession {
473
+ /**
474
+ * Whether the reporting-scope fallback has been logged. Without it, a host
475
+ * with no conductor config would repeat the same line about the same missing
476
+ * file every interval, for as long as the session lives.
477
+ */
478
+ scopeFallbackLogged: boolean;
479
+ /** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
480
+ pendingSkips: number;
481
+ }
482
+
483
+ /**
484
+ * One tick: gather the three facts, ask `tickDecision`, log the reason either
485
+ * way. Skips are deliberately silent in the UI — a disarmed fleet would
486
+ * otherwise emit a notification every interval, forever.
487
+ */
488
+ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
344
489
  const decision = tickDecision({
345
- paused: isPaused(),
346
490
  armed: config.armedFile === undefined || existsSync(config.armedFile),
347
491
  channelOk: config.accessFile === undefined || channelIsUp(config.accessFile),
348
492
  hasPending: ctx.hasPendingMessages(),
@@ -350,19 +494,35 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: { scop
350
494
 
351
495
  if (!decision.send) {
352
496
  pi.logger.info(`[omp-conductor] tick skipped: ${decision.reason}`, { reason: decision.reason });
497
+ // Only a coalesced skip counts. "Not armed" and "channel down" say nothing
498
+ // about the queue — they are gates the operator or the bridge closed, and
499
+ // the session behind them may be perfectly awake.
500
+ if (decision.reason !== PENDING_REASON) return;
501
+ session.pendingSkips += 1;
502
+ // Only the crossing writes. A later skip refreshing the timestamp would
503
+ // keep moving "stalled since" forward, and how long it has been wedged is
504
+ // the fact the file is read for.
505
+ if (session.pendingSkips === STALL_TICKS) {
506
+ pi.logger.error(`[omp-conductor] orchestrator stalled: ${STALL_DIAGNOSIS}; see ${STALL_MARKER_FILE}`, {
507
+ reason: decision.reason,
508
+ pendingSkips: session.pendingSkips,
509
+ });
510
+ writeStallMarker(pi, ctx.cwd);
511
+ }
353
512
  return;
354
513
  }
355
514
 
356
- // A configured message owns the whole contract, reporting clause included: an
357
- // operator who wrote their own prompt did not ask for ours appended to it.
358
- let content = config.message;
515
+ // A configured message owns the whole contract, reporting and delivery clauses
516
+ // included: an operator who wrote their own prompt did not ask for ours
517
+ // appended to it.
518
+ let content = currentMessage(ctx.cwd, config);
359
519
  if (content === undefined) {
360
520
  const scope = resolveTickScope();
361
521
  if (scope.fallback !== undefined && !session.scopeFallbackLogged) {
362
522
  session.scopeFallbackLogged = true;
363
523
  pi.logger.info(`[omp-conductor] tick reporting scope: using ${DEFAULT_REPORT_SCOPE} — ${scope.fallback}`);
364
524
  }
365
- content = `${defaultTickMessage(new Date())}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}`;
525
+ content = `${defaultTickMessage(new Date(), scope.briefPath)}\n${TICK_SCOPE_CONSTRAINTS[scope.scope]}\n${TICK_DELIVERY_RULE}`;
366
526
  }
367
527
 
368
528
  pi.sendMessage(
@@ -370,6 +530,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: { scop
370
530
  { triggerTurn: true, deliverAs: "followUp" },
371
531
  );
372
532
  pi.logger.info(`[omp-conductor] tick sent: ${decision.reason}`, { reason: decision.reason });
533
+ // An empty queue at send time is the proof the previous tick was consumed, so
534
+ // this is the only place either the counter or the marker is cleared.
535
+ session.pendingSkips = 0;
536
+ clearStallMarker(pi, ctx.cwd);
373
537
  }
374
538
 
375
539
  export default function orchestratorTickExtension(pi: TickApi): void {
@@ -377,9 +541,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
377
541
  // `session_start` cannot install a second heartbeat on the same session.
378
542
  let armed = false;
379
543
  // Held per registration for the same reason: the "using the default reporting
380
- // scope, because ..." line is logged once for this heartbeat, and a second
381
- // session in the same process starts with its own count.
382
- const session = { scopeFallbackLogged: false };
544
+ // scope, because ..." line is logged once for this heartbeat, and the stall
545
+ // counter is about this session's own queue. A second session in the same
546
+ // process starts with both at zero.
547
+ const session: TickSession = { scopeFallbackLogged: false, pendingSkips: 0 };
383
548
 
384
549
  pi.on("session_start", (_event, ctx) => {
385
550
  if (armed) return;
@@ -387,11 +552,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
387
552
  // A subagent inherits the orchestrator's cwd, so it finds the same
388
553
  // activation file and would arm a heartbeat of its own — one extra tick
389
554
  // per worker, each prompting a session whose whole contract is to finish
390
- // and yield. The discriminator is omp-telegram's, which has been running
391
- // it in production (`isTaskSubagent`, ~/VSCode/omp/plugins/telegram/src/
392
- // index.ts:87-90, applied at index.ts:1951-1952): task sessions are
393
- // headless *and* always carry the `yield` tool. Neither half suffices
394
- // alone — a headless root session (print/RPC mode) has no `yield`, and an
555
+ // and yield. The discriminator is the one omp-telegram has been running in
556
+ // production: task sessions are headless *and* always carry the `yield`
557
+ // tool. Neither half suffices alone a headless root session (print/RPC
558
+ // mode) has no `yield`, and an
395
559
  // interactive session may well have one. Checked before the config read
396
560
  // so the overwhelmingly common case never touches the filesystem.
397
561
  if (!ctx.hasUI && pi.getActiveTools().includes("yield")) {
package/src/plugin.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  DEFAULT_REPORT_SCOPE,
44
44
  type Caps,
45
45
  type ConductorConfig,
46
+ type OrchestratorMode,
46
47
  type ProjectConfig,
47
48
  type ReportScope,
48
49
  } from "./types.ts";
@@ -239,6 +240,53 @@ async function askReportScope(ctx: CommandContext, current: ReportScope): Promis
239
240
  return choice.scope;
240
241
  }
241
242
 
243
+ /**
244
+ * Who merges and who releases. Two confirms rather than one four-way list:
245
+ * these are independent grants — delegating merges is routine, delegating
246
+ * releases is not — and a menu of four combinations frames them as equally
247
+ * ordinary choices, which is exactly the framing a release grant must not get.
248
+ *
249
+ * Neither confirm can start on "yes", so a re-run that Enters through the
250
+ * wizard revokes rather than renews. That is the safe direction, and the
251
+ * current grant is named in the question so the revoke is never a surprise.
252
+ */
253
+ async function askAuthority(
254
+ ctx: CommandContext,
255
+ prior: ProjectConfig["authority"],
256
+ ): Promise<ProjectConfig["authority"]> {
257
+ const merge = await ctx.ui.confirm(
258
+ "Merge authority",
259
+ "Delegate PR merging to the orchestrator session? It would land green PRs one at a time, each " +
260
+ "re-checked against the base branch first. Default: humans merge" +
261
+ `${prior.merge === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
262
+ );
263
+ const release = await ctx.ui.confirm(
264
+ "Release authority",
265
+ "Delegate release cutting to the orchestrator session? It would tag, pin and publish by the " +
266
+ "procedure you write into its brief — and its brief forbids cutting one before you have. " +
267
+ "Default: humans release" +
268
+ `${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
269
+ );
270
+ return { merge: merge ? "orchestrator" : "human", release: release ? "orchestrator" : "human" };
271
+ }
272
+
273
+ /**
274
+ * Where the session that triages escalations lives. Phrased as a fact about the
275
+ * host rather than a preference, because that is what it is: answering yes when
276
+ * no such session exists leaves tier-1 escalations sitting in issue comments
277
+ * that nobody drains.
278
+ */
279
+ async function askOrchestratorMode(ctx: CommandContext, prior: OrchestratorMode): Promise<OrchestratorMode> {
280
+ const external = await ctx.ui.confirm(
281
+ "Orchestrator session",
282
+ "Do you already run your own orchestrator session for this project — a visible TUI session, say? " +
283
+ "Then the daemon starts none of its own, and posts tier-1 escalations as issue comments for yours " +
284
+ "to drain. Default: no, the daemon runs one" +
285
+ `${prior === "external" ? " — currently external" : ""}.`,
286
+ );
287
+ return external ? "external" : "embedded";
288
+ }
289
+
242
290
  /**
243
291
  * Whether to render the operator's own brief, and — separately — whether an
244
292
  * existing one may be replaced. Two questions on purpose: that file is where a
@@ -382,6 +430,11 @@ async function collectAnswers(
382
430
  );
383
431
  }
384
432
 
433
+ // Straight after the caps, and for the same reason they sit together: these
434
+ // are the two questions that decide what an unattended fleet may do without
435
+ // asking anybody.
436
+ const authority = await askAuthority(ctx, prior?.authority ?? SETUP_DEFAULTS.authority);
437
+
385
438
  // Outside the caps block: a model is not a ceiling, and an operator who left
386
439
  // the caps alone may still want workers on a cheaper model.
387
440
  const answeredModel = await ask(
@@ -417,6 +470,11 @@ async function collectAnswers(
417
470
  "Also comment on the issue when a run escalates? Recommended: a chat message you miss is a run nobody sees.",
418
471
  );
419
472
 
473
+ const orchestratorMode = await askOrchestratorMode(
474
+ ctx,
475
+ prior?.escalation.orchestrator ?? SETUP_DEFAULTS.orchestratorMode,
476
+ );
477
+
420
478
  const reportScope = await askReportScope(ctx, prior?.reporting?.scope ?? DEFAULT_REPORT_SCOPE);
421
479
 
422
480
  const answers: SetupAnswers = {
@@ -428,6 +486,8 @@ async function collectAnswers(
428
486
  targetRepos,
429
487
  caps,
430
488
  fallbackToIssueComment,
489
+ authority,
490
+ orchestratorMode,
431
491
  reportScope,
432
492
  // Asked last, and asked with the real path in the question — which needs the
433
493
  // rest of the answers to derive, so the decision is folded in below.