/** * The repo-reading half of onboarding: a short, confined omp session that reads a * routing repo and answers one question about it. * * Ported (#307) from the onboarding skill that #309 then deleted, which held * judgment no code had — read each repo's CI workflows, `package.json` scripts and * `Makefile`/`justfile` and *propose* the real pre-push gates; write the project * context; turn a release intent into the repo's own machinery. This is where its * reading half went; the operator-only questions went to `setup-wizard.ts`. * * **A probe proposes; it never decides.** Every answer is a seed the operator * edits or a preview they confirm. Its capability is cut to match: * * - `readOnly: true` — the session is given **no shell, no editors and no verbs** * (`confinement.ts`'s allowlist gate). It is not a sandbox and does not contain * the *process*; it removes the tools, and unknown tools arrive denied. Because * a probe has no externally started shape, unlike the orchestrator of #143, * every probe session this package starts carries it. * - `role: "worker"`, so structured reads are also scoped to the checkout; * - **no `verbSocketPath`**, so the conductor verbs fail closed: a probe has no * route to a label, a merge or a release; * - the checkout is a throwaway `git clone --depth 1` in a tmpdir, removed after; * - a 30-turn cap and a 10-minute wall clock, then it is disposed. * * What this is **not**: a boundary against the host. The probe runs as this * process's own uid, so it reads what the operator can read. That is the same * posture the repo already takes for workers, and the reason a probe is pointed at * repositories the operator has themselves configured. * * And **setup never fails because a probe did**: a crash, a cap kill, output that * does not parse, or no omp peer at all each become one warning plus an empty * seed. An operator gets the interview they came for either way. */ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; import { createSession, disposeSession, type AgentSessionLike } from "./omp.ts"; import { reportText } from "./worker.ts"; import type { WizardUi } from "./wizard-ui.ts"; /** The shipped prompt templates, beside the briefs they help write. */ export type ProbeName = "gates" | "project-context" | "release-procedure"; /** A probe's answer, or why there is none. Never a throw: see the module note. */ export type ProbeResult = | { kind: "ok"; text: string } | { kind: "unavailable"; reason: string }; export interface ProbeTarget { /** Routing key, for the prompt and the operator-facing warning. */ name: string; cloneUrl: string; defaultBranch: string; } /** * Where each repo is cloned inside the probe workspace, and under what name the * prompt refers to it. * * Generated, never the routing key. A key is validated as non-empty and nothing * more, so `../api` — or an `owner/repo` typo — would make `git clone` write * outside the throwaway root, where the cleanup that removes it does not reach. * The key survives as *data* in the mapping the prompt carries, which is the only * place it is needed. */ export function probeLayout(targets: readonly ProbeTarget[]): { target: ProbeTarget; dir: string }[] { return targets.map((target, index) => ({ target, dir: `repo-${index}` })); } /** * The routing-key-to-directory mapping a multi-repo prompt needs. * * Without it the probe can read every checkout but cannot say which routing label * owns which — and the repo map is the section's whole purpose. */ export function probeRepoMap(targets: readonly ProbeTarget[], labelPrefix: string): string { return probeLayout(targets) .map(({ target, dir }) => `- \`${labelPrefix}${target.name}\` — ./${dir}/ (branch ${target.defaultBranch})`) .join("\n"); } export interface ProbeDeps { /** Injected so tests exercise parsing and failure handling with no model. */ run?: (input: { prompt: string; cwd: string }) => Promise; /** Injected so tests never clone. Returns the checkout, or why it failed. */ clone?: (target: ProbeTarget, into: string) => Promise<{ ok: true } | { ok: false; reason: string }>; now?: () => number; } /** One turn beyond this and the probe is killed: a probe that needs 30 turns to * read three files is not converging on an answer. */ export const PROBE_MAX_TURNS = 30; /** Wall clock, because a stuck tool call burns no turns at all. */ export const PROBE_WALL_CLOCK_MS = 10 * 60_000; /** Where the shipped templates live once installed. */ function templatePath(name: ProbeName): string { return join(import.meta.dir, "briefs", "probes", `${name}.md`); } /** * Renders a shipped template, substituting `{{KEY}}` placeholders. * * An unreplaced placeholder is left visible rather than blanked: a prompt that * silently lost its stated inputs is one the probe answers by inventing them, * which is the single failure these templates are written to prevent. */ export function renderProbePrompt(name: ProbeName, values: Readonly>): string { let text = readFileSync(templatePath(name), "utf8"); for (const [key, value] of Object.entries(values)) { text = text.replaceAll(`{{${key}}}`, value); } return text; } /** * Runs one probe against a fresh shallow clone of `target`. * * Returns `unavailable` for every fault — no peer, clone refused, session throw, * cap kill, empty answer — because the caller's contract is to warn and carry on. */ export async function runProbe( name: ProbeName, targets: readonly ProbeTarget[], values: Readonly>, deps: ProbeDeps = {}, ): Promise { if (targets.length === 0) return { kind: "unavailable", reason: "no repo to read" }; const prompt = renderProbePrompt(name, values); const root = mkdtempSync(join(tmpdir(), "omp-probe-")); try { const clone = deps.clone ?? shallowClone; const layout = probeLayout(targets); for (const { target, dir } of layout) { const cloned = await clone(target, join(root, dir)); // Every named repo, or none: a project-context draft that silently skipped // the repo it could not clone would map ownership it never read. if (!cloned.ok) return { kind: "unavailable", reason: cloned.reason }; } // One repo runs *in* its checkout, so the answer's paths are repo-relative, as // the gates template asks. Several run in the workspace above them, where each // repo is a directory — the shape the ported skill's "read, per routing repo" // instruction needs to be answerable in one pass. const cwd = layout.length === 1 ? join(root, layout[0]?.dir ?? "") : root; return await (deps.run ?? runInSession)({ prompt, cwd }); } catch (err) { return { kind: "unavailable", reason: err instanceof Error ? err.message : String(err) }; } finally { // Throwaway by design, so it goes even on the error paths: a tmpdir per setup // run would otherwise accumulate silently. rmSync(root, { recursive: true, force: true }); } } async function shallowClone( target: ProbeTarget, into: string, ): Promise<{ ok: true } | { ok: false; reason: string }> { const proc = Bun.spawn( ["git", "clone", "--depth", "1", "--single-branch", "--branch", target.defaultBranch, target.cloneUrl, into], { stdout: "ignore", stderr: "pipe" }, ); const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]); if (code === 0) return { ok: true }; return { ok: false, reason: `could not clone ${target.cloneUrl} (${target.defaultBranch}): ${stderr.trim().split("\n").at(-1) ?? `git exited ${code}`}`, }; } /** * The probe's capability set, as one value so a test can hold it to account. * * Exported because this object *is* the trust boundary. `readOnly` removes the * shell, the editors and every unknown tool; the absent `verbSocketPath` leaves the * conductor verbs failing closed. A dropped field here would hand a * repository-reading session real power, and nothing else about the probe would * look any different. */ export function probeSessionOptions(cwd: string): { cwd: string; role: "worker"; readOnly: boolean } { return { cwd, role: "worker", readOnly: true }; } /** * Runs the prompt to an answer. The caps are what stop a probe becoming a cost. * * Two orderings, both learnt from bugs: * * - The prompt is **raced against the caps**, never merely awaited. `worker.ts:336` * says why: an aborted session may never reach a terminal turn, so a cap that * disposed and then waited for `prompt` would hang — here, inside an operator's * interview, with no output and nothing to press. * - Teardown is **awaited before returning**, through one memoised promise. The * caller deletes the clone the moment this resolves, and `omp.ts`'s disposer is * bounded (5s grace, `SIGTERM`, 5s, `SIGKILL`), so waiting for it cannot hang and * not waiting would race `rmSync` against a child still reading the checkout. * Memoised because the cap path and the `finally` both ask for it, and two * concurrent disposals would signal the child twice. */ export async function runInSession( input: { prompt: string; cwd: string }, start: (opts: ReturnType) => Promise = createSession, // Injected alongside `start` because `disposeSession` resolves through a private // registry only a real session is in, so a test could not otherwise observe that // this function waits for teardown before letting its caller delete the checkout. teardown: (session: AgentSessionLike) => Promise = disposeSession, ): Promise { const session = await start(probeSessionOptions(input.cwd)); let answer = ""; let turns = 0; let disposal: Promise | undefined; const dispose = (): Promise => (disposal ??= teardown(session)); // Resolves with the reason a cap fired. The prompt racing against it is what // guarantees this function returns. const capped = Promise.withResolvers(); const stop = (why: string): void => { capped.resolve(why); // Started here so the child is already shutting down while the race unwinds; // the `finally` below is what waits for it. void dispose(); }; session.on("turn_start", () => { // `turn_start` once per turn, for the reason worker.ts uses it: one turn can // emit several assistant `message_end`s and would burn the cap while behaving. turns += 1; if (turns > PROBE_MAX_TURNS) stop(`stopped after ${PROBE_MAX_TURNS} turns without an answer`); }); session.on("message_end", (event: unknown) => { // Narrowed rather than asserted: this is a harness value the compiler lost, // and a fabricated shape here would read `undefined` in silence. if (event === null || typeof event !== "object" || !("message" in event)) return; const message = event.message; if (message === null || typeof message !== "object") return; if (!("role" in message) || message.role !== "assistant") return; // Newest non-empty assistant text wins, exactly as a worker's report is // collected — whether the probe finished cleanly or was cut off. const text = reportText("content" in message ? message.content : undefined); if (text !== "") answer = text; }); const wall = setTimeout(() => stop("stopped after 10 minutes"), PROBE_WALL_CLOCK_MS); let capReason: string | undefined; try { // A cap kill still keeps whatever the probe had already said: a draft cut off // mid-sentence is the operator's to accept or decline, same as a complete one. capReason = await Promise.race([ session.prompt(input.prompt).then(() => undefined), capped.promise, ]); } catch (err) { return { kind: "unavailable", reason: err instanceof Error ? err.message : String(err) }; } finally { clearTimeout(wall); // Before the caller removes the checkout this child is standing in. await dispose(); } if (answer === "") return { kind: "unavailable", reason: capReason ?? "the probe produced no answer" }; return { kind: "ok", text: answer }; } /** One gate the probe proposes. Shape-checked, never trusted as configuration. */ export interface ProbedGate { cmd: string; cwd: string; } /** * Pulls `{"gates":[{"cmd","cwd"}],"evidence"}` out of a probe's answer. * * Fenced-JSON rather than prose because a gate list is the one probe answer that * feeds a *value* rather than a preview: it pre-fills the gates prompt, so it has * to survive a parse. Anything unparseable is no seed at all — the operator then * types the gates, which is exactly what they did before this existed. */ /** * The shape a gates probe must answer in. * * A schema rather than hand-rolled field reads because this is outside-controlled * input — a model's text, parsed as JSON — and it is the one probe answer that * feeds a *value* rather than a preview. A missing `cwd` is the repo root, which * is what an unqualified gate means. */ const ProbedGatesSchema = z.object({ gates: z.array( z.object({ cmd: z.string().trim().min(1), cwd: z.string().trim().min(1).default("."), }), ), evidence: z.string().trim().min(1).optional(), }); /** * Pulls `{"gates":[{"cmd","cwd"}],"evidence"}` out of a probe's answer. * * Fenced JSON, because a gate list pre-fills a prompt and therefore has to * survive a parse. Anything unparseable is no seed at all — the operator then * types the gates, which is exactly what they did before this existed. */ export function parseProbedGates(text: string): { gates: ProbedGate[]; evidence?: string } | undefined { const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text); const body = (fenced?.[1] ?? text).trim(); let raw: unknown; try { raw = JSON.parse(body); } catch { return undefined; } const parsed = ProbedGatesSchema.safeParse(raw); if (!parsed.success) return undefined; return { gates: parsed.data.gates, ...(parsed.data.evidence === undefined ? {} : { evidence: parsed.data.evidence }), }; } /** * Runs the gates probe and returns what the operator should see pre-filled. * * The warning is deliberately the operator's, not a log line: they are about to * be shown a gates prompt, and whether it was seeded by a probe or left as the * shipped default changes how carefully they should read it. */ export async function probeGates( ui: WizardUi, target: ProbeTarget, deps: ProbeDeps = {}, ): Promise { const result = await runProbe("gates", [target], { REPO: target.name, BRANCH: target.defaultBranch }, deps); if (result.kind !== "ok") { ui.notify(`Could not read ${target.name}'s gates (${result.reason}) — type them yourself below.`, "warning"); return []; } const parsed = parseProbedGates(result.text); if (parsed === undefined) { ui.notify(`${target.name}'s gate probe did not answer in the required shape — type them yourself below.`, "warning"); return []; } if (parsed.gates.length === 0) { // An honest "no gates" is a real answer the template asks for, and it is not // the same as a failed probe: say so rather than implying a fault. ui.notify(`Probe found no pre-push gates in ${target.name}${parsed.evidence === undefined ? "" : ` — ${parsed.evidence}`}.`, "info"); return []; } ui.notify( [ `Proposed gates for ${target.name}, from its own CI:`, ...parsed.gates.map((g) => ` ${g.cmd}${g.cwd === "." ? "" : ` @ ${g.cwd}`}`), ...(parsed.evidence === undefined ? [] : [` evidence: ${parsed.evidence}`]), "Edit them at the prompt below — they are a proposal, not a decision.", ].join("\n"), "info", ); return parsed.gates; } /** Strips one fenced block, which is how both prose templates are asked to answer. */ export function unfence(text: string): string { const fenced = /```(?:markdown|md)?\s*([\s\S]*?)```/.exec(text); return (fenced?.[1] ?? text).trim(); } /** * Runs a prose probe, shows what it wrote, and asks whether to keep it. * * The confirm is the whole point. A gate list is checkable at a glance; a * paragraph about what a repo owns is not, and one an operator did not read is * worse than a stub — the brief is re-read on every tick, so a plausible invention * in it becomes an instruction the orchestrator follows all week. * * Declined, unavailable, or empty all return `undefined`: the caller writes the * shipped stub, which says what is missing and how to fill it in. */ export async function probeProse( ui: WizardUi, name: Exclude, heading: string, targets: readonly ProbeTarget[], values: Readonly>, deps: ProbeDeps = {}, ): Promise { const names = targets.map((t) => t.name).join(", "); ui.notify(`Reading ${names} to draft ${heading} — this takes a few minutes.`, "info"); const result = await runProbe(name, targets, values, deps); if (result.kind !== "ok") { ui.notify(`Could not draft ${heading} (${result.reason}) — the brief keeps its stub, which says what to write.`, "warning"); return undefined; } const text = unfence(result.text); if (text.length === 0) { ui.notify(`The ${heading} probe answered with nothing — the brief keeps its stub.`, "warning"); return undefined; } ui.notify([`Proposed ${heading}, from ${names}:`, "", text, ""].join("\n"), "info"); const keep = await ui.confirm( `Keep this ${heading}?`, "It becomes part of POLICY.md, which the orchestrator re-reads on every tick. You can edit that file afterwards. Declining keeps the stub.", { key: `keep-probe.${name}` }, ); // A dismissed prompt is not a yes. `undefined` means the operator walked away. if (keep !== true) { ui.notify(`Discarded — the brief keeps its ${heading} stub.`, "info"); return undefined; } return text; }