omp-conductor 0.13.0 → 0.15.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.
Files changed (44) hide show
  1. package/README.md +549 -234
  2. package/package.json +8 -5
  3. package/schema/config.schema.json +609 -0
  4. package/src/availability.ts +165 -0
  5. package/src/board.ts +19 -32
  6. package/src/brief-upgrade.ts +1 -1
  7. package/src/briefs/orchestrator.md +72 -31
  8. package/src/briefs/policy.md +48 -36
  9. package/src/briefs/probes/gates.md +51 -0
  10. package/src/briefs/probes/project-context.md +59 -0
  11. package/src/briefs/probes/release-procedure.md +81 -0
  12. package/src/cli.ts +356 -212
  13. package/src/config-schema.ts +352 -0
  14. package/src/config.ts +1037 -679
  15. package/src/confinement.ts +54 -0
  16. package/src/daemon.ts +644 -390
  17. package/src/diff-flags.ts +73 -4
  18. package/src/digest-schedule.ts +92 -24
  19. package/src/escalate.ts +89 -22
  20. package/src/fleet.ts +351 -46
  21. package/src/generate-schema.ts +21 -0
  22. package/src/graph.ts +3 -3
  23. package/src/host.ts +16 -0
  24. package/src/omp.ts +21 -1
  25. package/src/orchestrator-tick.ts +732 -56
  26. package/src/privileged.ts +264 -0
  27. package/src/reports.ts +203 -6
  28. package/src/session-host.ts +3 -0
  29. package/src/setup-host.ts +209 -24
  30. package/src/setup-install.ts +320 -0
  31. package/src/setup-probe.ts +412 -0
  32. package/src/setup-wizard.ts +1946 -0
  33. package/src/setup.ts +457 -53
  34. package/src/store.ts +610 -98
  35. package/src/tracker/github.ts +43 -5
  36. package/src/types.ts +153 -14
  37. package/src/upgrade.ts +44 -10
  38. package/src/verbs/actions.ts +131 -13
  39. package/src/verbs/server.ts +40 -18
  40. package/src/wizard-ui.ts +249 -0
  41. package/src/worker.ts +24 -7
  42. package/skills/conductor-onboarding/SKILL.md +0 -748
  43. package/skills/conductor-update/SKILL.md +0 -51
  44. package/src/plugin.ts +0 -1495
@@ -0,0 +1,412 @@
1
+ /**
2
+ * The repo-reading half of onboarding: a short, confined omp session that reads a
3
+ * routing repo and answers one question about it.
4
+ *
5
+ * Ported (#307) from the onboarding skill that #309 then deleted, which held
6
+ * judgment no code had — read each repo's CI workflows, `package.json` scripts and
7
+ * `Makefile`/`justfile` and *propose* the real pre-push gates; write the project
8
+ * context; turn a release intent into the repo's own machinery. This is where its
9
+ * reading half went; the operator-only questions went to `setup-wizard.ts`.
10
+ *
11
+ * **A probe proposes; it never decides.** Every answer is a seed the operator
12
+ * edits or a preview they confirm. Its capability is cut to match:
13
+ *
14
+ * - `readOnly: true` — the session is given **no shell, no editors and no verbs**
15
+ * (`confinement.ts`'s allowlist gate). It is not a sandbox and does not contain
16
+ * the *process*; it removes the tools, and unknown tools arrive denied. Because
17
+ * a probe has no externally started shape, unlike the orchestrator of #143,
18
+ * every probe session this package starts carries it.
19
+ * - `role: "worker"`, so structured reads are also scoped to the checkout;
20
+ * - **no `verbSocketPath`**, so the conductor verbs fail closed: a probe has no
21
+ * route to a label, a merge or a release;
22
+ * - the checkout is a throwaway `git clone --depth 1` in a tmpdir, removed after;
23
+ * - a 30-turn cap and a 10-minute wall clock, then it is disposed.
24
+ *
25
+ * What this is **not**: a boundary against the host. The probe runs as this
26
+ * process's own uid, so it reads what the operator can read. That is the same
27
+ * posture the repo already takes for workers, and the reason a probe is pointed at
28
+ * repositories the operator has themselves configured.
29
+ *
30
+ * And **setup never fails because a probe did**: a crash, a cap kill, output that
31
+ * does not parse, or no omp peer at all each become one warning plus an empty
32
+ * seed. An operator gets the interview they came for either way.
33
+ */
34
+
35
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
36
+ import { tmpdir } from "node:os";
37
+ import { join } from "node:path";
38
+ import { z } from "zod";
39
+ import { createSession, disposeSession, type AgentSessionLike } from "./omp.ts";
40
+ import { reportText } from "./worker.ts";
41
+ import type { WizardUi } from "./wizard-ui.ts";
42
+
43
+ /** The shipped prompt templates, beside the briefs they help write. */
44
+ export type ProbeName = "gates" | "project-context" | "release-procedure";
45
+
46
+ /** A probe's answer, or why there is none. Never a throw: see the module note. */
47
+ export type ProbeResult =
48
+ | { kind: "ok"; text: string }
49
+ | { kind: "unavailable"; reason: string };
50
+
51
+ export interface ProbeTarget {
52
+ /** Routing key, for the prompt and the operator-facing warning. */
53
+ name: string;
54
+ cloneUrl: string;
55
+ defaultBranch: string;
56
+ }
57
+
58
+ /**
59
+ * Where each repo is cloned inside the probe workspace, and under what name the
60
+ * prompt refers to it.
61
+ *
62
+ * Generated, never the routing key. A key is validated as non-empty and nothing
63
+ * more, so `../api` — or an `owner/repo` typo — would make `git clone` write
64
+ * outside the throwaway root, where the cleanup that removes it does not reach.
65
+ * The key survives as *data* in the mapping the prompt carries, which is the only
66
+ * place it is needed.
67
+ */
68
+ export function probeLayout(targets: readonly ProbeTarget[]): { target: ProbeTarget; dir: string }[] {
69
+ return targets.map((target, index) => ({ target, dir: `repo-${index}` }));
70
+ }
71
+
72
+ /**
73
+ * The routing-key-to-directory mapping a multi-repo prompt needs.
74
+ *
75
+ * Without it the probe can read every checkout but cannot say which routing label
76
+ * owns which — and the repo map is the section's whole purpose.
77
+ */
78
+ export function probeRepoMap(targets: readonly ProbeTarget[], labelPrefix: string): string {
79
+ return probeLayout(targets)
80
+ .map(({ target, dir }) => `- \`${labelPrefix}${target.name}\` — ./${dir}/ (branch ${target.defaultBranch})`)
81
+ .join("\n");
82
+ }
83
+
84
+ export interface ProbeDeps {
85
+ /** Injected so tests exercise parsing and failure handling with no model. */
86
+ run?: (input: { prompt: string; cwd: string }) => Promise<ProbeResult>;
87
+ /** Injected so tests never clone. Returns the checkout, or why it failed. */
88
+ clone?: (target: ProbeTarget, into: string) => Promise<{ ok: true } | { ok: false; reason: string }>;
89
+ now?: () => number;
90
+ }
91
+
92
+ /** One turn beyond this and the probe is killed: a probe that needs 30 turns to
93
+ * read three files is not converging on an answer. */
94
+ export const PROBE_MAX_TURNS = 30;
95
+ /** Wall clock, because a stuck tool call burns no turns at all. */
96
+ export const PROBE_WALL_CLOCK_MS = 10 * 60_000;
97
+
98
+ /** Where the shipped templates live once installed. */
99
+ function templatePath(name: ProbeName): string {
100
+ return join(import.meta.dir, "briefs", "probes", `${name}.md`);
101
+ }
102
+
103
+ /**
104
+ * Renders a shipped template, substituting `{{KEY}}` placeholders.
105
+ *
106
+ * An unreplaced placeholder is left visible rather than blanked: a prompt that
107
+ * silently lost its stated inputs is one the probe answers by inventing them,
108
+ * which is the single failure these templates are written to prevent.
109
+ */
110
+ export function renderProbePrompt(name: ProbeName, values: Readonly<Record<string, string>>): string {
111
+ let text = readFileSync(templatePath(name), "utf8");
112
+ for (const [key, value] of Object.entries(values)) {
113
+ text = text.replaceAll(`{{${key}}}`, value);
114
+ }
115
+ return text;
116
+ }
117
+
118
+ /**
119
+ * Runs one probe against a fresh shallow clone of `target`.
120
+ *
121
+ * Returns `unavailable` for every fault — no peer, clone refused, session throw,
122
+ * cap kill, empty answer — because the caller's contract is to warn and carry on.
123
+ */
124
+ export async function runProbe(
125
+ name: ProbeName,
126
+ targets: readonly ProbeTarget[],
127
+ values: Readonly<Record<string, string>>,
128
+ deps: ProbeDeps = {},
129
+ ): Promise<ProbeResult> {
130
+ if (targets.length === 0) return { kind: "unavailable", reason: "no repo to read" };
131
+ const prompt = renderProbePrompt(name, values);
132
+ const root = mkdtempSync(join(tmpdir(), "omp-probe-"));
133
+ try {
134
+ const clone = deps.clone ?? shallowClone;
135
+ const layout = probeLayout(targets);
136
+ for (const { target, dir } of layout) {
137
+ const cloned = await clone(target, join(root, dir));
138
+ // Every named repo, or none: a project-context draft that silently skipped
139
+ // the repo it could not clone would map ownership it never read.
140
+ if (!cloned.ok) return { kind: "unavailable", reason: cloned.reason };
141
+ }
142
+ // One repo runs *in* its checkout, so the answer's paths are repo-relative, as
143
+ // the gates template asks. Several run in the workspace above them, where each
144
+ // repo is a directory — the shape the ported skill's "read, per routing repo"
145
+ // instruction needs to be answerable in one pass.
146
+ const cwd = layout.length === 1 ? join(root, layout[0]?.dir ?? "") : root;
147
+ return await (deps.run ?? runInSession)({ prompt, cwd });
148
+ } catch (err) {
149
+ return { kind: "unavailable", reason: err instanceof Error ? err.message : String(err) };
150
+ } finally {
151
+ // Throwaway by design, so it goes even on the error paths: a tmpdir per setup
152
+ // run would otherwise accumulate silently.
153
+ rmSync(root, { recursive: true, force: true });
154
+ }
155
+ }
156
+
157
+ async function shallowClone(
158
+ target: ProbeTarget,
159
+ into: string,
160
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
161
+ const proc = Bun.spawn(
162
+ ["git", "clone", "--depth", "1", "--single-branch", "--branch", target.defaultBranch, target.cloneUrl, into],
163
+ { stdout: "ignore", stderr: "pipe" },
164
+ );
165
+ const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
166
+ if (code === 0) return { ok: true };
167
+ return {
168
+ ok: false,
169
+ reason: `could not clone ${target.cloneUrl} (${target.defaultBranch}): ${stderr.trim().split("\n").at(-1) ?? `git exited ${code}`}`,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The probe's capability set, as one value so a test can hold it to account.
175
+ *
176
+ * Exported because this object *is* the trust boundary. `readOnly` removes the
177
+ * shell, the editors and every unknown tool; the absent `verbSocketPath` leaves the
178
+ * conductor verbs failing closed. A dropped field here would hand a
179
+ * repository-reading session real power, and nothing else about the probe would
180
+ * look any different.
181
+ */
182
+ export function probeSessionOptions(cwd: string): { cwd: string; role: "worker"; readOnly: boolean } {
183
+ return { cwd, role: "worker", readOnly: true };
184
+ }
185
+
186
+ /**
187
+ * Runs the prompt to an answer. The caps are what stop a probe becoming a cost.
188
+ *
189
+ * Two orderings, both learnt from bugs:
190
+ *
191
+ * - The prompt is **raced against the caps**, never merely awaited. `worker.ts:336`
192
+ * says why: an aborted session may never reach a terminal turn, so a cap that
193
+ * disposed and then waited for `prompt` would hang — here, inside an operator's
194
+ * interview, with no output and nothing to press.
195
+ * - Teardown is **awaited before returning**, through one memoised promise. The
196
+ * caller deletes the clone the moment this resolves, and `omp.ts`'s disposer is
197
+ * bounded (5s grace, `SIGTERM`, 5s, `SIGKILL`), so waiting for it cannot hang and
198
+ * not waiting would race `rmSync` against a child still reading the checkout.
199
+ * Memoised because the cap path and the `finally` both ask for it, and two
200
+ * concurrent disposals would signal the child twice.
201
+ */
202
+ export async function runInSession(
203
+ input: { prompt: string; cwd: string },
204
+ start: (opts: ReturnType<typeof probeSessionOptions>) => Promise<AgentSessionLike> = createSession,
205
+ // Injected alongside `start` because `disposeSession` resolves through a private
206
+ // registry only a real session is in, so a test could not otherwise observe that
207
+ // this function waits for teardown before letting its caller delete the checkout.
208
+ teardown: (session: AgentSessionLike) => Promise<void> = disposeSession,
209
+ ): Promise<ProbeResult> {
210
+ const session = await start(probeSessionOptions(input.cwd));
211
+ let answer = "";
212
+ let turns = 0;
213
+
214
+ let disposal: Promise<void> | undefined;
215
+ const dispose = (): Promise<void> => (disposal ??= teardown(session));
216
+
217
+ // Resolves with the reason a cap fired. The prompt racing against it is what
218
+ // guarantees this function returns.
219
+ const capped = Promise.withResolvers<string>();
220
+ const stop = (why: string): void => {
221
+ capped.resolve(why);
222
+ // Started here so the child is already shutting down while the race unwinds;
223
+ // the `finally` below is what waits for it.
224
+ void dispose();
225
+ };
226
+
227
+ session.on("turn_start", () => {
228
+ // `turn_start` once per turn, for the reason worker.ts uses it: one turn can
229
+ // emit several assistant `message_end`s and would burn the cap while behaving.
230
+ turns += 1;
231
+ if (turns > PROBE_MAX_TURNS) stop(`stopped after ${PROBE_MAX_TURNS} turns without an answer`);
232
+ });
233
+ session.on("message_end", (event: unknown) => {
234
+ // Narrowed rather than asserted: this is a harness value the compiler lost,
235
+ // and a fabricated shape here would read `undefined` in silence.
236
+ if (event === null || typeof event !== "object" || !("message" in event)) return;
237
+ const message = event.message;
238
+ if (message === null || typeof message !== "object") return;
239
+ if (!("role" in message) || message.role !== "assistant") return;
240
+ // Newest non-empty assistant text wins, exactly as a worker's report is
241
+ // collected — whether the probe finished cleanly or was cut off.
242
+ const text = reportText("content" in message ? message.content : undefined);
243
+ if (text !== "") answer = text;
244
+ });
245
+
246
+ const wall = setTimeout(() => stop("stopped after 10 minutes"), PROBE_WALL_CLOCK_MS);
247
+ let capReason: string | undefined;
248
+ try {
249
+ // A cap kill still keeps whatever the probe had already said: a draft cut off
250
+ // mid-sentence is the operator's to accept or decline, same as a complete one.
251
+ capReason = await Promise.race([
252
+ session.prompt(input.prompt).then(() => undefined),
253
+ capped.promise,
254
+ ]);
255
+ } catch (err) {
256
+ return { kind: "unavailable", reason: err instanceof Error ? err.message : String(err) };
257
+ } finally {
258
+ clearTimeout(wall);
259
+ // Before the caller removes the checkout this child is standing in.
260
+ await dispose();
261
+ }
262
+
263
+ if (answer === "") return { kind: "unavailable", reason: capReason ?? "the probe produced no answer" };
264
+ return { kind: "ok", text: answer };
265
+ }
266
+
267
+ /** One gate the probe proposes. Shape-checked, never trusted as configuration. */
268
+ export interface ProbedGate {
269
+ cmd: string;
270
+ cwd: string;
271
+ }
272
+
273
+ /**
274
+ * Pulls `{"gates":[{"cmd","cwd"}],"evidence"}` out of a probe's answer.
275
+ *
276
+ * Fenced-JSON rather than prose because a gate list is the one probe answer that
277
+ * feeds a *value* rather than a preview: it pre-fills the gates prompt, so it has
278
+ * to survive a parse. Anything unparseable is no seed at all — the operator then
279
+ * types the gates, which is exactly what they did before this existed.
280
+ */
281
+ /**
282
+ * The shape a gates probe must answer in.
283
+ *
284
+ * A schema rather than hand-rolled field reads because this is outside-controlled
285
+ * input — a model's text, parsed as JSON — and it is the one probe answer that
286
+ * feeds a *value* rather than a preview. A missing `cwd` is the repo root, which
287
+ * is what an unqualified gate means.
288
+ */
289
+ const ProbedGatesSchema = z.object({
290
+ gates: z.array(
291
+ z.object({
292
+ cmd: z.string().trim().min(1),
293
+ cwd: z.string().trim().min(1).default("."),
294
+ }),
295
+ ),
296
+ evidence: z.string().trim().min(1).optional(),
297
+ });
298
+
299
+ /**
300
+ * Pulls `{"gates":[{"cmd","cwd"}],"evidence"}` out of a probe's answer.
301
+ *
302
+ * Fenced JSON, because a gate list pre-fills a prompt and therefore has to
303
+ * survive a parse. Anything unparseable is no seed at all — the operator then
304
+ * types the gates, which is exactly what they did before this existed.
305
+ */
306
+ export function parseProbedGates(text: string): { gates: ProbedGate[]; evidence?: string } | undefined {
307
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
308
+ const body = (fenced?.[1] ?? text).trim();
309
+ let raw: unknown;
310
+ try {
311
+ raw = JSON.parse(body);
312
+ } catch {
313
+ return undefined;
314
+ }
315
+ const parsed = ProbedGatesSchema.safeParse(raw);
316
+ if (!parsed.success) return undefined;
317
+ return {
318
+ gates: parsed.data.gates,
319
+ ...(parsed.data.evidence === undefined ? {} : { evidence: parsed.data.evidence }),
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Runs the gates probe and returns what the operator should see pre-filled.
325
+ *
326
+ * The warning is deliberately the operator's, not a log line: they are about to
327
+ * be shown a gates prompt, and whether it was seeded by a probe or left as the
328
+ * shipped default changes how carefully they should read it.
329
+ */
330
+ export async function probeGates(
331
+ ui: WizardUi,
332
+ target: ProbeTarget,
333
+ deps: ProbeDeps = {},
334
+ ): Promise<ProbedGate[]> {
335
+ const result = await runProbe("gates", [target], { REPO: target.name, BRANCH: target.defaultBranch }, deps);
336
+ if (result.kind !== "ok") {
337
+ ui.notify(`Could not read ${target.name}'s gates (${result.reason}) — type them yourself below.`, "warning");
338
+ return [];
339
+ }
340
+ const parsed = parseProbedGates(result.text);
341
+ if (parsed === undefined) {
342
+ ui.notify(`${target.name}'s gate probe did not answer in the required shape — type them yourself below.`, "warning");
343
+ return [];
344
+ }
345
+ if (parsed.gates.length === 0) {
346
+ // An honest "no gates" is a real answer the template asks for, and it is not
347
+ // the same as a failed probe: say so rather than implying a fault.
348
+ ui.notify(`Probe found no pre-push gates in ${target.name}${parsed.evidence === undefined ? "" : ` — ${parsed.evidence}`}.`, "info");
349
+ return [];
350
+ }
351
+ ui.notify(
352
+ [
353
+ `Proposed gates for ${target.name}, from its own CI:`,
354
+ ...parsed.gates.map((g) => ` ${g.cmd}${g.cwd === "." ? "" : ` @ ${g.cwd}`}`),
355
+ ...(parsed.evidence === undefined ? [] : [` evidence: ${parsed.evidence}`]),
356
+ "Edit them at the prompt below — they are a proposal, not a decision.",
357
+ ].join("\n"),
358
+ "info",
359
+ );
360
+ return parsed.gates;
361
+ }
362
+
363
+ /** Strips one fenced block, which is how both prose templates are asked to answer. */
364
+ export function unfence(text: string): string {
365
+ const fenced = /```(?:markdown|md)?\s*([\s\S]*?)```/.exec(text);
366
+ return (fenced?.[1] ?? text).trim();
367
+ }
368
+
369
+ /**
370
+ * Runs a prose probe, shows what it wrote, and asks whether to keep it.
371
+ *
372
+ * The confirm is the whole point. A gate list is checkable at a glance; a
373
+ * paragraph about what a repo owns is not, and one an operator did not read is
374
+ * worse than a stub — the brief is re-read on every tick, so a plausible invention
375
+ * in it becomes an instruction the orchestrator follows all week.
376
+ *
377
+ * Declined, unavailable, or empty all return `undefined`: the caller writes the
378
+ * shipped stub, which says what is missing and how to fill it in.
379
+ */
380
+ export async function probeProse(
381
+ ui: WizardUi,
382
+ name: Exclude<ProbeName, "gates">,
383
+ heading: string,
384
+ targets: readonly ProbeTarget[],
385
+ values: Readonly<Record<string, string>>,
386
+ deps: ProbeDeps = {},
387
+ ): Promise<string | undefined> {
388
+ const names = targets.map((t) => t.name).join(", ");
389
+ ui.notify(`Reading ${names} to draft ${heading} — this takes a few minutes.`, "info");
390
+ const result = await runProbe(name, targets, values, deps);
391
+ if (result.kind !== "ok") {
392
+ ui.notify(`Could not draft ${heading} (${result.reason}) — the brief keeps its stub, which says what to write.`, "warning");
393
+ return undefined;
394
+ }
395
+ const text = unfence(result.text);
396
+ if (text.length === 0) {
397
+ ui.notify(`The ${heading} probe answered with nothing — the brief keeps its stub.`, "warning");
398
+ return undefined;
399
+ }
400
+
401
+ ui.notify([`Proposed ${heading}, from ${names}:`, "", text, ""].join("\n"), "info");
402
+ const keep = await ui.confirm(
403
+ `Keep this ${heading}?`,
404
+ "It becomes part of POLICY.md, which the orchestrator re-reads on every tick. You can edit that file afterwards. Declining keeps the stub.",
405
+ );
406
+ // A dismissed prompt is not a yes. `undefined` means the operator walked away.
407
+ if (keep !== true) {
408
+ ui.notify(`Discarded — the brief keeps its ${heading} stub.`, "info");
409
+ return undefined;
410
+ }
411
+ return text;
412
+ }