@bakers-build/conductor 0.0.0-stage → 0.1.0-next.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/dist/src/bakers-project-context.d.ts +12 -0
  4. package/dist/src/bakers-project-context.js +59 -0
  5. package/dist/src/cli/config-plan.d.ts +35 -0
  6. package/dist/src/cli/config-plan.js +1264 -0
  7. package/dist/src/cli/config-schema.d.ts +137 -0
  8. package/dist/src/cli/config-schema.js +239 -0
  9. package/dist/src/cli/config-source-edit.d.ts +17 -0
  10. package/dist/src/cli/config-source-edit.js +95 -0
  11. package/dist/src/cli/config-templates.d.ts +43 -0
  12. package/dist/src/cli/config-templates.js +130 -0
  13. package/dist/src/cli/config-validation.d.ts +12 -0
  14. package/dist/src/cli/config-validation.js +353 -0
  15. package/dist/src/cli/migrate-addon-names.d.ts +82 -0
  16. package/dist/src/cli/migrate-addon-names.js +334 -0
  17. package/dist/src/cli/project-context.d.ts +21 -0
  18. package/dist/src/cli/project-context.js +105 -0
  19. package/dist/src/commands/conductor.d.ts +91 -0
  20. package/dist/src/commands/conductor.js +843 -0
  21. package/dist/src/config.d.ts +109 -0
  22. package/dist/src/config.js +712 -0
  23. package/dist/src/contract.d.ts +13 -0
  24. package/dist/src/contract.js +19 -0
  25. package/dist/src/core/action-queue.d.ts +31 -0
  26. package/dist/src/core/action-queue.js +69 -0
  27. package/dist/src/core/attempt.d.ts +25 -0
  28. package/dist/src/core/attempt.js +41 -0
  29. package/dist/src/core/conductor.d.ts +236 -0
  30. package/dist/src/core/conductor.js +1293 -0
  31. package/dist/src/core/host-key.d.ts +19 -0
  32. package/dist/src/core/host-key.js +27 -0
  33. package/dist/src/core/inbox-runtime.d.ts +48 -0
  34. package/dist/src/core/inbox-runtime.js +180 -0
  35. package/dist/src/core/mr-changes.d.ts +18 -0
  36. package/dist/src/core/mr-changes.js +47 -0
  37. package/dist/src/index.d.ts +8 -0
  38. package/dist/src/index.js +27 -0
  39. package/dist/src/plugins/agent-args.d.ts +43 -0
  40. package/dist/src/plugins/agent-args.js +221 -0
  41. package/dist/src/plugins/agent.d.ts +121 -0
  42. package/dist/src/plugins/agent.js +12 -0
  43. package/dist/src/plugins/executor.d.ts +182 -0
  44. package/dist/src/plugins/executor.js +1 -0
  45. package/dist/src/plugins/message.d.ts +61 -0
  46. package/dist/src/plugins/message.js +47 -0
  47. package/dist/src/plugins/plugins.d.ts +96 -0
  48. package/dist/src/plugins/plugins.js +120 -0
  49. package/dist/src/plugins/preset.d.ts +61 -0
  50. package/dist/src/plugins/preset.js +23 -0
  51. package/dist/src/plugins/process-host.d.ts +7 -0
  52. package/dist/src/plugins/process-host.js +1 -0
  53. package/dist/src/plugins/remote.d.ts +420 -0
  54. package/dist/src/plugins/remote.js +1 -0
  55. package/dist/src/plugins/workspace.d.ts +69 -0
  56. package/dist/src/plugins/workspace.js +1 -0
  57. package/dist/src/preset.d.ts +2 -0
  58. package/dist/src/preset.js +8 -0
  59. package/dist/src/types.d.ts +132 -0
  60. package/dist/src/types.js +6 -0
  61. package/package.json +38 -4
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Per-agent effort footprint parsed from that agent's run transcript
3
+ * (BB-132). The metrics are transcript-derived and therefore
4
+ * **agent-specific** (each agent's transcript has its own format), so parsing
5
+ * lives behind the agent abstraction (see {@link BbAgent.parseFootprint}) —
6
+ * the conductor stays agent-agnostic and writes the footprint verbatim.
7
+ * `duration_s` is likewise derived from the transcript (its first→last entry
8
+ * timestamps), NOT recomputed by the conductor from a re-read `started_at`
9
+ * (BB-151): the log is the single source of the run's wall-clock length, so
10
+ * there is no timestamp round-trip through JSON:API to mis-parse.
11
+ */
12
+ export interface AgentFootprint {
13
+ /** Total tokens across every usage bucket of every assistant turn. */
14
+ tokens: number;
15
+ /**
16
+ * Wall-clock run length in seconds, derived from the transcript's first→last
17
+ * entry timestamps (BB-151). 0 when the transcript has fewer than two
18
+ * timestamped entries (empty/absent log).
19
+ */
20
+ duration_s: number;
21
+ /** Number of assistant turns in the transcript. */
22
+ agent_turns: number;
23
+ /** Number of tool-use calls across all assistant turns. */
24
+ tool_calls: number;
25
+ /** Number of user-submitted prompts. */
26
+ user_prompts: number;
27
+ /** Total words across those user prompts. */
28
+ user_prompt_words: number;
29
+ /**
30
+ * Active agent time in seconds: `duration_s` minus the time spent waiting on
31
+ * the human, so the wait is `duration_s - active_s`. 0 means not measured,
32
+ * which is also what harnesses without a net-time parser report.
33
+ */
34
+ active_s: number;
35
+ /** Model of the last assistant turn, when present (informational). */
36
+ model?: string;
37
+ }
38
+ /** An all-zero footprint — the honest result for an empty/absent transcript. */
39
+ export declare function emptyAgentFootprint(): AgentFootprint;
40
+ /**
41
+ * A bakers.build agent: the program the conductor runs to work a ticket (e.g. claude).
42
+ * It knows how it is launched (CLI + model + flags) and where its per-run
43
+ * transcript/log lives — so the conductor stays agent-agnostic and the CLI can
44
+ * attach the run log on release without agent-specific knowledge.
45
+ */
46
+ /**
47
+ * Identity of the run a transcript lookup is scoped to (BB-473): the run's
48
+ * own UUID (usable as a session id by agents that support pinning one) and,
49
+ * optionally, when the run started (a lower bound for agents with no session
50
+ * mechanism at all, so a stale transcript from a prior run in the same
51
+ * worktree/cwd can never be selected). Both fields are optional so a call
52
+ * site or addon that predates BB-473 keeps compiling and behaving exactly
53
+ * as before (today's mtime/cwd-scan fallback).
54
+ */
55
+ export interface AgentRunIdentity {
56
+ sessionId: string;
57
+ startedAt?: string | number | Date;
58
+ /**
59
+ * The run's full deterministic attempt-id family (BB-473 review round 4,
60
+ * Critical 2), attempt 0 (== `sessionId`) first. When present, `getRunLog`
61
+ * should select the transcript of the HIGHEST-numbered attempt whose file
62
+ * actually exists, instead of the `startedAt` time window — a window
63
+ * selects by *when*, which a sibling session newer than the bound can
64
+ * still beat; a family selects by *which attempt*, which nothing outside
65
+ * this run can collide with. Omitted, callers keep the pre-round-4
66
+ * single-`sessionId` behaviour.
67
+ */
68
+ attemptFamily?: string[];
69
+ }
70
+ export interface BbAgent {
71
+ /** Stable id, e.g. 'claude'. */
72
+ readonly id: string;
73
+ /**
74
+ * Informational notes the conductor logs once per dispatch, before
75
+ * `startRun`. Unsupported effort on a harness that cannot express it is
76
+ * the current producer. Omitted when there is nothing to say.
77
+ */
78
+ readonly launchWarnings?: readonly string[];
79
+ /**
80
+ * Build the full agent CLI invocation for a bakers.build prompt. With an empty
81
+ * prompt, returns the bare launch command (no prompt argument).
82
+ *
83
+ * `run`, when given (BB-473), lets an agent that supports pinning its own
84
+ * session/transcript id do so at launch (e.g. `claude --session-id
85
+ * <uuid>`), so `getRunLog` can read that one file directly instead of
86
+ * scanning a worktree-keyed directory by mtime — the mtime scan is what let
87
+ * a resumed/re-dispatched/reused worktree finalise a FOREIGN transcript.
88
+ */
89
+ launchCommand(prompt: string, run?: AgentRunIdentity): string;
90
+ /**
91
+ * Locate and read this agent's run transcript for a run that executed in
92
+ * `worktreePath`. MUST return '' (never throw) when nothing is found.
93
+ *
94
+ * `run`, when given (BB-473), scopes the lookup to that run's own
95
+ * transcript — by direct filename lookup for agents that support pinning a
96
+ * session id at launch, or (for agents with no such mechanism) by
97
+ * discarding any transcript older than `run.startedAt`. Omitted, the call
98
+ * preserves today's directory-scan-by-mtime behaviour.
99
+ */
100
+ getRunLog(worktreePath: string, run?: AgentRunIdentity): Promise<string>;
101
+ /**
102
+ * Whether this agent already owns a transcript/session named `sessionId`
103
+ * under `worktreePath` (BB-473). Some CLIs refuse to start when a
104
+ * `--session-id` they are handed is already in use (`claude`, `grok`), so
105
+ * the conductor calls this **before** pinning the run's own UUID into
106
+ * `launchCommand` — a live re-dispatch of the same run must still be able
107
+ * to launch, even though the UUID it would pin already names a transcript
108
+ * from the run's first attempt. Optional: an agent whose launch mechanism
109
+ * cannot collide (no pinning at all, or a CLI that creates-if-missing like
110
+ * `pi`) declares none, and the conductor always pins for it.
111
+ */
112
+ sessionExists?(worktreePath: string, sessionId: string): Promise<boolean>;
113
+ /**
114
+ * Parse this agent's run transcript (as returned by {@link getRunLog}) into
115
+ * a footprint of effort metrics (BB-132). The transcript format is
116
+ * agent-specific, so each agent owns its own parser. MUST be tolerant of
117
+ * blank/partial/absent input (never throw) — an empty log yields
118
+ * {@link emptyAgentFootprint}.
119
+ */
120
+ parseFootprint(log: string): AgentFootprint;
121
+ }
@@ -0,0 +1,12 @@
1
+ /** An all-zero footprint — the honest result for an empty/absent transcript. */
2
+ export function emptyAgentFootprint() {
3
+ return {
4
+ tokens: 0,
5
+ duration_s: 0,
6
+ agent_turns: 0,
7
+ tool_calls: 0,
8
+ user_prompts: 0,
9
+ user_prompt_words: 0,
10
+ active_s: 0,
11
+ };
12
+ }
@@ -0,0 +1,182 @@
1
+ import type { Ticket } from './remote.js';
2
+ export interface SpawnedSession {
3
+ sessionRef: string;
4
+ }
5
+ export interface ExecutorCapabilities {
6
+ persistent: boolean;
7
+ }
8
+ export interface SpawnRunInput {
9
+ ticket: {
10
+ uuid: string;
11
+ identifier: string;
12
+ title: string;
13
+ branchName: string;
14
+ state: string;
15
+ url?: string;
16
+ };
17
+ run: {
18
+ uuid: string;
19
+ id: number;
20
+ handler: string;
21
+ };
22
+ workspacePath: string;
23
+ instructions: {
24
+ path: string;
25
+ sha256: string;
26
+ text: string;
27
+ } | null;
28
+ command?: string;
29
+ env?: Record<string, string>;
30
+ }
31
+ /** The lifecycle-hook slots, keyed exactly like `config.hooks`. */
32
+ export type HookName = 'after_create' | 'before_run' | 'after_run' | 'after_done' | 'on_freeze' | 'on_resume';
33
+ /**
34
+ * What an addon hook listener receives (BB-599). One context object is
35
+ * shared by every listener of one dispatch, so a key a `before_run` listener
36
+ * adds to `env` reaches the next listener, that slot's shell hook and the
37
+ * agent launch. Reserved keys (`BB_*`, `WORKSPACE_ROOT`, `TICKET_URL`) and
38
+ * invalid entries are restored after each listener. `env` values may be
39
+ * secret: never log them.
40
+ *
41
+ * At `after_run` and `after_done` the context is rebuilt from the current
42
+ * ticket and the durable run record, not from a launch-time snapshot — a
43
+ * listener must find what it started by `run.uuid`, not by a captured value.
44
+ */
45
+ export interface HookListenerContext {
46
+ /** Absolute path of the ticket's worktree (the hook's cwd). */
47
+ worktreePath: string;
48
+ ticket: Ticket;
49
+ /** The run's state at start; at `after_done` the ticket's current state. */
50
+ state: string;
51
+ env: Record<string, string>;
52
+ /** The run this slot belongs to; absent at `after_done` without a run. */
53
+ run?: {
54
+ uuid: string;
55
+ id?: number;
56
+ };
57
+ }
58
+ /** A listener's callback for one slot. Awaited; a failure is logged, never fatal. */
59
+ export type HookListenerFn = (ctx: HookListenerContext) => void | Promise<void>;
60
+ /**
61
+ * One addon contribution to the conductor `hooks` accumulator (BB-599): an
62
+ * object with an optional function per lifecycle slot. Listeners run in
63
+ * accumulator order, each before the slot's project shell hook.
64
+ */
65
+ export type HookListener = {
66
+ [K in HookName]?: HookListenerFn;
67
+ } & {
68
+ /** Names the listener in conductor logs. */
69
+ id?: string;
70
+ };
71
+ /** Context for a hook invocation — carries the ticket for observable logging. */
72
+ export interface HookContext {
73
+ /** The ticket identifier/uuid the hook is running for (log context). */
74
+ ticket: string;
75
+ }
76
+ /**
77
+ * What a lifecycle hook invocation DID (BB-237). `runHook` still never throws,
78
+ * but it no longer swallows the outcome silently: it reports it, so a caller can
79
+ * record a failed provisioning hook against the run it is about to dispatch
80
+ * instead of relying on a log line to survive (AC-6). Reporting — rather than
81
+ * throwing — keeps the never-wedge property that the never-throw contract exists
82
+ * for; boundedness stays with the conductor's circuit breaker.
83
+ */
84
+ export interface HookResult {
85
+ /** The hook slot that was invoked. */
86
+ hook: HookName;
87
+ /** false when no command is configured for the slot — nothing ran. */
88
+ ran: boolean;
89
+ /** false ONLY when a configured command actually failed. */
90
+ ok: boolean;
91
+ /** The stringified failure; present iff `ok` is false. */
92
+ error?: string;
93
+ }
94
+ /**
95
+ * Outcome of a worktree teardown (BB-293).
96
+ *
97
+ * - `removed` — the worktree was torn down on this host, or the prunable
98
+ * admin entry it left behind was swept.
99
+ * - `already_absent` — nothing to tear down: neither git nor the filesystem
100
+ * still has it. A FINISHED teardown, not a failure.
101
+ * - `unverified` — it could not be resolved or removed and may still be on
102
+ * disk; the reaper leaves the ticket on its work list.
103
+ *
104
+ * Replaces the former `boolean`, which could not distinguish "gone because we
105
+ * removed it" from "gone before we got here" — the distinction the reaper needs
106
+ * in order to record the absence rather than swallow it. The mapping preserves
107
+ * the old semantics exactly: `removed` is the old `true`, `unverified` the old
108
+ * `false`.
109
+ */
110
+ export type WorktreeTeardown = 'removed' | 'already_absent' | 'unverified';
111
+ export interface BbExecutor {
112
+ id: string;
113
+ capabilities(): ExecutorCapabilities;
114
+ /**
115
+ * Run the lifecycle hook `name` (command from `config.hooks[name]`) in `cwd`,
116
+ * best-effort. This is the SINGLE catch point for all lifecycle hooks:
117
+ *
118
+ * - MUST NEVER throw. No configured command → silent no-op. A failing command
119
+ * → `logger.error({hook,worktree,ticket,err}, 'lifecycle hook failed')` then
120
+ * return. So a hook failure never aborts dispatch or wedges a run in
121
+ * `claimed`.
122
+ * - The underlying shell command still fails honestly (a non-zero exit
123
+ * rejects); only the executor catches + logs + continues.
124
+ * - MUST report the outcome as a {@link HookResult} (BB-237): never throwing
125
+ * is not the same as never telling. The caller decides what a failure means
126
+ * for the run it is dispatching; the executor only refuses to abort.
127
+ *
128
+ * `env`, when given, is the run's resolved environment (per-ticket env_vars
129
+ * merged with the core BB_* vars, BB-99), merged over the hook process's
130
+ * inherited env. Values may be sensitive — implementations must never log
131
+ * them (log key names only).
132
+ */
133
+ runHook(name: HookName, cwd: string, ctx: HookContext, env?: Record<string, string>): Promise<HookResult>;
134
+ startRun(input: SpawnRunInput): Promise<SpawnedSession>;
135
+ /**
136
+ * Signal the run's agent to stop. Called by the conductor's run-finalise pass
137
+ * BEFORE it captures the agent transcript (BB-132), so a stop must not
138
+ * destroy the log. For herdr this is a NO-OP: at finalise time the agent is
139
+ * idle and its jsonl transcript is already fully written to disk, and the
140
+ * subsequent {@link cleanupRun} tab-close kills the PTY (the agent dies with
141
+ * it). The seam exists for executors whose agent outlives its UI surface.
142
+ * Best-effort; gated by the conductor on `capabilities().persistent`.
143
+ */
144
+ stopRun(branch: string): Promise<void>;
145
+ /**
146
+ * Tear down ONE run's hosted UI surface — for herdr, close only the tab whose
147
+ * label carries the `#<runId>` token (`startRun` labels every tab
148
+ * `<identifier> · <state> #<run.id>`), via `herdr tab close <tab_id>` (killing
149
+ * that PTY). Run-scoped by design (BB-183): a sibling run's tab in the same
150
+ * branch workspace — a still-open prior-state run or a concurrent re-claim —
151
+ * and any non-run tab are left untouched. Called as the LAST finalisation step,
152
+ * strictly after the transcript is captured, and again at dispatch to clear a
153
+ * reused workspace's leftover tab for the run being (re-)started. No matching
154
+ * tab → a quiet no-op. Does NOT touch the branch worktree (that is the
155
+ * reap/{@link removeWorktree} lifecycle). Best-effort: a failing tab-close is
156
+ * swallowed and never aborts finalisation or dispatch. A no-op for
157
+ * non-persistent executors.
158
+ */
159
+ cleanupRun(branch: string, runId: number): Promise<void>;
160
+ /**
161
+ * Tear down the branch's entire worktree (git worktree + hosted workspace),
162
+ * reclaiming its disk. Called by the conductor's ticket-cleanup pass once a
163
+ * ticket is done. A no-op for non-persistent executors (no hosted workspace);
164
+ * the conductor gates the call on `capabilities().persistent`.
165
+ *
166
+ * `worktreePath` is the stable, identifier-derived worktree path (from the
167
+ * ticket's latest run). Prefer it over `branch` to resolve the worktree: the
168
+ * checked-out branch is mutable (the coding agent may rename/switch it), the
169
+ * path is not.
170
+ *
171
+ * Reports what the teardown DID as a {@link WorktreeTeardown} (BB-293):
172
+ * `removed` when the worktree was present on this host and torn down (or its
173
+ * prunable admin entry swept), `already_absent` when there was nothing to
174
+ * tear down at all, and `unverified` when nothing matched here and the
175
+ * worktree may still be on disk (another conductor's host, or a path that
176
+ * could not be resolved). The reaper flags a ticket `cleaned_up` for the
177
+ * first two and leaves it on the work list for `unverified`, so it never
178
+ * false-teardowns a worktree another host physically holds. A genuine
179
+ * failure still throws (surfaced as a teardown miss).
180
+ */
181
+ removeWorktree(branch: string, worktreePath?: string): Promise<WorktreeTeardown>;
182
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import type { ConductorLogger } from '@bakers-build/core';
2
+ import { type MessageDefinition, type MessageResult } from '@bakers-build/message';
3
+ import type { Ticket } from './remote.js';
4
+ /** What a handler gets: its own request identity plus the ticket's worktree. */
5
+ export interface InboxMessageRequest {
6
+ /** The bb_inbox_message entity UUID; the request's own identity, never the message type. */
7
+ id: string;
8
+ /** The namespaced message type, e.g. `ports.tunnel`. */
9
+ messageType: string;
10
+ addonVersion: string;
11
+ requestedBy: string;
12
+ ticket: Ticket;
13
+ payload?: Record<string, unknown>;
14
+ worktreePath: string;
15
+ env: Record<string, string>;
16
+ logger: ConductorLogger;
17
+ /** Aborts when the handler runs past its timeout. */
18
+ signal: AbortSignal;
19
+ }
20
+ export type InboxMessageHandler = (request: InboxMessageRequest) => Promise<MessageResult>;
21
+ /** An addon preset's `inbox` export. */
22
+ export interface InboxMessages {
23
+ contract: 2;
24
+ label?: string;
25
+ /** Inline SVG markup, at most 8192 characters; shown as an image only. */
26
+ icon?: string;
27
+ messages: Record<string, MessageDefinition>;
28
+ /** Lazy: imported only when the conductor loads the addon. */
29
+ handlers: (options?: unknown) => Promise<Record<string, InboxMessageHandler>>;
30
+ }
31
+ /** One `inbox` export as discovery found it. */
32
+ export interface DiscoveredInbox {
33
+ name: string;
34
+ version: string;
35
+ inbox: InboxMessages;
36
+ /** The addon entry's `with` options, handed to `inbox.handlers`. */
37
+ options?: unknown;
38
+ }
39
+ /** A validated inbox addon with its handlers resolved. */
40
+ export interface LoadedInbox {
41
+ name: string;
42
+ version: string;
43
+ label: string | undefined;
44
+ icon?: string;
45
+ messages: Record<string, MessageDefinition>;
46
+ handlers: Record<string, InboxMessageHandler>;
47
+ }
48
+ /**
49
+ * Validates every declared message definition, checks that handlers and
50
+ * declared message types match one to one, and rejects a contract other than
51
+ * 2. `handlers` gets `options` (the addon entry's `with`). Throws an Error naming
52
+ * the addon on any violation.
53
+ */
54
+ export declare function loadInbox(found: DiscoveredInbox, options?: unknown): Promise<LoadedInbox>;
55
+ /** Validates a request's payload against its message's input schema. */
56
+ export declare function validateRequestPayload(messages: Record<string, MessageDefinition>, messageType: string, payload: unknown): {
57
+ ok: true;
58
+ } | {
59
+ ok: false;
60
+ errors: string[];
61
+ };
@@ -0,0 +1,47 @@
1
+ // The InboxMessageHandler contract on the conductor surface: an addon's
2
+ // preset may export `inbox` — a map of message types plus LAZY handlers, so
3
+ // a command-only load never imports them. Contract 2 replaces the retired
4
+ // `actions`/views/rows contract (BB-661): this is the only live inbox
5
+ // contract.
6
+ import { MESSAGE_CONTRACT_VERSION, validateArgs, validateMessageDefinition, } from '@bakers-build/message';
7
+ /**
8
+ * Validates every declared message definition, checks that handlers and
9
+ * declared message types match one to one, and rejects a contract other than
10
+ * 2. `handlers` gets `options` (the addon entry's `with`). Throws an Error naming
11
+ * the addon on any violation.
12
+ */
13
+ export async function loadInbox(found, options) {
14
+ const fail = (msg) => new Error(`addon ${found.name}: ${msg}`);
15
+ if (found.inbox.contract !== MESSAGE_CONTRACT_VERSION)
16
+ throw fail(`unsupported inbox contract ${found.inbox.contract}`);
17
+ for (const [key, def] of Object.entries(found.inbox.messages)) {
18
+ const valid = validateMessageDefinition(def);
19
+ if (!valid.ok)
20
+ throw fail(`messages.${key}: ${valid.errors.join('; ')}`);
21
+ }
22
+ const handlers = await found.inbox.handlers(options);
23
+ const declared = Object.keys(found.inbox.messages);
24
+ for (const key of declared) {
25
+ if (typeof handlers[key] !== 'function')
26
+ throw fail(`no handler for message ${key}`);
27
+ }
28
+ for (const key of Object.keys(handlers)) {
29
+ if (!declared.includes(key))
30
+ throw fail(`handler ${key} is not declared`);
31
+ }
32
+ return {
33
+ name: found.name,
34
+ version: found.version,
35
+ label: found.inbox.label,
36
+ ...(found.inbox.icon !== undefined ? { icon: found.inbox.icon } : {}),
37
+ messages: found.inbox.messages,
38
+ handlers,
39
+ };
40
+ }
41
+ /** Validates a request's payload against its message's input schema. */
42
+ export function validateRequestPayload(messages, messageType, payload) {
43
+ const def = messages[messageType];
44
+ if (!def)
45
+ return { ok: false, errors: [`unknown message type ${messageType}`] };
46
+ return validateArgs(def.input, payload);
47
+ }
@@ -0,0 +1,96 @@
1
+ import type { AgentRoutes, ConductorLogger } from '@bakers-build/core';
2
+ import type { DropSHPlugin } from 'dropsh/plugin';
3
+ import type { ConductorFileConfig } from '../types.js';
4
+ import type { BbAgent } from './agent.js';
5
+ import type { BbExecutor } from './executor.js';
6
+ import type { BbRemote, Ticket } from './remote.js';
7
+ import type { BbWorkspace } from './workspace.js';
8
+ /** Runtime dependencies handed to a plugin factory at selection time. */
9
+ export interface ExecutorDeps {
10
+ /** The in-scope conductor logger — the executor logs hook failures through it. */
11
+ logger: ConductorLogger;
12
+ }
13
+ export interface RemotePlugin extends DropSHPlugin {
14
+ readonly kind: 'remote';
15
+ readonly id: string;
16
+ readonly requiredModules: string[];
17
+ createRemote(config: ConductorFileConfig): Promise<BbRemote>;
18
+ }
19
+ export interface ExecutorPlugin extends DropSHPlugin {
20
+ readonly kind: 'executor';
21
+ readonly id: string;
22
+ readonly requiredModules: string[];
23
+ createExecutor(config: ConductorFileConfig, deps: ExecutorDeps): Promise<BbExecutor>;
24
+ }
25
+ export interface WorkspacePlugin extends DropSHPlugin {
26
+ readonly kind: 'workspace';
27
+ readonly id: string;
28
+ readonly requiredModules: string[];
29
+ /**
30
+ * BB-234: `deps` is **optional** so every existing workspace plugin
31
+ * (`addon-workspace-git`, `addon-fake`) keeps compiling untouched — a
32
+ * workspace that logs nothing simply ignores it. It reuses {@link ExecutorDeps}
33
+ * rather than minting a `WorkspaceDeps` alias: that interface is already the
34
+ * contract's name for "runtime deps handed to a plugin factory", and a second
35
+ * identical shape would be two names for one thing.
36
+ */
37
+ createWorkspace(config: ConductorFileConfig, deps?: ExecutorDeps): Promise<BbWorkspace>;
38
+ }
39
+ export interface AgentPlugin extends DropSHPlugin {
40
+ readonly kind: 'agent';
41
+ readonly id: string;
42
+ readonly requiredModules: string[];
43
+ createAgent(config: ConductorFileConfig): Promise<BbAgent>;
44
+ }
45
+ /** Resolves the remote from its named config slot. */
46
+ export declare function selectRemote(config: ConductorFileConfig): Promise<BbRemote>;
47
+ /** Resolves the executor from its named config slot, injecting the logger. */
48
+ export declare function selectExecutor(config: ConductorFileConfig, logger: ConductorLogger): Promise<BbExecutor>;
49
+ /**
50
+ * Resolves the workspace from its named config slot, injecting the logger when
51
+ * the caller has one — mirroring {@link selectExecutor}. Without it the herdr
52
+ * workspace's best-effort open-layout warn would land on a noop logger
53
+ * (BB-234).
54
+ */
55
+ export declare function selectWorkspace(config: ConductorFileConfig, logger?: ConductorLogger): Promise<BbWorkspace>;
56
+ /** A config-side agent choice: an agent plugin + an optional static priority over the ticket. */
57
+ export interface AgentCandidate {
58
+ alias?: string;
59
+ description?: string;
60
+ model?: string;
61
+ modelVersion?: string;
62
+ agent: AgentPlugin;
63
+ priority?: (ticket: Ticket) => number;
64
+ }
65
+ /** A constructed candidate: the agent id, its BbAgent, and the config-side priority. */
66
+ export interface ResolvedAgent {
67
+ alias?: string;
68
+ description?: string;
69
+ /** The candidate's configured model, when it pins one. Preferred over the
70
+ * transcript-derived model at run close, because an explicit human answer
71
+ * beats a guess; absent when the harness's own local configuration chooses
72
+ * the model, in which case the transcript's raw model id is used instead. */
73
+ model?: string;
74
+ /** The candidate's configured exact model version, when it pins one.
75
+ * Preferred over the transcript-derived version at run close for the same
76
+ * reason as `model`; absent when the transcript's raw model id should be
77
+ * used instead. */
78
+ modelVersion?: string;
79
+ id: string;
80
+ agent: BbAgent;
81
+ priority?: (ticket: Ticket) => number;
82
+ }
83
+ /**
84
+ * Pick the agent for a ticket: highest `priority(ticket)` wins; a candidate with
85
+ * no `priority` scores lowest; ties resolve by config array order (stable sort).
86
+ */
87
+ export declare function selectAgent(candidates: ResolvedAgent[], ticket: Ticket): ResolvedAgent;
88
+ /** Constructs every agent candidate from the normalized effective catalog. */
89
+ export declare function selectAgents(config: ConductorFileConfig): Promise<ResolvedAgent[]>;
90
+ /** Resolve logical aliases without changing the legacy priority selector. */
91
+ export declare function resolveAgent(candidates: ResolvedAgent[], ticket: Ticket, options?: {
92
+ warn?: (message: string) => void;
93
+ selectAgent?: ConductorFileConfig['selectAgent'];
94
+ routes?: AgentRoutes;
95
+ defaultAgent?: string;
96
+ }): ResolvedAgent;
@@ -0,0 +1,120 @@
1
+ /** Resolves the remote from its named config slot. */
2
+ export async function selectRemote(config) {
3
+ return config.remote.createRemote(config);
4
+ }
5
+ /** Resolves the executor from its named config slot, injecting the logger. */
6
+ export async function selectExecutor(config, logger) {
7
+ return config.executor.createExecutor(config, { logger });
8
+ }
9
+ /**
10
+ * Resolves the workspace from its named config slot, injecting the logger when
11
+ * the caller has one — mirroring {@link selectExecutor}. Without it the herdr
12
+ * workspace's best-effort open-layout warn would land on a noop logger
13
+ * (BB-234).
14
+ */
15
+ export async function selectWorkspace(config, logger) {
16
+ return config.workspace.createWorkspace(config, logger ? { logger } : undefined);
17
+ }
18
+ /** A missing `priority` scores strictly below any real number (AC-4). */
19
+ const DEFAULT_PRIORITY = Number.NEGATIVE_INFINITY;
20
+ /**
21
+ * Pick the agent for a ticket: highest `priority(ticket)` wins; a candidate with
22
+ * no `priority` scores lowest; ties resolve by config array order (stable sort).
23
+ */
24
+ export function selectAgent(candidates, ticket) {
25
+ const best = candidates
26
+ .map((c, index) => ({
27
+ c,
28
+ index,
29
+ score: c.priority ? c.priority(ticket) : DEFAULT_PRIORITY,
30
+ }))
31
+ .sort((a, b) => b.score - a.score || a.index - b.index)[0];
32
+ if (!best) {
33
+ throw new Error('selectAgent: no agent candidates configured');
34
+ }
35
+ return best.c;
36
+ }
37
+ /** Constructs every agent candidate from the normalized effective catalog. */
38
+ export async function selectAgents(config) {
39
+ const candidates = config.agents;
40
+ return Promise.all(candidates.map(async (c) => ({
41
+ id: c.agent.id,
42
+ ...(c.alias !== undefined ? { alias: c.alias } : {}),
43
+ ...(c.description !== undefined ? { description: c.description } : {}),
44
+ ...(c.model !== undefined ? { model: c.model } : {}),
45
+ ...(c.modelVersion !== undefined ? { modelVersion: c.modelVersion } : {}),
46
+ agent: await c.agent.createAgent(config),
47
+ ...(c.priority ? { priority: c.priority } : {}),
48
+ })));
49
+ }
50
+ /** Resolve logical aliases without changing the legacy priority selector. */
51
+ export function resolveAgent(candidates, ticket, options = {}) {
52
+ if (options.selectAgent)
53
+ return options.selectAgent(candidates, ticket);
54
+ const labels = ticket.labels.filter((label) => label.startsWith('agent:'));
55
+ const scoped = [];
56
+ const general = [];
57
+ for (const label of labels) {
58
+ const [alias, state, extra] = label.slice('agent:'.length).split('@');
59
+ if (!alias || extra !== undefined)
60
+ continue;
61
+ if (state === ticket.state)
62
+ scoped.push(alias);
63
+ else if (state === undefined)
64
+ general.push(alias);
65
+ }
66
+ const warn = options.warn ?? console.warn;
67
+ const routes = options.routes ?? {};
68
+ const direct = (aliases) => {
69
+ for (const alias of aliases) {
70
+ const candidate = candidates.find((c) => c.alias === alias);
71
+ if (candidate)
72
+ return candidate;
73
+ if (routes[alias] !== undefined)
74
+ continue;
75
+ warn(`${ticket.identifier}: agent alias '${alias}' is unavailable; trying the next fallback`);
76
+ }
77
+ return undefined;
78
+ };
79
+ const directCandidate = direct(scoped) ?? direct(general);
80
+ if (directCandidate)
81
+ return directCandidate;
82
+ const resolveRoutes = (names) => {
83
+ for (const name of names) {
84
+ if (name === 'default')
85
+ continue;
86
+ const route = routes[name];
87
+ if (route === undefined)
88
+ continue;
89
+ const alias = route[ticket.state] ?? route['*'];
90
+ if (alias === undefined) {
91
+ warn(`${ticket.identifier}: agent route '${name}' has no entry for '${ticket.state}' or '*'; trying the next fallback`);
92
+ continue;
93
+ }
94
+ const candidate = candidates.find((c) => c.alias === alias);
95
+ if (candidate)
96
+ return candidate;
97
+ warn(`${ticket.identifier}: agent route '${name}' resolves to unavailable alias '${alias}'; trying the next fallback`);
98
+ }
99
+ return undefined;
100
+ };
101
+ const routeCandidate = resolveRoutes(scoped) ?? resolveRoutes(general);
102
+ if (routeCandidate)
103
+ return routeCandidate;
104
+ const defaultRoute = routes.default;
105
+ for (const alias of [defaultRoute?.[ticket.state], defaultRoute?.['*']]) {
106
+ if (alias === undefined)
107
+ continue;
108
+ const candidate = candidates.find((c) => c.alias === alias);
109
+ if (candidate)
110
+ return candidate;
111
+ warn(`${ticket.identifier}: agent route 'default' resolves to unavailable alias '${alias}'; trying the next fallback`);
112
+ }
113
+ if (options.defaultAgent !== undefined) {
114
+ const candidate = candidates.find((c) => c.alias === options.defaultAgent);
115
+ if (candidate)
116
+ return candidate;
117
+ warn(`${ticket.identifier}: agent alias '${options.defaultAgent}' is unavailable; trying the next fallback`);
118
+ }
119
+ return selectAgent(candidates, ticket);
120
+ }