/** * The onboarding core behind `omp-conductor setup`. * * Everything here is headless and synchronously testable: the plugin owns the * dialogs, this module owns the decisions. That split is the point — a wizard * whose logic lives inside UI callbacks can only be verified by driving a TUI, * so in practice it never is, and the first thing a new user touches is the * least tested code in the package. * * Two functions here mutate something outside the process: `createMissingLabels` * (and its rollback compensation half, `deleteCreatedLabels`) and * `writeOrchestratorBrief`. Everything else reads, or computes. That is what * lets the plugin show a complete plan before asking for consent, and it is a * property worth preserving — check it before adding a function. * * ponytail: `gh` is shelled out to per call rather than shared with the tracker * adapter's private `gh()`, which throws on non-zero exit. Setup needs the * opposite: an unauthenticated or unreachable `gh` is a finding to report in the * plan, not an exception thrown mid-prompt. The ceiling is one duplicated 15-line * spawn helper; the upgrade path, if a third caller appears, is to lift both into * `src/gh.ts` exposing `gh()` (throwing) over `ghTry()` (classifying). */ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, relative } from "node:path"; import { parse as parseYaml } from "yaml"; import { AGENTS_BRIEF_NAME, COMPOSE_BANNER, CONSOLE_BRIEF_NAME, ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME, composeOrchestrator, policyPathForRoot, readSharedPolicy, refreshComposedBrief, renderBriefTemplate, sharedPolicyPath, } from "./brief-upgrade.ts"; import { clonePolicy, configPath, defaultMirrorRoot, defaultWorkspaceRoot, resolveArmProof, resolveCaps, resolvePolicy, resolveReleaseGrants, resolveReview, SCOPE_PRESETS, stateDir, } from "./config.ts"; import { graphProjectPath, graphRepos } from "./graph.ts"; import { modelRolesIn, ompSettingsOverlay } from "./omp-settings.ts"; import { shellQuote } from "./shell.ts"; import { CONFIG_VERSION, DEFAULT_ARM_PROOF, DEFAULT_AUTHORITY, DEFAULT_CAPS, DEFAULT_PROJECT_POLICY, DEFAULT_REPORT_SCOPE, DEFAULT_REVIEW_POLICY, DENIED_RELEASE_GRANTS, RELEASE_SHAPES, REVIEW_STRICTNESS, WEEKDAYS, type ArmProof, type BaseFreshness, type BehindBaseAction, type AuthorityHolder, type Caps, type ConductorConfig, type DigestCadence, type DraftPolicy, type OrchestratorMode, type GroomTrigger, type InterruptCategory, type ProjectConfig, type ProjectPolicy, type ReleaseRequirement, type ReportScopeChoice, type ReportingPolicy, type ReviewPolicy, type ReviewStrictness, type Weekday, type WeeklyAvailability, type RepoTarget, type ResolvedGrants, } from "./types.ts"; /** * What the onboarding skill interviewed for and no probe can supply. * * The skill is explicit that reading a repo answers none of this: "a tracker * shows what is open, never what matters" (`SKILL.md:337-341`), and a release * boundary is a sentence only the operator can say. Every field here fills a * stub the shipped `policy.md` already carries, which is what makes deleting the * skill (#309) a move rather than a loss. * * Every field is optional because the object is what has been *collected so * far*: an amend that never reaches these prompts carries none of them, and * `judgment` itself is absent on a project re-read from disk, since none of this * lives in `config.json`. * * `boundary` and everything under it is asked only when the orchestrator holds * some release shape: a humans-release fleet has no boundary to draw, and asking * for one would invite a procedure the config does not grant. */ export interface OperatorJudgment { /** Where the roadmap lives and what is being shipped now. Always *asked*; * absent means the interview has not reached it (or the operator left it blank). */ roadmap?: string; /** Where the orchestrator's leg ends, in one sentence. */ boundary?: string; /** What may be released, and from which branch. */ releaseWhat?: string; /** Batched how, after which *named* checks are green. */ releaseWhen?: string; /** The proof that must be held first — results read, not an impression. */ releaseProof?: string; /** What must still be asked, every time. */ releaseAsk?: string; /** What stays permanently forbidden. */ releaseForbidden?: string; /** The batching unit in the operator's own vocabulary. */ worthCutting?: string; /** Who owns the rollback. A named person moves the boundary earlier. */ rollbackOwner?: string; /** * Whether the operator accepted the boundary-moves-earlier consequence of * naming a person as the rollback owner. Recorded rather than enforced: the * wizard states the consequence and offers to move the boundary, and an * operator who declines has made a choice, not a mistake. */ rollbackMovesBoundary?: boolean; } /** * Every decision the wizard needs, in one plain object. Collected by the UI, * consumed by `buildConfig`, so the prompt order can change without touching * the shape of the config that comes out. */ export interface SetupAnswers { projectName: string; /** Tracker repo as `owner/repo` — the only spelling `gh` takes without a host. */ trackerRepo: string; queueLabel: string; stateLabels: { inProgress: string; blocked: string; failed: string; backlog: string }; routingLabelPrefix: string; targetRepos: { name: string; cloneUrl: string; defaultBranch: string; gates: { cmd: string; cwd: string }[]; migrations?: { dir: string }; release?: { versionFile: string }; }[]; caps: Partial; /** * Model pattern for worker sessions, in omp's model/role syntax. Absent means * the harness default, which is the answer for anyone who has not deliberately * pinned one. */ workerModel?: string; /** * The grooming trigger: a routable-candidate count below which the tick * prompt says to groom, or `"always"` to groom on demand every tick (#988). * The wizard never asks for it — it is hand-edited — so it exists here only * to survive an amend of some other area (#369). */ groomBelow?: GroomTrigger; /** * The OMP model role the daemon's own grooming scouts run under (#1041). * Hand-edited like {@link groomBelow} — the wizard never asks — so it exists * here only to survive an amend of some other area (#369). */ groomRole?: string; /** * Ordered fallback worker models and the provider-failure threshold that * starts them. The wizard never asks for either — they are hand-edited — * so they exist here only to survive an amend of some other area (#369, * #286). */ modelFallbacks?: string[]; modelFallbackThreshold?: number; /** * The one stronger tier a spinning cap retries on (#807). Hand-edited like * {@link modelFallbacks}, so it exists here only to survive an amend of some * other area (#369). */ workerEscalationModel?: string; /** * The project's omp settings overlay (#537): an opaque map layered into * every worker session via the fleet-owned settings channel. The wizard asks * for it as one free-form YAML answer (the only prompt this map gets — the * keys inside it are omp's vocabulary, not a list conductor can offer); it * also survives an amend of any other area unchanged, like * {@link modelFallbacks}. */ ompSettings?: Record; telegramChatId?: string; /** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */ telegramTopicId?: number; fallbackToIssueComment: boolean; /** How loud the supervising orchestrator session should be. */ reportScope: ReportScopeChoice; /** One IANA zone shared by the configured window and its daily digest. */ reportingTimezone?: string; /** Daily digest wall-clock (`HH:MM`) when an explicit daily policy is chosen. */ dailyDigestAt?: string; /** Digest cadence when preserving or explicitly editing a reporting policy. */ digestCadence?: DigestCadence; /** Weekly window without its shared timezone; absent keeps 24-hour interrupts. */ availability?: Omit; /** * Exact policy read from disk. Unrelated amend areas carry this through * untouched; the reporting interview deletes it before applying new answers. */ preservedReporting?: ReportingPolicy; /** * Mechanical gate for release/deploy-shaped tool calls, per shape. Always * complete: the wizard asks about every shape, so an answer object can never * leave one to be defaulted by whichever reader gets there first (#122). */ releaseGrants: ResolvedGrants; /** * What a merge or a release must satisfy before it happens (#129). Always * complete: the wizard asks about every field, so an answers object can never * leave one to be defaulted by whichever reader gets there first. */ policy: ProjectPolicy; /** * How `arm` proves a human just approved arming (#613). Always complete: * the wizard asks about it in the policy area, so an answers object can never * leave it to be defaulted by whichever reader gets there first. */ armProof: ArmProof; /** * How green PRs are reviewed and returned (#678): the strictness level and * the hard ceiling on review rounds per PR lifecycle. Always complete: the * wizard asks about it in the policy area, so an answers object can never * leave it to be defaulted by whichever reader gets there first. */ review: ReviewPolicy; /** * Hand-edited recovery merge authorizations carried through setup unchanged. * The wizard never grants one; forgetting them during an unrelated amend * would strand an in-progress recovery, while inventing one would weaken the * merge provenance gate. */ recoveryMerges?: ProjectConfig["recoveryMerges"]; /** * Hand-edited critical-base/safety markers (commit SHAs or refs) carried * through setup unchanged, like {@link recoveryMerges}. The wizard never * invents one; forgetting them during an unrelated amend would drop the * stale-base interlock from a project that relies on it (#428). */ criticalBase?: ProjectConfig["criticalBase"]; /** * Hand-edited providers that must bill to a subscription credential, carried * through setup unchanged like {@link criticalBase} (#852). The wizard never * invents one — which providers a fleet pays for by subscription is the * operator's billing arrangement, not something repo reading can answer — and * dropping it during an unrelated amend would silently remove the fence that * stops a disabled OAuth grant billing to an API key. */ requireOauthProviders?: ProjectConfig["requireOauthProviders"]; /** * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not * part of the config — the brief is the operator's file, and the conductor * never reads it back — but it is a decision the wizard has to carry from the * prompt that asked it to the step that acts on it. */ writeOrchestratorBrief: boolean; /** * The operator's own judgment, asked in the interview because no amount of * repo reading produces it — and because `src/briefs/policy.md` ships stubs * waiting for exactly these words (`:25-28`, `:53-56`). * * Not config: none of it is a key the daemon reads. It exists to reach * `POLICY.md` — written directly, and offered to a prose probe as stated input * the probe may not invent. It rides on the answers object because the wizard * has to carry it from the prompt that asked it to the step that writes it. */ judgment?: OperatorJudgment; /** * Who lands green PRs and who cuts releases. Both default to the human — see * {@link DEFAULT_AUTHORITY} — and the answer is what the rendered brief's * Releases paragraph states, so the session can never read a delegation the * config does not grant. */ authority: ProjectConfig["authority"]; /** * Whether the daemon runs its own triage session, or an operator already runs * one elsewhere. `external` on a host where the orchestrator is a visible TUI * session: the daemon then posts tier-1 escalations as issue comments for * that session to drain, rather than starting a second brain. */ orchestratorMode: OrchestratorMode; /** * Parent directory of the index-only clones workers query, or absent when the * operator declined code-graph discovery — in which case no repo gets a * `graphProject` and every rendered brief is the one this package shipped * before graphs existed. * * One answer for the whole project rather than one per repo: the clones are * derived data with no reason to live apart, and a per-repo prompt would ask * the same question four times to arrive at four siblings. */ graphRoot?: string; /** * Where this project's worktrees live. Absent means {@link defaultWorkspaceRoot} * under the state dir (the first-project layout). An added project seeds * `~/.omp/conductor/projects//worktrees` instead; an amend carries the * path already on disk so a re-run never migrates a neighbour. */ workspaceRoot?: string; /** Bare mirrors for this project; same seeding rules as {@link workspaceRoot}. */ mirrorRoot?: string; } /** What `gh auth status` says the active token may do. */ export interface ScopeCheck { ok: boolean; login?: string; scopes: string[]; missing: string[]; } /** One tracker label the daemon depends on, and whether it is already there. */ export interface LabelPlan { name: string; /** Six hex digits, no leading `#` — what `gh label create --color` wants. */ colour: string; description: string; exists: boolean; } /** Defaults the wizard pre-fills. Exported so the prompts and this module * cannot drift apart: one spelling of "ready-for-agent" in the package. */ export const SETUP_DEFAULTS = { queueLabel: "ready-for-agent", stateLabels: { inProgress: "agent:in-progress", blocked: "agent:blocked", failed: "agent:failed", backlog: "backlog", }, routingLabelPrefix: "repo:", defaultBranch: "main", /** Both authorities start with the human; the wizard asks to move each one. */ authority: DEFAULT_AUTHORITY, /** Every release/deploy shape stays closed until explicitly granted. */ releaseGrants: DENIED_RELEASE_GRANTS, /** The strictest reading of the prose these conditions replaced (#129). */ policy: DEFAULT_PROJECT_POLICY, /** The recommended review strictness and its default ceiling (#678). */ review: DEFAULT_REVIEW_POLICY, /** The daemon runs its own triage session unless an operator already runs one. */ orchestratorMode: "embedded", } as const; /** * The three report scopes as the operator meets them, described once. The wizard * shows these labels, the plan summary quotes the description, and the rendered * brief spells the same three options out — so "material" cannot come to mean * one thing in the dialog and another in the session that has to honour it. */ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScopeChoice; label: string; description: string; }[] = [ { scope: "decisions", label: "Decisions interrupt, rest batches", description: "tier-2 decisions and fleet-stopping conditions immediately; every other material event ships with the configured digest", }, { scope: "material", label: "Material events", description: "escalations, plus green PRs, second failures, and anything that stops the fleet", }, { scope: "escalations", label: "Escalations only", description: "escalations when they happen, plus one daily digest — silent otherwise", }, { scope: "quiet", label: "Quiet (scheduled digest)", description: "interrupt only for escalations, fleet stops and confirmed failures; one daily rollup on your schedule", }, ]; /** * What a *fresh* interview opens on: the first choice above, by construction, so * reordering that list moves the wizard's cursor with it. * * Deliberately not {@link DEFAULT_REPORT_SCOPE}. That one answers "a config on * disk with no `reporting` key" and must stay `material` forever, because * changing it would turn a running fleet's volume down on upgrade. This one * answers "an operator being asked the question for the first time", where the * recommended answer is the useful one — and they see it, and confirm it. */ export const SETUP_DEFAULT_REPORT_SCOPE: ReportScopeChoice = REPORT_SCOPE_CHOICES[0]!.scope; /** * What each precondition value means to the operator being asked about it, in * their words rather than the gate's. * * Mapped over the closed unions rather than written as parallel arrays, for the * reason {@link MERGE_DUTY} is: a fifth behind-base action fails to compile here * instead of reaching a wizard with no question for it, an amend row that cannot * describe it, and a plan summary that shows it blank. One vocabulary, three * consumers (#129). */ export const BASE_FRESHNESS_CHOICES: { readonly [K in BaseFreshness]: string } = { "up-to-date": "the head must be level with its base before it may merge", any: "merge whatever the base has moved to since — CI's verdict may be stale", }; export const DRAFT_POLICY_CHOICES: { readonly [K in DraftPolicy]: string } = { block: "never merge a draft — the author has said the work is not finished", allow: "a draft may merge when everything else passes", }; export const BEHIND_BASE_CHOICES: { readonly [K in BehindBaseAction]: string } = { "update-branch": "run `gh pr update-branch` and wait for the fresh run", hold: "leave it for whoever refreshes it, and merge nothing", escalate: "page a human rather than guess", }; /** * What each arming proof means to the operator being asked about it, in their * words rather than the gate's (conductor #613). The `claim-only` consequence * is the one line the issue contract requires the question to state: anything * that can invoke the already-privileged `omp-conductor arm` command can start * dispatch once the live claim and poller pass. Mapped over the closed union * so a third proof fails to compile here instead of reaching a wizard with no * question for it. */ export const ARM_PROOF_CHOICES: { readonly [K in ArmProof]: string } = { challenge: "an authenticated Telegram challenge proves a human just approved arming", "claim-only": "anyone who can invoke the already-privileged `omp-conductor arm` command can start dispatch once the live claim and poller pass", }; /** * What each review strictness blocks, in the operator's words, and the single * canonical phrasing of the three bars (#678). * * Mapped over the closed union for the reason {@link MERGE_DUTY} is: a fourth * level fails to compile here instead of reaching a wizard with no question for * it, an amend row that cannot describe it, and a rendered brief that shows it * blank. Each sentence is self-contained — "Low's bar" would reference prose * the brief may not be rendering — and it is the *same* text the wizard shows * one level at a time and the composed orchestrator brief carries for the * configured level, so the definition an operator chose is the threshold a * session enforces. * * The two consumers read this map rather than their own prose: the setup * dialog explains every level from it, and {@link reviewDuty} renders the * effective one from it. "One canonical implementation, not duplicated prose * branches" is this object. */ export const REVIEW_STRICTNESS_CHOICES: { readonly [K in ReviewStrictness]: string } = { low: "correctness, security, data-loss or explicit acceptance-criteria failures — anything else stays a review comment", medium: "correctness, security, data-loss or explicit acceptance-criteria failures, plus material maintainability or " + "reliability defects likely to become incidents within six months — anything else stays a review comment", high: "correctness, security, data-loss or explicit acceptance-criteria failures, plus material maintainability or " + "reliability defects likely to become incidents within six months, plus concrete quality defects — never " + "subjective style churn or unbounded refactoring", }; export const RELEASE_REQUIREMENT_CHOICES: { readonly [K in ReleaseRequirement]: string } = { "runs-settled": "every run of the released repo actually merged, not merely reached a green PR", "fleet-runs-settled": "every run in the project actually merged — suite-wide strictness for shapes that consume several repos", "no-open-prs": "no pull request is still open against the branch being released", "queue-drained": "nothing still carries the queue label", "base-branch-green": "the newest observed post-merge base-branch workflows are green", "epic-children-closed": "the epic this release closes has no open children", }; /** * The Releases paragraph the brief opens with, one per authority combination. * * Rendered rather than written by hand for the reason the standing orders are * worded from the same config: an operator who delegated merging in setup and a * brief that still says "you do not merge" is a session that has been given two * answers and will act on whichever it read last. * * A mapped type over both holders, so adding a third holder fails to compile * here instead of rendering `undefined` into somebody's standing prompt. */ export const RELEASES_DEFAULTS: { readonly [K in `${ProjectConfig["authority"]["merge"]}/${ProjectConfig["authority"]["release"]}`]: string; } = { "human/human": '**Default: humans release, and you do not merge.** Work ends at a green PR;\n' + "merging is a separate human action, and releasing is a separate human action after\n" + 'that. "This needs releasing" is something you report, never something you take on.', "orchestrator/orchestrator": "**Delegated in setup: you merge, and you release.** Merge green PRs one at a\n" + "time, re-checked against the base branch first. Cut releases per the procedure\n" + "your operator writes below — do not cut one before the seven specifics are\n" + "filled in.", "orchestrator/human": "**Delegated in setup: you merge; humans release.** Merge green PRs one at a\n" + 'time, re-checked against the base branch first. "This needs releasing" is\n' + "something you report, never something you take on.", "human/orchestrator": "**Delegated in setup: humans merge; you release.** You cut releases from work a\n" + "human has already merged, per the procedure your operator writes below.", }; /** * Duty 1's "the PR is green" branch, worded from `authority.merge`. * * The duty has to name an action, and which action depends on the grant. A * session told "merging is yours" in its standing orders and told "you do not * merge it" three sections into its own brief will do whichever it read last, * which is the failure this whole key exists to prevent. The bullet marker is * part of the value so the template line is nothing but the placeholder. */ export const MERGE_DUTY: { readonly [K in ProjectConfig["authority"]["merge"]]: string } = { human: "- **It is already done.** The PR is green and waiting on a human merge. Note it,\n" + " with the link, and move on. You do not merge it.", orchestrator: "- **It is already done.** The PR is green, and merging is yours. Re-check it\n" + " against the base branch, merge it, and note the link. One PR at a time — that\n" + " one is a hard boundary, not a preference.", }; /** * Duty 1's review-return contract, worded from `project.review` (#678). * * One canonical implementation rather than prose branches in the template: the * configured level's bar is the same {@link REVIEW_STRICTNESS_CHOICES} text * the setup dialog explains each level with, and the ceiling is the configured * number — so the strictness an operator chose in setup and the threshold this * session enforces cannot diverge. The paragraph names `conductor_pr_review` * (the #677 verb) as the only return path and states the three ceiling * behaviours: leave the PR open, record the unresolved findings, escalate once. */ export function reviewDuty(p: ProjectConfig): string { const { strictness, maxRounds } = resolveReview(p); return ( "\n" + "**Review policy:** before you merge (or settle) a green PR, review it at\n" + `this project's strictness. The level is **${strictness}**: ` + `${REVIEW_STRICTNESS_CHOICES[strictness]}. ` + "`conductor_pr_review` is the verb for findings at or above that bar, and\n" + "for nothing else — anything below it stays a review comment on the PR,\n" + "never a return. Every corrected head gets a fresh review, up to a hard\n" + `ceiling of ${maxRounds} ${maxRounds === 1 ? "round" : "rounds"} per PR lifecycle, visible in ` + "`omp-conductor status` as `review-revision N`. At the ceiling\n" + "`conductor_pr_review` refuses: leave the PR open, record the unresolved\n" + "findings, and escalate once — never a further round." ); } /** * The Promotion paragraph, worded from `authority.promotion`. * * The queue label is a sign-off: adding it to an issue lets a worker claim it * and starts spend. POLICY.md must state who may do that — never a blank the * floor defers into — and an unstated owner reads operator-only, because a * gate nobody claimed must not resolve to the session that wants to use it. * * The paragraph refers to "the queue label" rather than naming it: the label * itself is rendered into the section heading from config, and a second copy * here would be a label that can disagree with the one the daemon reads. */ export const PROMOTION_DUTIES: { readonly [K in AuthorityHolder]: string } = { human: "**Default: the operator promotes.** Adding the queue label to an issue is promotion, and\n" + "promotion is the operator's: the orchestrator grooms, routes and proposes, but the label\n" + "goes on only when the operator puts it there — it is the sign-off that starts spend. To\n" + "delegate, re-run `omp-conductor setup` or replace this paragraph with one naming the\n" + "orchestrator as promoter.", orchestrator: "**Delegated in setup: the orchestrator promotes.** Adding the queue label to an issue is\n" + "promotion, and the orchestrator may do it after grooming the issue, one at a time. The\n" + "operator can take it back by editing this paragraph.", }; export { AGENTS_BRIEF_NAME, CONSOLE_BRIEF_NAME, ORCHESTRATOR_BRIEF_NAME, POLICY_BRIEF_NAME }; /** Shipped floor template — duties, tiers, hard boundaries, Learning loop. */ const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator.md"); /** Shipped POLICY.md scaffold — Releases, Project context, Reporting, Amendments. */ const POLICY_TEMPLATE_PATH = join(import.meta.dir, "briefs", "policy.md"); /** * Shipped console floor — the operator console's role, delivery contract, * authority and boundaries. Render-only: it has no `POLICY.md` half, is never * composed, and is never classified by `inspectBriefLayout`. */ const CONSOLE_TEMPLATE_PATH = join(import.meta.dir, "briefs", "console.md"); /** * `repo` writes labels and closes issues; `project` moves cards on the board. * Both are load-bearing for an unattended loop, so a missing one is reported * rather than discovered at 03:00 as a run that claimed work it cannot label. */ const REQUIRED_SCOPES = ["repo", "project"] as const; /** GitHub's own palette, so the tracker reads at a glance: green means queued, * blue means moving, amber means waiting on you, red means it gave up, grey * means the operator parked it, and purple means routing — the operator's * input, the label the loop never writes. */ const LABEL_COLOURS = { queue: "0e8a16", inProgress: "1d76db", blocked: "fbca04", failed: "b60205", backlog: "8b949e", routing: "6f42c1", } as const; interface GhResult { code: number; stdout: string; stderr: string; } /** * Runs `gh` and classifies instead of throwing. stdin is a closed stream so a * command that would read it sees EOF rather than hanging on an open pipe. */ async function gh(argv: string[]): Promise { try { const proc = Bun.spawn(["gh", ...argv], { stdin: new Blob([""]), stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); return { code, stdout, stderr }; } catch (err) { // `gh` missing from PATH lands here. A diagnosis, not a crash: the wizard // is mid-conversation with a human and should say what is wrong. return { code: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) }; } } /** * Bounds `gh` stderr to the two lines that carry the diagnosis. A durable * contract rather than a rename: every error this module raises passes through * here, so an unexpectedly chatty `gh` build can never dump an arbitrary * subprocess transcript into a message the caller shows or logs. */ function briefly(stderr: string): string { const lines = stderr .split("\n") .map((l) => l.trim()) .filter((l) => l.length > 0); return lines.slice(0, 2).join(" / ") || "(no output)"; } /** * Reads the active `gh` token's scopes without ever handling the token itself. * * `gh auth status` masks the token in its own output and this function returns * only the parsed scope names and login, so no credential can escape through * the return value, a log line, or a thrown message — hence the deliberate * absence of any `throw` here. * * ponytail: the first `Token scopes:` line wins. A `gh` configured for both * github.com and a GHES host prints one block each, and this credits the first. * Upgrade path: pass `--hostname` once the tracker learns about non-default hosts. */ export async function checkTokenScopes(): Promise { // Older gh writes the status block to stderr, newer to stdout; read both // rather than guessing at the installed version. const r = await gh(["auth", "status"]); const text = `${r.stdout}\n${r.stderr}`; const scopeLine = /Token scopes:\s*(.*)/.exec(text); const scopes = (scopeLine?.[1] ?? "") .split(",") .map((s) => s.trim().replace(/^['"]|['"]$/g, "")) .filter((s) => s.length > 0 && s !== "none"); // "account " is gh >= 2.40, "as " everything before it. const loginMatch = /Logged in to \S+ (?:account|as) (\S+)/.exec(text); const login = loginMatch?.[1]; const missing = REQUIRED_SCOPES.filter((s) => !scopes.includes(s)); const check: ScopeCheck = { ok: r.code === 0 && missing.length === 0, scopes, missing }; if (login !== undefined) check.login = login; return check; } /** * Every label setup wants on the tracker, before existence is known: the queue * and state labels the loop reads and writes, the operator's park label (which * the loop must never write), plus one routing label per routed repo. Pure — * the prefix and the repo keys are already answered at this point in the * interview — which is what lets a test pin the whole set without `gh`. * * The park label is provisioned like the rest but owned the other way round: * it exists so the operator has a gesture, and the conductor never applies or * removes it (#507). Creating it queues nothing either — applying one, with * the queue label, stays the operator's sign-off. */ export function wantedLabels(a: SetupAnswers): Omit[] { return [ { name: a.queueLabel, colour: LABEL_COLOURS.queue, description: "Signed off by a human as ready for the conductor to claim", }, { name: a.stateLabels.inProgress, colour: LABEL_COLOURS.inProgress, description: "A conductor worker is running on this issue", }, { name: a.stateLabels.blocked, colour: LABEL_COLOURS.blocked, description: "Parked by the conductor — waiting on a human answer", }, { name: a.stateLabels.failed, colour: LABEL_COLOURS.failed, description: "The conductor gave up on this issue after its retry budget", }, { name: a.stateLabels.backlog, colour: LABEL_COLOURS.backlog, description: "Parked by the operator — never claimed, only the operator may set or clear it", }, ...a.targetRepos.map((r) => ({ name: `${a.routingLabelPrefix}${r.name}`, colour: LABEL_COLOURS.routing, description: `Routes this issue to the ${r.name} checkout`, })), ]; } /** * Turns wanted labels into the plan the operator is shown, deduped * case-insensitively and marked against what already exists. * * GitHub label names are unique case-insensitively, so an operator who answers * "Ready-For-Agent" against an existing "ready-for-agent" must not be told a * creation is pending that would then fail — and a multi-repo project whose * names case-fold onto each other or onto a lifecycle label wants one creation, * not two. */ export function planAgainstLabels( wanted: readonly Omit[], existing: ReadonlySet, ): LabelPlan[] { const seen = new Set(); const plan: LabelPlan[] = []; for (const w of wanted) { const key = w.name.toLowerCase(); if (seen.has(key)) continue; seen.add(key); plan.push({ ...w, exists: existing.has(key) }); } return plan; } /** * The labels a tracker needs, and which already exist. Read-only: this is what * the operator is shown before being asked to consent to creation. * * The queue and state labels are the ones the loop reads and writes; the * routing labels (one per repo) are the operator's input that makes an issue * routable, and setup provisions them only so there is something to apply. */ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise { return planAgainstLabels(wantedLabels(a), await listLabelNames(trackerRepo)); } /** Lower-cased label names currently on the repo. Throws rather than guessing: * a repo whose labels cannot be listed cannot be serviced either. */ async function listLabelNames(trackerRepo: string): Promise> { const r = await gh(["label", "list", "--repo", trackerRepo, "--limit", "200", "--json", "name"]); if (r.code !== 0) { throw new Error(`Cannot list labels in ${trackerRepo}: ${briefly(r.stderr)}`); } let parsed: unknown; try { parsed = JSON.parse(r.stdout || "[]") as unknown; } catch { throw new Error(`Cannot read the label list for ${trackerRepo}: gh returned output that is not JSON.`); } const names = new Set(); if (Array.isArray(parsed)) { for (const entry of parsed) { const name = (entry as { name?: unknown } | null)?.name; if (typeof name === "string" && name.length > 0) names.add(name.toLowerCase()); } } return names; } /** * The single mutating step in this module, and the plugin calls it only after * the operator has confirmed the printed plan. * * Returns the names actually created — a label that already existed is not in * the list, because reporting it as created would be a lie the operator would * have to verify by hand. A concurrent creation (or a label list that was * stale by the time we got here) is success, not an error: the end state the * caller asked for holds either way. */ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]): Promise { const created: string[] = []; for (const label of plan) { if (label.exists) continue; const r = await gh([ "label", "create", label.name, "--repo", trackerRepo, "--color", label.colour, "--description", label.description, ]); if (r.code === 0) { created.push(label.name); continue; } if (/already exists/i.test(r.stderr)) continue; // The labels created before this one failed ride on the error: the setup // transaction compensates the tracker exactly, and a throw without the // partial list would orphan them (#652). throw Object.assign( new Error(`Could not create label "${label.name}" in ${trackerRepo}: ${briefly(r.stderr)}`), { created }, ); } return created; } /** * Compensation half of {@link createMissingLabels}: deletes exactly the labels * a failed setup apply created, so the tracker returns to its pre-entry state. * Runs only on rollback, where a deletion failure is a reported restoration * failure — the orphaned label is named, never silently left behind (#652). */ export async function deleteCreatedLabels(trackerRepo: string, created: string[]): Promise { const failures: string[] = []; for (const name of created) { const r = await gh(["label", "delete", name, "--repo", trackerRepo, "--yes"]); if (r.code === 0 || /not found/i.test(r.stderr)) continue; failures.push(`could not delete label "${name}" in ${trackerRepo}: ${briefly(r.stderr)}`); } if (failures.length > 0) throw new Error(failures.join("; ")); } const QUIET_INTERRUPT_ON: InterruptCategory[] = [ "tier2", "fleet-stopped", "confirmed-failure", ]; function interruptCategories(scope: ReportScopeChoice): InterruptCategory[] { return scope === "quiet" ? [...QUIET_INTERRUPT_ON] : [...(SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]).interruptOn]; } /** Copy policy arrays so a setup amend cannot mutate the config it was seeded from. */ function cloneReportingPolicy(policy: ReportingPolicy): ReportingPolicy { return { interruptOn: [...policy.interruptOn], digest: { ...policy.digest }, ...(policy.availability === undefined ? {} : { availability: { ...policy.availability, days: [...policy.availability.days], bypass: [...policy.availability.bypass], }, }), ...(policy.scopePreset === undefined ? {} : { scopePreset: policy.scopePreset }), }; } /** Turn the wizard's local notification answers into the runtime policy. */ function reportingFromAnswers(a: SetupAnswers): ReportingPolicy { if (a.preservedReporting !== undefined) return cloneReportingPolicy(a.preservedReporting); const digest = (fallback: DigestCadence): ReportingPolicy["digest"] => { const cadence = a.digestCadence ?? fallback; if (cadence !== "daily") return { cadence }; const at = a.dailyDigestAt?.trim(); const timezone = a.reportingTimezone?.trim(); return { cadence, ...(at === undefined || at.length === 0 ? {} : { at }), ...(timezone === undefined || timezone.length === 0 ? {} : { timezone }), }; }; const timezone = a.reportingTimezone?.trim(); if (a.availability !== undefined) { if (timezone === undefined || timezone.length === 0) { throw new Error("operator availability requires an IANA reporting timezone"); } return { interruptOn: interruptCategories(a.reportScope), digest: digest("daily"), availability: { timezone, days: [...a.availability.days], start: a.availability.start, end: a.availability.end, bypass: [...a.availability.bypass], }, }; } if (a.reportScope === "quiet") { return { interruptOn: [...QUIET_INTERRUPT_ON], digest: digest("daily"), }; } const preset = SCOPE_PRESETS[a.reportScope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE]; const resolvedDigest = digest(preset.digest.cadence); const presetDigest = resolvedDigest.cadence === preset.digest.cadence && resolvedDigest.at === preset.digest.at && resolvedDigest.timezone === preset.digest.timezone; return { interruptOn: [...preset.interruptOn], digest: resolvedDigest, ...(presetDigest ? { scopePreset: preset.scopePreset } : {}), }; } /** * The single `ProjectConfig` the answers describe. * * Split out of `buildConfig` so the plan summary can show the exact project * that would be written — including the derived worktree and mirror paths — * without assembling a whole config and indexing back into its array. Exported * for the amend summary's before-and-after, and so a test can pin its round-trip * with {@link answersFromProject} — the pair an amend's carry-through rests on. */ export function buildProject(a: SetupAnswers): ProjectConfig { const repos: Record = {}; const graphRoot = a.graphRoot?.trim(); for (const r of a.targetRepos) { repos[r.name] = { name: r.name, cloneUrl: r.cloneUrl, defaultBranch: r.defaultBranch, gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })), // One answered root becomes one clone per routed repo. Omitted entirely // when unanswered rather than written empty: the key's absence is what // makes an existing config's briefs render exactly as they did before. ...(graphRoot === undefined || graphRoot.length === 0 ? {} : { graphProject: graphProjectPath(graphRoot, r.name) }), ...(r.migrations === undefined ? {} : { migrations: { ...r.migrations } }), ...(r.release === undefined ? {} : { release: { ...r.release } }), }; } const escalation: ProjectConfig["escalation"] = { fallbackToIssueComment: a.fallbackToIssueComment, orchestrator: a.orchestratorMode, }; if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) { escalation.telegramChatId = a.telegramChatId.trim(); } if ( a.telegramTopicId !== undefined && Number.isFinite(a.telegramTopicId) && Number.isSafeInteger(a.telegramTopicId) ) { escalation.telegramTopicId = a.telegramTopicId; } const caps: Partial = {}; // Drops `undefined` members so an unanswered cap is absent from the JSON // rather than present-and-null, which the validator would have to reject. for (const [key, value] of Object.entries(a.caps) as [keyof Caps, number | undefined][]) { // `planUsage` is the one cap that is not a number, and the wizard does not // ask for it: naming a provider allowance window means reading // `omp usage --json` on the fleet host first, so it stays a documented // hand-edited key (#110). Skipped here to keep this numeric loop honest, // and carried below instead — an unrelated amend that deleted the weekly // allowance guard would be exactly the silent loss #369 is about. if (key !== "planUsage" && typeof value === "number") caps[key] = value; } // `null` is a spelling in its own right — "no allowance guard" — so both it // and a configured window survive an amend unchanged. if (a.caps.planUsage !== undefined) { caps.planUsage = a.caps.planUsage === null ? null : { ...a.caps.planUsage }; } return { name: a.projectName, tracker: { kind: "github", repo: a.trackerRepo }, queueLabel: a.queueLabel, // The park label is now an answer like the other three: the wizard offers // it and every answers constructor carries it, so writing the answers as // they stand is the single source — slice 1's hardcoded default bridge is // gone (#507 slice 2). stateLabels: { ...a.stateLabels }, routing: { labelPrefix: a.routingLabelPrefix, repos }, caps, ...(a.workerModel !== undefined && a.workerModel.trim().length > 0 ? { workerModel: a.workerModel.trim() } : {}), // A documented hand-edited key, so an unrelated amend must not delete it. ...(a.groomBelow === undefined ? {} : { groomBelow: a.groomBelow }), // Same contract for the daemon's grooming-scout role: hand-edited, and an // amend that dropped it would silently move grooming back to the harness // default model (#1041). ...(a.groomRole === undefined ? {} : { groomRole: a.groomRole }), // The failover chain is hand-edited too: dropping it on an unrelated amend // would pin every run of the project onto a dead provider again (#286). ...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }), ...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }), // Hand-edited like the failover chain, and the same reason to keep it: an // unrelated amend that dropped it would silently take the fleet's only // spinning-cap recovery away (#807). ...(a.workerEscalationModel === undefined ? {} : { workerEscalationModel: a.workerEscalationModel }), // The omp settings overlay is an opaque map the wizard collects as free-form // YAML; an unrelated amend must not delete it (#537). ...(a.ompSettings === undefined ? {} : { ompSettings: a.ompSettings }), escalation, authority: { ...a.authority }, // Written out in full, never as the legacy string: the file then says which // shapes are open without anyone having to know a migration rule (#122). releasePolicy: { ...a.releaseGrants }, // Written out in full for the same reason: the file then says what a merge // and a release require without anyone having to know a default (#129). policy: clonePolicy(a.policy), // Written out even when it is the default, so an operator amending the // policy has a line in the file to point at — and the recovery playbook // can read which proof the project opted into (#613). arm: { proof: a.armProof }, // Written out in full for the same reason as `arm`: the file then carries // the strictness and the ceiling an operator chose, without anyone having // to know a migration rule (#678). review: { ...a.review }, ...(a.recoveryMerges === undefined ? {} : { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }), // Hand-edited safety markers carried unchanged on an unrelated amend // (#428): dropping them would silently disarm the stale-base interlock. ...(a.criticalBase === undefined ? {} : { criticalBase: [...a.criticalBase] }), ...(a.requireOauthProviders === undefined ? {} : { requireOauthProviders: [...a.requireOauthProviders] }), // Written out even when it is the default, so an operator amending the // volume has a line in the file to point at. Opting into availability makes // the schedule explicit and daily; omitting it preserves the preset's // legacy 24-hour behaviour (#273). reporting: reportingFromAnswers(a), // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete // uninstall, and neither can land in a repo the daemon then tries to commit. // An explicit answer wins (added projects seed under projects//; amends // carry the path already on disk). Absence keeps the first-project flat layout. workspaceRoot: a.workspaceRoot?.trim() || defaultWorkspaceRoot(), mirrorRoot: a.mirrorRoot?.trim() || defaultMirrorRoot(), }; } /** * Turns answers into a config the real validator accepts. * * `existing` is how re-running setup for one project stays safe: a same-named * project is replaced in place (keeping its position), everything else is * carried through untouched. Rebuilding from answers alone would silently * delete a neighbour's entire configuration. */ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): ConductorConfig { const project = buildProject(a); const previous = existing?.projects ?? []; const collision = previous.find( (p) => p.name !== project.name && p.workspaceRoot === project.workspaceRoot, ); if (collision !== undefined) { throw new Error( `workspaceRoot ${project.workspaceRoot} collides between projects ` + `"${collision.name}" and "${project.name}" — two fleet sessions cannot share a cwd ` + `(one tick config would clobber the other). Pick a different workspaceRoot for one of them.`, ); } return { version: CONFIG_VERSION, // Answered caps land on the project, not here: on a re-run the global block // is also the baseline every other project inherits, and one project's // answers must never quietly re-budget its neighbour. An existing global // block is kept as-is so a hand-tuned default survives setup. defaults: { ...DEFAULT_CAPS, ...(existing?.defaults ?? {}) }, projects: previous.some((p) => p.name === project.name) ? previous.map((p) => (p.name === project.name ? project : p)) : [...previous, project], }; } /** * The seed a brand-new project starts from: the shipped defaults, and nothing * answered yet. * * Exists so "what does an unanswered field start as" has exactly one spelling. * The wizard pre-fills every prompt from an answers object — this one on a first * run, {@link answersFromProject} on a re-run — rather than reaching for a * default at each prompt, which is how one prompt comes to disagree with the * config key it writes. */ export function defaultAnswers(projectName: string, opts: { added?: boolean } = {}): SetupAnswers { // Added projects live under projects// so two fleets never share a cwd // with the first project's flat worktrees/mirrors. The first project keeps // those flat paths; buildProject falls back to defaultWorkspaceRoot() when // these are absent. const scopedBase = opts.added === true && projectName.trim().length > 0 ? join(stateDir(), "projects", projectName.trim()) : undefined; return { projectName, // Empty rather than a plausible guess: both are required, and a pre-filled // tracker repo is the one default an operator would Enter straight past. trackerRepo: "", queueLabel: SETUP_DEFAULTS.queueLabel, stateLabels: { ...SETUP_DEFAULTS.stateLabels }, routingLabelPrefix: SETUP_DEFAULTS.routingLabelPrefix, targetRepos: [], caps: {}, fallbackToIssueComment: true, authority: { ...SETUP_DEFAULTS.authority }, releaseGrants: { ...SETUP_DEFAULTS.releaseGrants }, policy: clonePolicy(SETUP_DEFAULTS.policy), // The strict reading, matching the loader's absent-key default: a project // that never answered keeps today's authenticated challenge round-trip. armProof: DEFAULT_ARM_PROOF, // First-run default: the recommended strictness and its ceiling, so a // fresh project opens on the answer the issue recommends — and the loader // materialises the same value for a config that has no key at all. review: { ...SETUP_DEFAULTS.review }, orchestratorMode: SETUP_DEFAULTS.orchestratorMode, reportScope: SETUP_DEFAULT_REPORT_SCOPE, writeOrchestratorBrief: false, ...(scopedBase === undefined ? {} : { workspaceRoot: defaultWorkspaceRoot(scopedBase), mirrorRoot: defaultMirrorRoot(scopedBase), }), }; } /** * The answers that describe a project already on disk — the inverse of * {@link buildProject}, and what makes amending one area possible. * * Every field is derived here, in one function, rather than field by field at * each prompt: an amend asks one area's questions and carries everything else * through untouched, so anything this forgets is a setting the operator loses by * changing an unrelated one. `buildProject(answersFromProject(p))` is pinned to * `p` by a test for exactly that reason. * * Four fields cannot be a straight copy: * * - `writeOrchestratorBrief` is a decision rather than a value, and it starts * `false` so an amend that never visits the brief area leaves that file alone. * - `reportScope` recognizes both preset metadata and the explicit quiet * category set, because an availability schedule intentionally has no preset. * - The reporting timezone is shared by availability and the daily digest in * the wizard even though runtime config stores it in both places. * - `graphRoot` is one answer for a whole project while the config stores one * path per repo, so it comes back from whichever repo already has one. Repos * that disagree — only a hand-edit can produce that — widen to all of them on * the next write exactly as a full re-run would, and the plan summary names * every clone before anything is written. */ function sameInterruptCategories( actual: readonly InterruptCategory[], expected: readonly InterruptCategory[], ): boolean { return ( actual.length === expected.length && actual.every((category) => expected.includes(category)) ); } export function reportScopeFromPolicy(policy: ReportingPolicy | undefined): ReportScopeChoice { if (policy?.scopePreset !== undefined) return policy.scopePreset; const actual = policy?.interruptOn ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE].interruptOn; if (sameInterruptCategories(actual, QUIET_INTERRUPT_ON)) return "quiet"; for (const scope of ["material", "decisions", "escalations"] as const) { if (sameInterruptCategories(actual, SCOPE_PRESETS[scope].interruptOn)) return scope; } return DEFAULT_REPORT_SCOPE; } function reportScopeDescriptionForPolicy(policy: ReportingPolicy | undefined): string { const scope = reportScopeFromPolicy(policy); const choice = REPORT_SCOPE_CHOICES.find((candidate) => candidate.scope === scope); const expectedCadence = scope === "quiet" ? "daily" : SCOPE_PRESETS[scope].digest.cadence; const configured = policy?.digest ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE].digest; if (configured.cadence === expectedCadence) { return choice?.description ?? "unknown scope"; } const digest = configured.cadence === "none" ? "disabled" : configured.cadence === "per-tick" ? "every tick" : `daily at ${configured.at ?? "(model-timed)"}${ configured.timezone === undefined ? "" : ` ${configured.timezone}` }`; return `${choice?.label ?? scope}; configured digest ${digest}`; } export function answersFromProject(p: ProjectConfig): SetupAnswers { const answers: SetupAnswers = { projectName: p.name, trackerRepo: p.tracker.repo, queueLabel: p.queueLabel, stateLabels: { ...p.stateLabels }, routingLabelPrefix: p.routing.labelPrefix, targetRepos: Object.values(p.routing.repos).map((r) => ({ name: r.name, cloneUrl: r.cloneUrl, defaultBranch: r.defaultBranch, gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })), ...(r.migrations === undefined ? {} : { migrations: { ...r.migrations } }), ...(r.release === undefined ? {} : { release: { ...r.release } }), })), caps: { ...p.caps }, fallbackToIssueComment: p.escalation.fallbackToIssueComment, authority: { ...p.authority }, releaseGrants: resolveReleaseGrants(p), policy: resolvePolicy(p), // The loader materialises `arm` complete, so this is the answer the project // actually has — never a default guessed at the amend prompt. armProof: resolveArmProof(p), // Same reason as `armProof`: the loader materialises `review` complete, so // a re-run preserves the configured strictness and ceiling rather than // opening on a default the operator already answered. review: resolveReview(p), orchestratorMode: p.escalation.orchestrator, reportScope: reportScopeFromPolicy(p.reporting), writeOrchestratorBrief: false, }; if (p.reporting !== undefined) { answers.preservedReporting = cloneReportingPolicy(p.reporting); answers.digestCadence = p.reporting.digest.cadence; } // Set only when present, never as an explicit `undefined`: an absent key is // what keeps the rewritten config identical to the one that was read. if (p.workerModel !== undefined) answers.workerModel = p.workerModel; if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow; if (p.groomRole !== undefined) answers.groomRole = p.groomRole; if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks]; if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold; if (p.workerEscalationModel !== undefined) answers.workerEscalationModel = p.workerEscalationModel; if (p.ompSettings !== undefined) answers.ompSettings = { ...p.ompSettings }; if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId; if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId; if (p.recoveryMerges !== undefined) { answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry })); } if (p.criticalBase !== undefined) answers.criticalBase = [...p.criticalBase]; if (p.requireOauthProviders !== undefined) { answers.requireOauthProviders = [...p.requireOauthProviders]; } if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at; if (p.reporting?.digest.timezone !== undefined) { answers.reportingTimezone = p.reporting.digest.timezone; } if (p.reporting?.availability !== undefined) { answers.reportingTimezone = p.reporting.availability.timezone; answers.availability = { days: [...p.reporting.availability.days], start: p.reporting.availability.start, end: p.reporting.availability.end, bypass: [...p.reporting.availability.bypass], }; } const graphed = graphRepos(p)[0]; if (graphed !== undefined) answers.graphRoot = dirname(graphed.graphProject); if (p.workspaceRoot.length > 0) answers.workspaceRoot = p.workspaceRoot; if (p.mirrorRoot.length > 0) answers.mirrorRoot = p.mirrorRoot; return answers; } /** * Where a configured project's composed brief lives: beside its worktrees, under * the state directory, so it is on the same disk the fleet already owns and * survives a reinstall of the package. Derived from the project rather than * fixed, so a project that ever gains a chosen workspace root keeps its brief * with it. */ export function briefPathForProject(p: ProjectConfig): string { return join(p.workspaceRoot, ORCHESTRATOR_BRIEF_NAME); } /** Where the fleet-owned POLICY.md overlay lives for a project. */ export function policyPathForProject(p: ProjectConfig): string { return policyPathForRoot(p.workspaceRoot); } function briefVarsForProject(p: ProjectConfig): Record { return { PROJECT: p.name, TRACKER_REPO: p.tracker.repo, QUEUE_LABEL: p.queueLabel, RELEASES_DEFAULT: RELEASES_DEFAULTS[`${p.authority.merge}/${p.authority.release}`], MERGE_DUTY: MERGE_DUTY[p.authority.merge], REVIEW_DUTY: reviewDuty(p), PROMOTION_DUTY: PROMOTION_DUTIES[p.authority.promotion ?? DEFAULT_AUTHORITY.promotion], POLICY_SOURCE: policySourceLine(p), }; } /** * Where the merge and release conditions actually live, worded for this * project. * * A pointer, deliberately carrying no value from the policy it points at. A * threshold rendered into the standing prompt *and* stored in the config is a * threshold with two copies, and the copy a session reads is the one nobody * updated — which is the failure #129 exists to end. So the brief describes * the policy and names where to read it; it never restates it. */ function policySourceLine(p: ProjectConfig): string { return ( `They are in \`${configPath()}\` under \`projects[] "${p.name}" policy\`, and they change with ` + `\`omp-conductor setup policy\` ("${AMEND_AREAS.policy.name}"). Never by hand in this file.` ); } /** Package floor template, placeholders and all. */ export function shippedFloorTemplate(): string { return readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8"); } /** POLICY.md scaffold template, placeholders and all. */ export function shippedPolicyTemplate(): string { return readFileSync(POLICY_TEMPLATE_PATH, "utf8"); } /** * Floor + policy templates concatenated with the compose banner, placeholders * intact. Used when no project config is available to render coordinates. */ export function shippedBriefTemplate(): string { return composeOrchestrator(shippedFloorTemplate(), shippedPolicyTemplate()); } /** Rendered package floor for a configured project. */ export function renderFloorForProject(p: ProjectConfig): string { return renderBriefTemplate(shippedFloorTemplate(), briefVarsForProject(p)); } /** Rendered POLICY.md scaffold for a configured project. */ export function renderPolicyForProject(p: ProjectConfig): string { return renderBriefTemplate(shippedPolicyTemplate(), briefVarsForProject(p)); } // ------------------------------------------------------- the operator console // // The console is the second session of the two-session split: the tick brain // keeps its pane and never receives an operator turn, and this one owns the // Telegram conversation 24/7. Everything below provisions its workspace, and // the whole point of the shape is what it does NOT do: // // - **No tick config.** The tick extension activates only when // `.conductor-tick.json` exists in the session cwd, so a console cwd // without one is inert by construction rather than by a runtime check that // could be got wrong. Nothing here ever writes that file. // - **No POLICY.md, no compose, no layout classification.** The floor is // rendered and written; there is no operator half to merge, so // `refreshComposedBrief` and `inspectBriefLayout` never see this file. // - **Derived, never stamped.** The cwd is a function of the state dir and // the project name, in TypeScript here and in the same shape in // `recover.sh`. A config key would be a second copy of the answer, and the // copy nobody updated is the one a pane would `cd` into. /** * The console session's own directory: `/console/`. * * Under the state root rather than beside the project's worktrees because the * console is a fleet surface, not a checkout — and because a directory the * worker machinery never touches cannot be force-removed by a re-claim. */ export function consoleCwdForProject(p: ProjectConfig): string { return join(stateDir(), "console", p.name); } /** The rendered console floor inside {@link consoleCwdForProject}. */ export function consoleBriefPathForProject(p: ProjectConfig): string { return join(consoleCwdForProject(p), CONSOLE_BRIEF_NAME); } /** Shipped console floor template, placeholders and all. */ export function shippedConsoleTemplate(): string { return readFileSync(CONSOLE_TEMPLATE_PATH, "utf8"); } /** * Rendered console floor for a configured project. * * The same `briefVarsForProject` coordinates the orchestrator floor gets, so a * placeholder either template names is filled from one table — a console that * shipped a literal `{{PROJECT}}` to a session would be reading a template, * not a brief. */ export function renderConsoleForProject(p: ProjectConfig): string { return renderBriefTemplate(shippedConsoleTemplate(), briefVarsForProject(p)); } /** * The shell line that launches the console pane, produced here so * `omp-conductor console` and `recover.sh` cannot print two different commands * for one surface. * * `dmOwner` is the Telegram DM force-claim, and it is a host-level judgement, * not a preference: one state dir may have exactly one DM owner, so a host * with several fleet cwds launches its consoles without it and lets inbound * messages arrive by forum-topic routing instead. Nothing else in this package * sets `OMP_TELEGRAM_DM_OWNER`. */ export function consolePaneCommand(opts: { cwd: string; dmOwner: boolean }): string { // Quoted because the reader copies what a surface prints: a state root under // a path with a space or a `$` must still `cd` where it says. return `cd ${shellQuote(opts.cwd)} && ${opts.dmOwner ? "OMP_TELEGRAM_DM_OWNER=1 " : ""}omp`; } /** * The `AGENTS.md` link in the console cwd and what provisioning will do about * it, with the same never-clobber discipline the fleet pane's link gets: an * already-correct symlink is kept, a stale one is replaced, and a **regular * file** is an operator's own brief — reported and left untouched. */ export interface ConsoleLinkPlan { path: string; /** Relative target inside the symlink — `CONSOLE.md`, its own directory. */ target: string; action: "create" | "update" | "keep" | "skip"; /** Why {@link action} is `skip`; present only then. */ skippedReason?: string; } /** What provisioning the console workspace will write, before it writes it. */ export interface ConsolePlan { /** The directory the console pane runs in. Created by the write. */ cwd: string; /** The rendered floor: `create` on a fresh cwd, `keep` when already current. */ brief: { path: string; content: string; action: "create" | "update" | "keep" }; link: ConsoleLinkPlan; } /** * Plans the console workspace for a project. Pure: reads the filesystem, * writes nothing, so `setup host` can show the whole thing before consent and * `omp-conductor console` can report what it did. */ export function planConsole(p: ProjectConfig): ConsolePlan { const cwd = consoleCwdForProject(p); const briefPath = consoleBriefPathForProject(p); const content = renderConsoleForProject(p); const linkPath = join(cwd, AGENTS_BRIEF_NAME); // Relative, and inside the same directory, so the whole console cwd stays // movable — the same reason the fleet pane's link is relative. const target = relative(dirname(linkPath), briefPath); // An unreadable existing brief is planned as `update`: rewriting it is the // repair, and a plan that reported `keep` on a file it could not read would // leave the console without a floor. let live: string | undefined; try { live = readFileSync(briefPath, "utf8"); } catch { live = undefined; } return { cwd, brief: { path: briefPath, content, action: live === undefined ? (existsSync(briefPath) ? "update" : "create") : live === content ? "keep" : "update", }, link: planConsoleLink(linkPath, target), }; } function planConsoleLink(path: string, target: string): ConsoleLinkPlan { let st; try { st = lstatSync(path); } catch { // ENOENT, and any unreadable parent, both collapse to a best-effort // create; a filesystem that refuses the link surfaces at write time. return { path, target, action: "create" }; } if (st.isSymbolicLink()) { return { path, target, action: readlinkSync(path, "utf8") === target ? "keep" : "update" }; } return { path, target, action: "skip", skippedReason: `an existing file at ${path} is not ours to overwrite; left untouched`, }; } /** What {@link writeConsole} wrote, plus anything it could not. */ export interface ConsoleWrite { wrote: string[]; /** Best-effort failures the caller surfaces; provisioning continues. */ warnings: string[]; } /** * Materialises a planned console workspace. Idempotent: a `keep` brief and a * `keep` link touch nothing, so re-running `omp-conductor console` or * `setup host` rewrites nothing and reports an empty write. * * The link is re-`lstat`ed here rather than trusted from the plan, because the * entry can change across the consent gap: an operator who drops their own * `AGENTS.md` where a stale symlink was planned for replacement must keep it. */ export function writeConsole(plan: ConsolePlan): ConsoleWrite { const wrote: string[] = []; const warnings: string[] = []; mkdirSync(plan.cwd, { recursive: true }); if (plan.brief.action !== "keep") { writeFileSync(plan.brief.path, plan.brief.content); wrote.push(plan.brief.path); } if (plan.link.action === "create" || plan.link.action === "update") { const { path, target } = plan.link; try { let existing; try { existing = lstatSync(path); } catch { existing = undefined; } if (existing !== undefined && !existing.isSymbolicLink()) { warnings.push( `${path} is a regular file, not ours to overwrite; console AGENTS.md link skipped`, ); } else if (existing !== undefined && readlinkSync(path, "utf8") === target) { // Already correct, however the plan read it. } else { if (existing !== undefined) rmSync(path, { force: true }); symlinkSync(target, path); wrote.push(path); } } catch (err) { warnings.push( `could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`, ); } } return { wrote, warnings }; } /** * Composed session brief: rendered floor + shared policy (when present) + * POLICY scaffold (or a caller's policy). * * Setup writes the policy half to `POLICY.md` and this compose to * `ORCHESTRATOR.md`. Later ticks recompose from the live POLICY.md and the live * host-wide shared policy, so package floor updates apply without brief-upgrade. */ export function renderBriefForProject(p: ProjectConfig, policyText?: string): string { return composeOrchestrator( renderFloorForProject(p), policyText ?? renderPolicyForProject(p), readSharedPolicy(), ); } /** Wizard-time path, via the project the answers describe. */ export function orchestratorBriefPath(a: SetupAnswers): string { return briefPathForProject(buildProject(a)); } /** Wizard-time render, via the project the answers describe. */ export function renderOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}): string { // Rendered through the same judgment fill the writer applies, so "what setup // would write" and "what setup writes" cannot diverge — the preview an operator // consents to is the file they get. const project = buildProject(a); return composeOrchestrator(renderFloorForProject(project), renderPolicy(a, prose), readSharedPolicy()); } /** `POLICY.md` for these answers: the project's own template plus the interview. */ export function renderPolicy(a: SetupAnswers, prose: ProbedProse = {}): string { return fillJudgment(renderPolicyForProject(buildProject(a)), a.judgment, prose); } /** * The body of `## Project context`. * * **One writer, not two.** Confirmed prose is the whole section: the probe was * handed the roadmap answer as a stated input and told to carry it as its fourth * item, so synthesising a roadmap line here as well would print the operator's * priority twice — once out of the product/repos/grooming order the section is * written to be read in. * * Without prose — `--no-ai`, a declined preview, an unreachable peer — the roadmap * answer is still the one part of this section the interview *did* collect, so it * is written above a note naming what is left. */ function contextSection(roadmap: string | undefined, context: string | undefined): string { const written = context?.trim(); if (written !== undefined && written.length > 0) { // The heading came from the template; a probe that repeated it would leave two. return written.replace(/^##\s*Project context[^\n]*\n+/, ""); } const stated = roadmap?.trim(); return [ ...(stated === undefined || stated.length === 0 ? [] : [`**Roadmap and current priority.** ${stated}`, "", "Judge grooming priority against that, not against recency.", ""]), "Still to fill in, by hand or by re-running `omp-conductor setup brief`: the", "product in a paragraph, a map of which repo owns what, and the grooming guidance", "Duty 2 needs to spot issues that would collide.", ].join("\n"); } /** * Writes `POLICY.md` + composed `ORCHESTRATOR.md`, and returns the composed path. * * Unconditional by design: the "do not clobber my edits" decision belongs to the * operator, is asked in the wizard, and arrives here as * `answers.writeOrchestratorBrief`. A second existence check in here would make * that dialog's answer un-actionable — an operator who says "yes, overwrite it" * must get an overwrite. */ export function writeOrchestratorBrief(a: SetupAnswers, prose: ProbedProse = {}): string { const project = buildProject(a); const policyPath = policyPathForProject(project); const orchestratorPath = briefPathForProject(project); mkdirSync(dirname(orchestratorPath), { recursive: true }); const policy = renderPolicy(a, prose); writeFileSync(policyPath, policy); writeFileSync(orchestratorPath, composeOrchestrator(renderFloorForProject(project), policy, readSharedPolicy())); return orchestratorPath; } /** The stub `policy.md` ships where the operator's own priority pointer belongs. */ const CONTEXT_STUB = "Empty until setup fills it in: the product in a paragraph, a map of which repo\n" + "owns what, the grooming guidance Duty 2 needs to judge priority and spot issues\n" + "that would collide, and where the roadmap lives. `omp-conductor setup` asks for\n" + "the last of those and proposes the rest."; /** * Prose a probe read out of the repos and the **operator confirmed**. * * Separate from {@link SetupAnswers} on purpose: these are paragraphs of the * operator's brief, not configuration. Nothing here is ever written unconfirmed, * and every field is optional — a declined preview, an unreachable peer and * `--no-ai` all arrive here as the same absence. */ export interface ProbedProse { /** The product, the repo map and the grooming guidance Duty 2 needs. */ projectContext?: string; /** This repo's real release machinery, expressing the operator's stated intent. */ releaseProcedure?: string; } /** * Writes the interview's answers, and any confirmed probe prose, into the two * `POLICY.md` stubs that were waiting for them. * * Text substitution rather than a template variable, because these are the * *operator's* words in the operator's file: the conductor renders `POLICY.md` * once and never reads it back, so anything here has to land as prose they can * then edit freely. * * Absent answers leave the shipped guidance in place — a stub that still explains * what belongs there is more use than a heading with nothing under it. What no * outcome leaves behind is a pointer at the onboarding skill #309 deleted: the * template no longer carries one, and `setup.test.ts` pins its absence, because a * stub telling an operator to load something that does not ship is exactly the * capability loss #307 and #309 were sequenced to avoid. */ export function fillJudgment( policy: string, judgment: OperatorJudgment | undefined, prose: ProbedProse = {}, ): string { const j = judgment ?? {}; let out = policy; const procedure = prose.releaseProcedure?.trim(); const releases = [...releaseLines(j), ...(procedure === undefined || procedure.length === 0 ? [] : ["", procedure])]; if (releases.length > 0) { // Appended under the four bullets rather than replacing them: the bullets say // what a good answer contains, and keeping them beside the answer is what // makes a later edit self-checking. out = out.replace( " forbidden everywhere, always.\n", ` forbidden everywhere, always.\n\n${releases.join("\n")}\n`, ); } out = out.replace(CONTEXT_STUB, contextSection(j.roadmap, prose.projectContext)); return out; } /** The operator's release intent, as the brief's own bullet shape. */ function releaseLines(j: OperatorJudgment): string[] { const lines: string[] = []; const add = (label: string, value: string | undefined): void => { if (value !== undefined && value.trim().length > 0) lines.push(`- **${label}** ${value.trim()}`); }; add("Your leg ends:", j.boundary); add("What may be released:", j.releaseWhat); add("When:", j.releaseWhen); add("Proof held first:", j.releaseProof); add("Always ask about:", j.releaseAsk); add("Permanently forbidden:", j.releaseForbidden); add("A release worth cutting:", j.worthCutting); add("Rollback owner:", j.rollbackOwner); if (lines.length === 0) return lines; return [ "Answered during setup — yours to edit:", "", ...lines, ...(j.rollbackMovesBoundary === true ? [ "", "The rollback owner is a person, so the boundary above ends *before* the", "irreversible step: prepare and verify it, then hand it over.", ] : []), ]; } /** * Recompose `ORCHESTRATOR.md` from the package floor + host-wide shared policy * (when present) + live `POLICY.md`. * * Returns false when POLICY.md is missing (caller should migrate or set up). */ export function refreshComposedBriefForProject(p: ProjectConfig): boolean { return refreshComposedBrief({ policyPath: policyPathForProject(p), orchestratorPath: briefPathForProject(p), floor: renderFloorForProject(p), sharedPolicyPath: sharedPolicyPath(), }); } /** @internal test helper — expose compose banner for assertions. */ export const BRIEF_COMPOSE_BANNER = COMPOSE_BANNER; /** * What can be told about an omp-telegram install without opening a socket. * * A named contract rather than an inferred one: consumers depend on the shape * of the answer, not on the identity of the function that produced it. */ export interface TelegramPresence { /** An omp-telegram state directory exists at all. */ available: boolean; stateDir: string; /** A bot token line is present. Never, under any circumstance, its value. */ hasToken: boolean; /** Set only when exactly one chat is paired, so the wizard cannot offer to * page a chat that belongs to somebody else. */ pairedOwnerId?: string; } /** * Whether omp-telegram is installed beside us, and who it is paired with. * * Only ever reports the *presence* of a token, never its value — a wizard that * echoes a bot token into a plan summary has leaked it to the scrollback, the * terminal's history and any screen recording of the session. */ export function detectTelegram(): TelegramPresence { const override = process.env["OMP_TELEGRAM_STATE_DIR"]?.trim(); const dir = override ? override : join(homedir(), ".omp", "agent", "telegram"); const envPath = join(dir, ".env"); const accessPath = join(dir, "access.json"); const available = existsSync(envPath) || existsSync(accessPath); if (!available) return { available: false, stateDir: dir, hasToken: false }; const result: TelegramPresence = { available: true, stateDir: dir, hasToken: hasTelegramToken(envPath), }; const owner = pairedOwner(accessPath); if (owner !== undefined) result.pairedOwnerId = owner; return result; } /** * Env override for the daemon account's global omp settings file, exactly the * seam `OMP_TELEGRAM_STATE_DIR` gives telegram discovery: a test (or a second * fleet on the same machine) can redirect the read without touching a real * `~/.omp/agent/config.yml`. */ export const OMP_GLOBAL_SETTINGS_ENV = "OMP_CONDUCTOR_OMP_SETTINGS_PATH"; /** * The model roles the daemon account's global omp settings actually configure * — the keys of its `modelRoles` stanza (#875) — the "live OMP role * configuration" an adjudicator answer must be checked against. OMP's own * settings file (`~/.omp/agent/config.yml`) is the source rather than any * conductor-owned registry: conductor stores only role names, so the only * place an unrelated role could be invented is here, and reading this file is * what keeps setup from offering (or accepting) a role OMP never configured. * * A missing file is no roles; an unreadable or malformed one is also no roles * but is reported through `decoded: false` so the wizard can say why instead * of silently offering the default alone. */ export function readConfiguredOmpRoles(): ConfiguredOmpRoles { const override = process.env[OMP_GLOBAL_SETTINGS_ENV]?.trim(); const path = override && override.length > 0 ? override : join(homedir(), ".omp", "agent", "config.yml"); if (!existsSync(path)) return { path, global: [], decoded: true }; try { const parsed = parseYaml(readFileSync(path, "utf8")); return { path, global: [...new Set(modelRolesIn(parsed))].sort(), decoded: true, }; } catch { return { path, global: [], decoded: false }; } } /** The global omp settings read (`readConfiguredOmpRoles`) reports. */ export interface ConfiguredOmpRoles { /** The path read (the override, or the default under the operator's home). */ readonly path: string; /** The model-role names the file's `modelRoles` stanza declares. */ readonly global: readonly string[]; /** False when the file exists but could not be read or parsed. */ readonly decoded: boolean; } /** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is * compared against emptiness and then dropped on the floor. */ function hasTelegramToken(envPath: string): boolean { let raw: string; try { raw = readFileSync(envPath, "utf8"); } catch { return false; } for (const line of raw.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith("#")) continue; const match = /^(?:export\s+)?TELEGRAM_BOT_TOKEN\s*=\s*(.*)$/.exec(trimmed); if (!match) continue; let value = (match[1] ?? "").trim(); const quote = value[0]; if (value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote)) { value = value.slice(1, -1); } if (value.length > 0) return true; } return false; } /** * The paired owner id, but only when there is exactly one. Several entries * means the wizard cannot know which human owns escalations, and guessing * would route a tier-2 page to a stranger. */ function pairedOwner(accessPath: string): string | undefined { let parsed: unknown; try { parsed = JSON.parse(readFileSync(accessPath, "utf8")) as unknown; } catch { return undefined; } const allowFrom = (parsed as { allowFrom?: unknown } | null)?.allowFrom; if (!Array.isArray(allowFrom) || allowFrom.length !== 1) return undefined; const only = allowFrom[0] as unknown; if (typeof only === "string" && only.trim().length > 0) return only.trim(); if (typeof only === "number" && Number.isFinite(only)) return String(only); return undefined; } function describeAvailabilityDays(days: readonly Weekday[]): string { return days.length === WEEKDAYS.length && WEEKDAYS.every((day) => days.includes(day)) ? "every day" : days.join(","); } /** * The caps table's key column. Wide enough that the longest cap key, * `maxConcurrentWorkersPerRepo`, cannot fuse with its value — the very defect * that made #448's consent screen read `maxConcurrentWorkersPerRepo1` while * the daemon ran 3. Exported so tests pin the rendered table against the same * column the renderer uses. */ export const CAPS_PLAN_KEY_PAD = 28; /** * Everything that would change, as plain text, with no side effects at all. * * This is the consent screen. If it does not name a change, the operator did * not agree to it — so every mutating step the plugin can take afterwards is * listed here first, including the labels that would be created and the * transport a tier-2 page would take. */ export function summarisePlan( a: SetupAnswers, scopes: ScopeCheck, labels: LabelPlan[], tg: TelegramPresence, defaults: Caps = DEFAULT_CAPS, ): string { const project = buildProject(a); // Caps are resolved against the caller's defaults — the loaded config's // global block on a re-run, the shipped baseline on a first run, where the // two are the same object. That is the exact baseline the daemon resolves // against, so the consent screen and runtime cannot disagree (#372/#448). // The shipped baseline itself is never labelled "effective". const effective = resolveCaps(project, defaults); const states = Object.values(a.stateLabels).join(", "); const lines: string[] = []; if (scopes.missing.length > 0) { lines.push( `!! MISSING TOKEN SCOPE: ${scopes.missing.join(", ")}`, scopes.login === undefined ? " `gh` reported no authenticated account — run `gh auth login` first." : ` Signed in as ${scopes.login} with: ${scopes.scopes.join(", ") || "(no scopes reported)"}`, " Without `repo` the daemon cannot label or close issues; without `project` it cannot move cards.", " Fix with: gh auth refresh -s repo,project", "", ); } lines.push( `project ${a.projectName}`, `config ${configPath()}`, `tracker ${a.trackerRepo}`, `github user ${scopes.login ?? "(not authenticated)"}`, `queue query open issues in ${a.trackerRepo} labelled "${a.queueLabel}", minus anything already`, ` labelled ${states}, routed by one "${a.routingLabelPrefix}" label`, `worktrees ${project.workspaceRoot}`, `mirrors ${project.mirrorRoot}`, "", ); const toCreate = labels.filter((l) => !l.exists); const present = labels.filter((l) => l.exists); lines.push( toCreate.length === 0 ? `labels all ${labels.length} already exist in ${a.trackerRepo} — nothing to create` : `labels would CREATE ${toCreate.length} in ${a.trackerRepo}:`, ); for (const l of toCreate) lines.push(` + ${l.name} #${l.colour} ${l.description}`); if (present.length > 0 && toCreate.length > 0) { lines.push(` already present: ${present.map((l) => l.name).join(", ")}`); } lines.push("", `routing "${a.routingLabelPrefix}" on an issue selects one of:`); if (a.targetRepos.length === 0) { lines.push(" (none — nothing can be routed, setup will refuse this)"); } for (const r of a.targetRepos) { lines.push(` ${a.routingLabelPrefix}${r.name} ${r.cloneUrl} (${r.defaultBranch})`); for (const g of r.gates) lines.push(` gate: ${g.cmd} [cwd ${g.cwd}]`); if (r.gates.length === 0) { lines.push(" gate: none — nothing is verified before a push"); } } // Absent entirely when unanswered: a plan for a project with no graph must // read exactly as it did before graphs were a thing this wizard could offer. const graphed = graphRepos(project); if (graphed.length > 0) { lines.push("", "code graph workers query these clones instead of grepping:"); for (const r of graphed) lines.push(` ${r.name} ${r.graphProject}`); lines.push( " conductor's own index-only clones — nothing human edits them, and", " nothing here creates them. Run `omp-conductor setup graph` after", " setup: it clones, installs the timer and seeds the first index.", ); } lines.push("", "caps (effective)"); for (const [key, value] of Object.entries(effective)) { const answered = Object.hasOwn(project.caps, key) ? " (answered)" : ""; lines.push(` ${key.padEnd(CAPS_PLAN_KEY_PAD)}${String(value)}${answered}`); } if (a.workerModel !== undefined && a.workerModel.trim().length > 0) { lines.push(` ${"worker model".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerModel.trim()} (answered)`); } if (a.workerEscalationModel !== undefined && a.workerEscalationModel.trim().length > 0) { lines.push( ` ${"cap escalation".padEnd(CAPS_PLAN_KEY_PAD)}${a.workerEscalationModel.trim()} (hand-edited)`, ); } // The daemon spends real money grooming unattended (#1041), so which role it // spends it under belongs on the plan a human approves — hand-edited, like // the cap-escalation tier above it, and silent when nobody answered. if (a.groomRole !== undefined && a.groomRole.trim().length > 0) { lines.push(` ${"groom role".padEnd(CAPS_PLAN_KEY_PAD)}${a.groomRole.trim()} (hand-edited)`); } lines.push("", "escalation"); if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) { const topic = a.telegramTopicId !== undefined && Number.isSafeInteger(a.telegramTopicId) ? ` topic ${a.telegramTopicId}` : " flat chat"; lines.push( ` tier 2 Telegram chat ${a.telegramChatId.trim()}${topic}` + (tg.hasToken ? "" : " — WARNING: no bot token found, this will not send"), ` via omp-telegram at ${tg.stateDir}`, ); } else { lines.push( " tier 2 issue comment only", tg.available && tg.hasToken ? ` note omp-telegram is installed at ${tg.stateDir} but no chat was chosen` : ` note no omp-telegram install found at ${tg.stateDir}`, ); } lines.push(` fallback ${a.fallbackToIssueComment ? "comment on the issue as well" : "disabled"}`); lines.push( ` triage ${ a.orchestratorMode === "external" ? "external — the daemon starts no session; an operator's own drains tier 1 off the tracker" : "embedded — the daemon runs its own orchestrator session" }`, ); const delegated = a.authority.merge === "orchestrator" || a.authority.release === "orchestrator"; // The consent screen is the last place an unintended grant can be caught, so // it names the shapes rather than a policy word: "operator-brief" is exactly // what the operator in #122 read as safe while it also permitted a deploy. const granted = RELEASE_SHAPES.filter((shape) => a.releaseGrants[shape] === "orchestrator"); lines.push( "", `authority merge=${a.authority.merge} release=${a.authority.release} promotion=${a.authority.promotion ?? DEFAULT_AUTHORITY.promotion}`, `tool gate ${ granted.length === 0 ? "every release/deploy shape blocked for workers and the orchestrator" : `orchestrator may invoke ${granted.join(", ")}` }`, ...(granted.length === 0 ? [] : [ ` blocked: ${RELEASE_SHAPES.filter((shape) => a.releaseGrants[shape] !== "orchestrator").join(", ") || "nothing"}`, ]), delegated ? " the brief tells that session so, and it must spell the procedure out before acting" : " humans do both; workers and the conductor stop at a green PR", ); lines.push("", "preconditions what a merge and a release must satisfy — checked mechanically, never re-read from prose"); lines.push( ` merge ${describeChecks(a.policy.merge.requiredChecks, "the PR")}; base ${a.policy.merge.baseFreshness}; ` + `drafts ${a.policy.merge.drafts}; behind base → ${a.policy.merge.whenBehindBase}`, ` release ${ a.policy.release.requires.length === 0 ? "nothing has to have landed first" : `needs ${a.policy.release.requires.join(", ")}` }; ${describeChecks(a.policy.release.requiredChecks, "the branch")}`, ` artefacts ${a.policy.release.artefacts.join(", ") || "none declared — a release verb has nothing to ship"}`, ` environments ${a.policy.release.environments.join(", ") || "none declared — every deploy target is refused"}`, ); lines.push("", "review", ` strictness ${a.review.strictness} — ${REVIEW_STRICTNESS_CHOICES[a.review.strictness]}`); lines.push(` max rounds/PR ${a.review.maxRounds} — at the ceiling the PR is left open, the findings recorded, and it is escalated once`); lines.push(` adjudicator ${a.review.adjudicator} — the OMP model role that runs that ceiling adjudication`); const reporting = project.reporting as ReportingPolicy; const briefPath = orchestratorBriefPath(a); lines.push( "", "reporting", ` scope ${a.reportScope} — ${reportScopeDescriptionForPolicy(reporting)}`, ); if (reporting.availability === undefined) { lines.push(" availability continuous — interrupt categories may page 24 hours a day"); } else { lines.push( ` availability ${describeAvailabilityDays(reporting.availability.days)} ` + `${reporting.availability.start}-${reporting.availability.end} ${reporting.availability.timezone}`, ` bypass ${reporting.availability.bypass.join(", ") || "none"}`, ); } lines.push( ` digest ${reporting.digest.cadence}` + (reporting.digest.cadence === "daily" ? ` at ${reporting.digest.at ?? "(model-timed)"}${reporting.digest.timezone ? ` ${reporting.digest.timezone}` : ""}` : ""), ); if (a.writeOrchestratorBrief) { const policyPath = briefPath.replace(/ORCHESTRATOR\.md$/, "POLICY.md"); lines.push( existsSync(briefPath) || existsSync(policyPath) ? ` brief would OVERWRITE ${briefPath} + POLICY.md` : ` brief would write ${briefPath} + POLICY.md`, " POLICY.md is yours — Releases/Reporting/Amendments; floor recomposes each tick", ); } else { lines.push( existsSync(briefPath) ? ` brief not written — ${briefPath} is left exactly as it is` : ` brief not written — no orchestrator brief at ${briefPath}`, " the package still stops at green PRs; releases stay a human action", ); } return lines.join("\n"); } /** * Gates as the wizard both shows and reads them back: `cmd`, or `cmd @ cwd` when * one runs from a subdirectory. One spelling, so the pre-filled prompt line and * the amend menu's current value cannot drift apart. */ /** * A required-check list as both the plan and the amend row say it. An empty list * is *stricter* than a named one, so it is spelled out rather than shown as * "none" — an operator reading "checks: none" would reasonably conclude the gate * was open. */ function describeChecks(checks: readonly string[], reporter: string): string { return checks.length === 0 ? `every check ${reporter} reports` : `checks ${checks.join(", ")}`; } export function formatGates(gates: readonly { cmd: string; cwd: string }[]): string { return gates.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", "); } /** * Parses the wizard's free-form YAML answer for a project's omp settings * overlay (#537) and validates YAML shape only — the same boundary the config * loader enforces. A mapping at the document root is the whole contract: * everything inside it is omp's schema to own, so an unknown key or a * wrong-typed value is omp's to reject, never this module's to understand * (non-string keys are coerced by the YAML parser exactly as omp's own loader * coerces them, so a `true: x` mapping stays YAML-valid on both sides). Blank * input is a valid "no overlay" answer. */ export function parseOmpSettingsYaml( text: string, ): { ok: true; value: Record } | { ok: false; problem: string } { const trimmed = text.trim(); if (trimmed.length === 0) return { ok: true, value: {} }; let parsed: unknown; try { parsed = parseYaml(trimmed); } catch (err) { return { ok: false, problem: `"${text}" is not valid YAML: ${err instanceof Error ? err.message : String(err)}` }; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return { ok: false, problem: "the omp settings overlay must be a YAML mapping at the root, e.g. `modelRoles: { worker: \"@slow\" }`" }; } return { ok: true, value: parsed as Record }; } /** * The wizard's questions, grouped as the areas a re-run can amend one of, in the * order the full interview asks them. * * Data rather than a switch so the menu, the CLI's positional area vocabulary, * ./setup-wizard.ts's `AREA_ASKERS` table and the amend summary all enumerate the * same ten areas: an added area fails to compile until it has a name, a current * value and a set of questions. */ export const AMEND_AREA_IDS = [ "tracker", "gates", "caps", "omp-settings", "code-graph", "authority", "policy", "escalation", "reporting", "brief", ] as const; export type AmendAreaId = (typeof AMEND_AREA_IDS)[number]; /** * The pure half of amend mode: what each area is called, what choosing it asks, * and what it says right now. * * `describe` is the reason the pick-list is worth anything — an operator picking * blind from eight nouns cannot tell which one holds the setting they came to * change, so every row carries its own current value. It reads only the config, * so the whole menu can be rendered and reviewed without a terminal. The caps * row resolves inherited values against the defaults the caller is amending — * the loaded config's global block on a re-run — so the menu and the daemon * agree on what a project actually runs (#448). */ export const AMEND_AREAS: { readonly [K in AmendAreaId]: { readonly name: string; readonly asks: string; readonly describe: (p: ProjectConfig, defaults?: Caps) => string; }; } = { tracker: { name: "tracker & repos", asks: "tracker repo, queue and state labels, routing prefix, then every routed repo with its gates", describe: (p) => { const names = Object.values(p.routing.repos).map((r) => r.name); return ( `${p.tracker.repo}, queue "${p.queueLabel}", ` + `"${p.routing.labelPrefix}" → ${names.join(", ") || "no repos"}` ); }, }, gates: { name: "gates", asks: "the pre-push commands for each configured repo, and nothing else", describe: (p) => { const repos = Object.values(p.routing.repos); if (repos.length === 0) return "no repos configured"; return repos.map((r) => `${r.name}: ${r.gates.length === 0 ? "none" : formatGates(r.gates)}`).join("; "); }, }, caps: { // The model rides with the caps because it is the other per-worker knob, and // an area no menu offers is a setting only a full re-interview can reach. name: "caps & worker model", asks: "concurrency, spend, turn base and extension ceiling, wall clock, failed attempts, continuations — then the worker model", describe: (p, defaults = DEFAULT_CAPS) => { const c = resolveCaps(p, defaults); const answered = Object.keys(p.caps).length > 0; const spend = c.dailySpendUsd === null ? "no spend cap" : `$${c.dailySpendUsd}/day`; return ( `${c.maxConcurrentWorkers} workers (${c.maxConcurrentWorkersPerRepo}/repo), ` + `${c.workerMaxTurns} base/${c.workerMaxTurnsCeiling} max turns, ` + `${Math.round(c.workerWallClockMs / 60000)}m, ${spend}, ` + `${c.maxAttemptsPerIssue} failed attempt${c.maxAttemptsPerIssue === 1 ? "" : "s"}, ` + `${c.maxContinuationsPerIssue} continuation${c.maxContinuationsPerIssue === 1 ? "" : "s"}` + `${answered ? "" : " (all defaults)"} — ` + `${p.workerModel === undefined ? "harness default model" : `model ${p.workerModel}`}` ); }, }, "omp-settings": { // The free-form omp settings overlay (#537), the sibling per-worker knob to // the model: one area no menu offers is a setting only a full re-interview // can reach, so this one has its own row like the caps. name: "omp settings overlay", asks: "the free-form YAML map layered into every worker session's omp settings — omp's schema, not conductor's", // The *effective* overlay — `ompSettings` plus the retry keys derived from // `modelFallbacks` — so the menu says what a worker would actually load, // matching `doctor` and the dispatcher rather than echoing raw config. describe: (p) => { const overlay = ompSettingsOverlay(p); return overlay === undefined ? "no overlay — workers inherit global settings" : JSON.stringify(overlay); }, }, "code-graph": { name: "code graph", asks: "whether workers query a code-graph index, and the root its one-clone-per-repo lives under", describe: (p) => { const graphed = graphRepos(p); const first = graphed[0]; if (first === undefined) return "not configured — workers grep"; return `${dirname(first.graphProject)} — ${graphed.length} clone(s): ${graphed.map((r) => r.name).join(", ")}`; }, }, authority: { name: "authority", asks: "who lands green PRs, who cuts releases, who promotes, then one question per release/deploy shape", describe: (p) => { const grants = resolveReleaseGrants(p); const granted = RELEASE_SHAPES.filter((shape) => grants[shape] === "orchestrator"); // Counted rather than listed because this row is elided at 96 characters — // and `deploy` is then named on its own, because it is the one grant in the // set that mutates a running environment and the one #122 cost us. The full // table is in `status` and in the amend summary. return ( `merge=${p.authority.merge}, release=${p.authority.release}, ` + `promote=${p.authority.promotion ?? DEFAULT_AUTHORITY.promotion}, tools: ` + `${granted.length === 0 ? "all blocked" : `${granted.length}/${RELEASE_SHAPES.length} granted`}` + `, deploy=${grants.deploy}` ); }, }, policy: { name: "merge & release preconditions", asks: "the checks, base freshness, draft rule and behind-base action for a merge, then what a release requires and what it ships — and how green PRs are reviewed, how many rounds a PR may be returned, and which OMP model role adjudicates a PR at that ceiling", describe: (p) => { const policy = resolvePolicy(p); const review = resolveReview(p); // Counted rather than listed: this row is elided at 96 characters, and the // full table is in the plan summary the consent screen shows next. The // adjudicator is the third review decision, so it is named with the other // two (#875). return ( `merge: ${policy.merge.requiredChecks.length === 0 ? "every check" : `${policy.merge.requiredChecks.length} check(s)`}, ` + `base ${policy.merge.baseFreshness}, drafts ${policy.merge.drafts}, behind → ${policy.merge.whenBehindBase}; ` + `release: ${policy.release.requires.length} must-land, ${policy.release.artefacts.length} artefact(s), ` + `${policy.release.environments.length} env(s); ` + `review ${review.strictness} ×${review.maxRounds} (adjudicator ${review.adjudicator})` ); }, }, escalation: { name: "escalation & triage", asks: "the tier-2 Telegram chat, optional forum topic, whether escalations also comment, and where the orchestrator session lives", describe: (p) => [ p.escalation.telegramChatId === undefined ? "tier 2 by issue comment only" : p.escalation.telegramTopicId === undefined ? `tier 2 pages Telegram ${p.escalation.telegramChatId} (flat chat)` : `tier 2 pages Telegram ${p.escalation.telegramChatId} topic ${p.escalation.telegramTopicId}`, p.escalation.fallbackToIssueComment ? "comments too" : "no comment fallback", `triage ${p.escalation.orchestrator}`, ].join(", "), }, reporting: { name: "reporting scope", asks: "how much the orchestrator says unprompted", describe: (p) => { const scope = reportScopeFromPolicy(p.reporting); return `${scope} — ${reportScopeDescriptionForPolicy(p.reporting)}`; }, }, brief: { name: "orchestrator brief", asks: `whether to write ${ORCHESTRATOR_BRIEF_NAME} + ${POLICY_BRIEF_NAME} — the one area that writes no config key`, describe: (p) => { const path = briefPathForProject(p); return existsSync(path) ? `written at ${path}` : `none at ${path}`; }, }, }; /** * How much of a current value fits on a menu row before it costs more than it * tells. Chosen so a four-repo fleet's tracker row — the longest one worth * keeping whole — survives intact. The full text is never lost either way: the * amend summary prints it unelided, and the area's own prompts pre-fill from it. */ const AMEND_LABEL_MAX = 96; /** * The amend pick-list, rendered. * * The label carries the current value because the harness's select resolves to * the label it showed, so the row an operator picked has to be recognisable from * its own text alone — and it is the value, not the noun, that tells them * whether this is the row they came for. */ export function amendChoices(p: ProjectConfig, defaults: Caps = DEFAULT_CAPS): { id: AmendAreaId; label: string; description: string }[] { return AMEND_AREA_IDS.map((id) => { const area = AMEND_AREAS[id]; const current = area.describe(p, defaults); return { id, label: `${area.name} — ${current.length > AMEND_LABEL_MAX ? `${current.slice(0, AMEND_LABEL_MAX - 1).trimEnd()}…` : current}`, description: area.asks, }; }); } /** * What an amend leads its consent screen with: the area, what it said, what it * would say, and the eight areas nobody was asked about. * * The whole plan still follows this, because the confirm has to name every * mutation it authorises — creating labels, writing the config, replacing a * brief — and a delta alone names none of them. What this adds is the sentence * the operator is actually looking for: one area changed, everything else came * back off disk. Inherited caps on both sides of the "was"/"now" compare * against the same `defaults`, so a changed per-repo limit reads as a change * and a carried value reads as no change (#448). */ export function summariseAmend( area: AmendAreaId, before: ProjectConfig, a: SetupAnswers, defaults: Caps = DEFAULT_CAPS, ): string { const it = AMEND_AREAS[area]; const was = it.describe(before, defaults); // The brief is a decision, not a config key, so its "after" is what the wizard // is about to do rather than what a rebuilt project would say. const now = area === "brief" ? a.writeOrchestratorBrief ? `would ${existsSync(orchestratorBriefPath(a)) ? "OVERWRITE" : "write"} ${orchestratorBriefPath(a)}` : "not written — left exactly as it is" : it.describe(buildProject(a), defaults); const others = AMEND_AREA_IDS.filter((o) => o !== area).map((o) => AMEND_AREAS[o].name); const lines = [`amending ${it.name} — project ${before.name}`]; if (was === now) { lines.push(` no change ${was}`, " you answered through without changing anything here"); } else { lines.push(` was ${was}`, ` now ${now}`); } lines.push( ` carried over ${others.join(", ")}`, ` read back from ${configPath()} and rewritten unchanged`, "", "The whole project as it would then be written:", "", ); return lines.join("\n"); }