/** * Brief building: everything that turns a routed issue plus the host's facts * into the text a worker session actually reads. * * The cut is "what the dispatcher renders" versus "what the dispatcher does". * Nothing in here claims an issue, opens a worktree or launches anything, and * nothing in here takes `Deps` — the caller passes the pieces, which is what * makes the gate grouping, the discussion budget and the whole assembled brief * assertable against a fixture instead of against a dispatched run. * * `BRIEF_TEMPLATE_PATH` resolves through `PACKAGE_SRC_DIR` rather than this * file's own `import.meta.dir`: the templates stayed in `src/briefs/` while this * module moved a directory deeper, and a path that is silently one level off * fails at runtime, not at the type check. */ import { join } from "node:path"; import { effectiveLane, effectiveModel, laneEcho } from "../admission.ts"; import { graphHint } from "../graph.ts"; import { hostConstraintsNotice } from "../host.ts"; import { knowledgeSection } from "../knowledge.ts"; import { releaseShapeFromCommand, sharedHostBriefNotice, type GateShape } from "../release-policy.ts"; import type { Routed } from "../routing.ts"; import type { EffectiveModel, FileLane, HostConstraints, IssueComment, ProjectConfig, ReadyIssue, RepoTarget } from "../types.ts"; import { renderBrief } from "../worker.ts"; import { PACKAGE_SRC_DIR, knowledgeRepoKey, repoSlug } from "./deps.ts"; export const BRIEF_TEMPLATE_PATH = join(PACKAGE_SRC_DIR, "briefs", "worker.md"); /** One configured pre-push gate: the exact command CI runs, and its cwd. */ export type Gate = RepoTarget["gates"][number]; /** A configured gate the guard will refuse a worker, with the shape that decided it. */ export interface CiOwnedGate { gate: Gate; shape: GateShape; } /** * The repo's pre-push gates split into the two groups a worker brief must keep * apart: the ones the worker runs locally, and the ones the guard refuses it * (#1042). * * One typed decision drives both — `releaseShapeFromCommand`, the same * classifier the run-time interlock consults. Every shape it names is refused * for a worker session: `SHARED_HOST_SHAPE` unconditionally, because a * whole-package `bun test` is the exact load that stopped this shared host, * and every release shape because a worker holds no grant whatever the config * says (#126). So a gate it names must never reach the "run every one of * them" list: commanding it buys a burnt turn, a refusal, and a * `claimed-proof-blocked` settlement, which is the #1042 incident. Anything * the classifier does not name is the worker's to run, which keeps a focused * `bun test src/x.test.ts` local. */ export function workerGateGroups(repo: RepoTarget): { local: Gate[]; ciOwned: CiOwnedGate[] } { const local: Gate[] = []; const ciOwned: CiOwnedGate[] = []; for (const gate of repo.gates) { const shape = releaseShapeFromCommand(gate.cmd); if (shape === undefined) local.push(gate); else ciOwned.push({ gate, shape }); } return { local, ciOwned }; } /** * The locally runnable gates, with the "run every one" instruction scoped to * exactly that group (#1042). An empty gate list keeps the honesty line it * always had; a repo whose every gate is CI-owned renders nothing here rather * than a heading over an empty list — {@link ciOwnedGatesBlock} says so in its * own words instead. */ export function gatesBlock(slug: string, groups: { local: Gate[]; ciOwned: CiOwnedGate[] }): string { if (groups.local.length === 0) { return groups.ciOwned.length === 0 ? "_No pre-push gates are configured for this repo. Say so in your report rather than inventing one._" : ""; } return [ `These are the gates for \`${slug}\` that are yours to run before pushing:`, "", groups.local.map((g) => `- \`${g.cmd}\` — run from \`${g.cwd}\``).join("\n"), "", "Run every one of these, from the directory listed, over the **whole tree** — not", "just the directory you edited. Linting only the source dir is how an error in a", "migration, a config file or a script reaches the runners.", ].join("\n"); } /** * The CI-owned gates, each naming the shape that classified it so the worker * reads *why* it is not theirs rather than guessing (#1042). Renders nothing * when there are none, so an unguarded repo's brief is byte-for-byte the * single-group brief it always was. * * The leading blank lines belong to the value, not the template: the * placeholder sits flush against `{{GATES}}` so an empty group leaves no * orphaned whitespace, the same convention `hostConstraintsNotice` uses. */ export function ciOwnedGatesBlock(slug: string, groups: { local: Gate[]; ciOwned: CiOwnedGate[] }): string { if (groups.ciOwned.length === 0) return ""; return [ "", "", groups.local.length === 0 ? `Every configured gate for \`${slug}\` is CI-owned on this host:` : `CI owns these gates for \`${slug}\` on this host:`, "", groups.ciOwned .map( ({ gate, shape }) => `- \`${gate.cmd}\` (from \`${gate.cwd}\`) — classified \`${shape}\`; the guard refuses this one for a worker session`, ) .join("\n"), "", "**Do not run these locally, and do not claim them as proof.** They run in CI on", "your PR, which is where their verdict comes from. Attempting one here returns a", "refusal, not evidence, and costs you a turn you needed for the work.", ].join("\n"); } export function acceptanceCriteria(issue: ReadyIssue): string { const body = issue.body.trim(); // ponytail: the whole issue body stands in for a criteria section. Upgrade // path is parsing the "## Acceptance criteria" heading once issue templates // are consistent enough to trust; a fuzzy extraction today would silently // drop context the worker needs, and the brief already tells it to read the // issue itself. return body.length > 0 ? body : "_The issue body is empty. Read the issue and its comments, and escalate if it is genuinely underspecified._"; } /** * How much of a comment thread one brief may hold. Grooming notes are short, * and a thread that outgrows this is truncated with an explicit marker rather * than a silently dropped tail — the disappearance this fix exists to prevent. */ export const DISCUSSION_CHARS_BUDGET = 8_000; /** * The issue's comments as a brief section: attributed, numbered, oldest * first, so a later correction visibly supersedes an earlier note. Rendered * beside the body at dispatch, because the orchestrator's grooming is posted * as comments and a worker must never depend on a runtime read to see it. * * `"unread"` is not "empty": when the tracker refused at dispatch the section * says so, instead of silently reading as "no discussion" — which is exactly * the #517 failure. An empty comment list renders nothing, so a commentless * issue's brief stays byte-identical to what this package has always shipped. */ export function renderDiscussion(comments: IssueComment[] | "unread", lane?: FileLane): string { if (comments === "unread") { const lines = [ "## Discussion", "", "_The issue's comments could not be read at dispatch time. The live read below is_", "_the only path to them; if it prints nothing, that is a failed read, not an_", "_absence of discussion._", "", ]; // The lane admission enforced is carried through dispatch, so even an // unreadable thread cannot hide it from the worker (#608): the gate's // effective declaration renders from the admission snapshot, not from the // read that just failed. A body declaration needs no note — the body // always renders. if (lane !== undefined && lane.at !== "body") { lines.push( "_The effective file lane below was enforced at admission — it supersedes any earlier declaration._", lane.source, "", ); } return lines.join("\n"); } if (comments.length === 0) return ""; const total = comments.length; const lines = [ "## Discussion", "", `${total} comment${total === 1 ? "" : "s"} on the issue at dispatch, oldest first — a later`, "comment supersedes an earlier one.", "", ]; let chars = lines.join("\n").length; let shown = 0; for (const comment of comments) { const block = `**@${comment.author} — comment ${shown + 1}:**\n\n${comment.body}\n\n`; if (shown > 0 && chars + block.length > DISCUSSION_CHARS_BUDGET) break; lines.push(`**@${comment.author} — comment ${shown + 1}:**`, "", comment.body, ""); chars += block.length; shown += 1; } if (shown < total) { const omitted = total - shown; lines.push( `… ${omitted} comment${omitted === 1 ? "" : "s"} omitted — the discussion exceeded ${DISCUSSION_CHARS_BUDGET} characters.`, "Read the issue for the tail.", "", ); } // The file-lane gate resolves its effective lane across the whole thread, // not just what this budget renders, so a winning declaration beyond the // budget would otherwise control admission while the worker never sees it // (#608). Reproduce the winning declaration verbatim here — `at` records // the comment it came from — so the rendered brief and the gate agree on // the same lane, and the note itself re-parses through the same // `File lane:` grammar. The note is suppressed only when the winning // declaration is provably visible in the rendered thread (the body always // renders, so a body winner needs none); a carried admission lane whose // comment the dispatch re-read shifted or dropped still renders here, // whether or not the thread was truncated. if ( lane !== undefined && lane.at !== "body" && !comments.slice(0, shown).some((c) => c.body.includes(lane.source)) ) { lines.push( `_The effective file lane was declared in comment ${lane.at + 1} — it supersedes any earlier declaration._`, lane.source, "", ); } return lines.join("\n"); } /** * The parsed file lane as a brief section (#724): the file list the gate will * enforce, or the explicit fail-open note — the same `laneEcho` the promotion * verb prints, so the author and the worker read one parse. Deliberately only * the parse, never the source line: the prose already renders in the body or * the discussion, and in #720's case the prose is exactly what looked * reasonable to a human while parsed greedily. */ export function laneBlock(lane: FileLane | undefined): string { const echo = laneEcho(lane); const body = lane === undefined ? `_${echo}_` : `\`${echo}\``; return ["## File lane (as parsed)", "", body, "", ""].join("\n"); } /** * The declared model as a brief section (#535): the selector the dispatch * will launch on — the same parse admission carried, never a second read — * or the explicit fail-open note, so the worker reads the run's model on the * brief itself rather than inferring it. Deliberately only the selector, * never the source line: the declaration's prose already renders in the body * or Discussion. */ export function modelBlock(model: EffectiveModel | undefined): string { const body = model === undefined ? "_no model declared — the project's workerModel (or harness default) is in effect_" : `\`${model.model}\``; return ["## Model (as parsed)", "", body, "", ""].join("\n"); } /** * What an orphan-resumed worker is told about the file lane on top of the * continuation notice (#608). The original brief already in the transcript * may show an earlier declaration, while admission enforces the current one — * replaying the whole brief would re-do the work, but continuing under a stale * lane is the collision the gate exists to stop. The declaration is rendered * verbatim, so it re-parses through the same `File lane:` grammar every * surfaced declaration uses. */ export function resumeLaneBlock(lane: FileLane): string { return [ "", "The file lane admission enforced for this continuation is below. It supersedes", "any lane declaration in the brief already in your transcript:", "", lane.source, "", `Parsed files: ${laneEcho(lane)}.`, "", ].join("\n"); } /** * What a worker whose block has been answered is told, when its own session can * be resumed (Phase 3 inner loop). * * The shape of `REVIEW_REVISION_PROMPT`, and for the same reason: the run's * whole context is already in the transcript being continued, so replaying the * cold brief is how a resumed worker re-reads a repo it just finished reading * and re-does work it has already done. What it does NOT already have is the * answer — that arrived after it parked — so the discussion travels with the * prompt. * * The answer is not singled out, deliberately. Tracker comments carry no * timestamp through this port, so the daemon cannot prove which comment is the * answer; naming one would be a guess dressed as a fact. The thread newest-last * plus "the answer is in it" is exactly what the daemon knows. */ export const ANSWERED_BLOCK_PROMPT = "You blocked this run for a decision, and the operator has now answered. Continue this same " + "session: same issue, same branch, same worktree, and the same pull request if you had opened " + "one — do not start over, and do not re-read what your transcript already holds. The issue's " + "discussion as it stands now is below, oldest first: the answer to your blocking question is in " + "it, and a later comment supersedes an earlier one. Act on it, finish the work against the same " + "acceptance criteria, and settle exactly as you would have."; export function renderAnsweredBlockPrompt(issue: number, discussion: string): string { return [ ANSWERED_BLOCK_PROMPT, "", `Issue #${String(issue)} — the decision you were waiting on:`, "", discussion.trim().length === 0 ? "_No comment was posted on the issue. The block was cleared by an operator without a written " + "answer: proceed on your own best reading of the acceptance criteria, and block again only if " + "the question is genuinely still unanswerable._" : discussion, ].join("\n"); } /** * The worker's opening prompt. * * Exported for the same reason `salvageLines` is: this text is the entire * context a session with the host's credentials gets, so the two things a test * can hold it to are worth holding — that a configured graph reaches the worker, * and that a project without one gets the brief this package has always shipped, * to the byte. */ export async function buildBrief( project: ProjectConfig, r: Routed, branch: string, worktree: string, opts: { continuation?: boolean; defaultBranch?: string; salvagedSha?: string; /** The issue's comments, rendered into the Discussion section. `"unread"` * when the tracker refused at dispatch — rare, but it must never read as * "no comments" (the #517 failure mode). Absent means an empty list. */ comments?: IssueComment[] | "unread"; /** * The effective file lane admission resolved for this candidate (#608). * When carried, the brief renders exactly this value — the gate's own * snapshot — instead of recomputing from the dispatch-time comment read, * so a changed or failed second read can neither hide nor reword the * lane admission enforced. Absent (unit-level callers), the lane is * resolved from the rendered thread itself. */ lane?: FileLane; /** * The effective `Model:` declaration admission resolved for this * candidate (#535). When carried, the brief echoes exactly this * selector — the same value dispatch launches on — instead of * recomputing from the dispatch-time comment read, so the run's model is * one value on every surface. Absent (unit-level callers), the model is * resolved from the rendered thread itself. */ model?: EffectiveModel; /** * The typed host-constraints block the brief renders (#721), re-read with * the config at the tick boundary so an operator edit applies on the next * dispatch. Absent renders no host-constraints section at all — the * brief is byte-for-byte what it always was. */ host?: HostConstraints; } = {}, ): Promise { // Read per dispatch rather than caching: editing the brief then takes effect // on the next issue instead of needing a daemon restart. const template = await Bun.file(BRIEF_TEMPLATE_PATH).text(); const defaultBranch = opts.defaultBranch ?? r.repo.defaultBranch; const continuation = opts.continuation === true ? [ "", "## Continuation — do not start from zero", "", `You are **resuming** issue #${r.issue.number}. Branch \`${branch}\` already exists`, "and was reattached with prior commits (and possibly a salvaged WIP tip).", ...(opts.salvagedSha === undefined ? [] : [ "", `The previous attempt's uncommitted work was preserved for you as commit`, `\`${opts.salvagedSha}\` on this branch. It is the tip you are continuing from,`, "and it is the only copy of that work — do not reset past it or force-push over it.", ]), "Before writing anything:", "", "```bash", "git log --oneline origin/" + defaultBranch + "..HEAD", "git diff --stat origin/" + defaultBranch + "...HEAD", "git status --porcelain", "```", "", "Read that history. **Do not recreate work that already exists.** Finish", "what remains against the same acceptance criteria. If a prior attempt", "left a `wip(#…): … auto-salvaged` commit, treat it as your starting", "point, not as trash to rewrite from scratch.", "", ].join("\n") : ""; // The comments as rendered, and the effective file lane the brief must show // — the admission-carried value when dispatch has one, else the lane the // thread itself resolves to — passed to the renderer so a winning // declaration can be reproduced verbatim even when it sits beyond the // discussion budget, keeping the gate and the worker-visible brief on one // lane (#608). const comments = opts.comments ?? []; // The effective lane admission resolved (or resolves) for this candidate: // the carried admission snapshot when dispatch has one, else the thread // itself. Both the discussion renderer and the parsed-lane section draw on // the same value, so the brief shows one lane on every surface (#608, #724). const lane = opts.lane ?? (comments === "unread" ? undefined : effectiveLane(r.issue.body, comments)); // The effective model declaration admission resolved (or resolves) for this // candidate: the carried admission snapshot when dispatch has one, else the // thread itself. Dispatch launches on the same value, so the brief shows // one model on every surface (#535). const model = opts.model ?? (comments === "unread" ? undefined : effectiveModel(r.issue.body, comments)); // One typed gate decision, rendered as two groups (#1042). Computed once so // the two placeholders cannot disagree about which gate belongs where. const gates = workerGateGroups(r.repo); const slug = repoSlug(r.repo); return renderBrief(template, { ISSUE_NUMBER: String(r.issue.number), ISSUE_TITLE: r.issue.title, TRACKER_REPO: project.tracker.repo, REPO: slug, BRANCH: branch, WORKTREE: worktree, ACCEPTANCE_CRITERIA: acceptanceCriteria(r.issue), ISSUE_COMMENTS: renderDiscussion(comments, lane), FILE_LANE: laneBlock(lane), MODEL: modelBlock(model), // The fleet's accumulated knowledge about this repo, as its own capped // section (Phase 3 knowledge overlay). Read per dispatch from // `/knowledge/.md`, so what one worker discovered and // reported at settlement reaches the next one instead of dying with its // run row. Empty string when the file does not exist or holds nothing: // the placeholder sits flush against `{{MODEL}}`, so a fleet with no // knowledge yet renders the brief it always did, to the byte. KNOWLEDGE: knowledgeSection(knowledgeRepoKey(r.repo)), // The gates the worker runs, and — flush against them so an empty group // leaves no orphaned whitespace — the ones the guard refuses it (#1042). GATES: gatesBlock(slug, gates), CI_OWNED_GATES: ciOwnedGatesBlock(slug, gates), // The guarded shell suites and their sanctioned parse-only check, derived // from SHARED_HOST_SCRIPTS so the brief and the refusal share one list // (#687). Non-empty whenever the guard has paths, so the placeholder line // always renders to a line. SHARED_HOST_NOTICE: sharedHostBriefNotice(), // The typed host-constraints paragraph (#721): derived cores/RAM folded // into the operator's description, the non-interactive PATH, and the // routed repo's convention. Empty when the config names none — no // section, no placeholder text. HOST_CONSTRAINTS: hostConstraintsNotice(opts.host, slug), // The brief's code-graph paragraph: the exact `project` key for a // configured repo, or an explicit "no graph" statement for an // unconfigured one — never silence, because a worker that knows there is // no graph stops looking for it. GRAPH_HINT: graphHint(r.repo), CONTINUATION: continuation, }); }