diff --git a/packages/opencode/src/altimate/prompts/profiles.ts b/packages/opencode/src/altimate/prompts/profiles.ts index 393b1c0e2..778264852 100644 --- a/packages/opencode/src/altimate/prompts/profiles.ts +++ b/packages/opencode/src/altimate/prompts/profiles.ts @@ -72,12 +72,26 @@ export const BUILDER_PROFILE: readonly FragmentName[] = [ */ export const DATA_QA_PROFILE: readonly FragmentName[] = ["core", "legacy-skills-catalogue", "core-training"] +/** + * The `builder` profile with the Pre-Execution Protocol (sql-guard) pack + * excluded — every other fragment, same order, unchanged. NOT a registered + * agent and not selected by default; it exists purely as a per-session + * override that `session/pre-execution.ts` swaps in for the `builder` agent's + * `.prompt` field when its task-shape gate confidently classifies a run-mode + * workspace as having no dbt project (the one cell an internal 540-trial + * paired ablation covered: no score effect, 2,805 pre-execution tool calls + * removed). Every other case — dbt work, interactive chat, or an + * unclassifiable workspace — keeps the default `PROMPT_BUILDER` untouched. + */ +export const BUILDER_PROFILE_SCOPED: readonly FragmentName[] = BUILDER_PROFILE.filter((name) => name !== "sql-guard") + export function assemble(profile: readonly FragmentName[]): string { return profile.map((name) => FRAGMENTS[name]).join("") } export const PROMPT_BUILDER = assemble(BUILDER_PROFILE) export const PROMPT_DATA_QA = assemble(DATA_QA_PROFILE) +export const PROMPT_BUILDER_SCOPED = assemble(BUILDER_PROFILE_SCOPED) export * as PromptProfiles from "./profiles" // altimate_change end diff --git a/packages/opencode/src/session/pre-execution.ts b/packages/opencode/src/session/pre-execution.ts new file mode 100644 index 000000000..bc19fced2 --- /dev/null +++ b/packages/opencode/src/session/pre-execution.ts @@ -0,0 +1,352 @@ +// Fork-only module — owns the PRE-EXECUTION PROTOCOL SCOPING CONTRACT. +// +// The protocol used to sit statically in `altimate/prompts/builder.txt`. +// builder is a PRIMARY agent, so a static section there governs every builder +// surface at once: dbt authoring, interactive chat, and headless +// question-answering runs. A pre-registered paired ablation (540 trials on a +// public data-question benchmark, one binary across both arms) measured what +// the section costs on the question-answering surface: +// +// macro Pass@1 control 0.6667 → treatment 0.6807 +// delta +0.0140, query-blocked permutation p = 0.7358, +// cluster-bootstrap 95% CI [-0.0400, +0.0674] → no score effect +// wall clock 440.9s → 319.4s (-27.6%) +// model turns -27.7%, generation time -32.2% +// `altimate_core_validate` + `sql_analyze` calls 2,805 → 0 +// `sql_execute` calls +49% (the freed budget went into real querying) +// +// The 2,805 → 0 is the one number directly attributable to this text: the +// ritual is prompt-ordered, and deleting the order deletes it completely. The +// latency win is NOT attributable to this section alone — that treatment arm +// bundled five coupled changes and the experiment declined to attribute. +// +// So this module SCOPES rather than deletes. The measurement covers exactly +// one cell — headless question-answering in a workspace with no dbt project — +// and that is the only cell where the protocol is dropped. dbt work and +// interactive chat, where a pre-execution discipline may genuinely earn its +// place, are unmeasured and keep it. Anything that cannot be classified +// confidently keeps it too: the cost of keeping it is latency on one workload, +// the cost of wrongly dropping it is unmeasured. +// +// MECHANISM (reworked onto the pack architecture — `altimate/prompts/profiles.ts`, +// PR #1217): `builder.txt` no longer exists. The protocol is the `sql-guard` +// pack, and it ships statically inside the default `builder` agent's `.prompt` +// (`PromptProfiles.PROMPT_BUILDER`, byte-pinned by +// `test/altimate/prompt-profiles.test.ts`) so it is present by default in every +// case. This module no longer INJECTS the text — there is nothing to inject, +// it is already there. Instead `scopedBuilderPrompt` returns a per-session +// override prompt (`PromptProfiles.PROMPT_BUILDER_SCOPED`, the same profile +// with the sql-guard pack excluded) for the one cell the ablation covers, or +// `undefined` to mean "use the agent's default prompt unchanged". The call +// site (session/prompt.ts) clones the resolved `Agent.Info` with `.prompt` +// swapped only when this returns a value — the default static registration in +// agent.ts, and the byte-identity pin, are never touched. +// +// Directive commentary lives here (not at the call site) so any change to the +// gate's reasoning reviews in ONE file, mirroring session/termination.ts. + +import fs from "fs/promises" +import path from "path" +import { PromptProfiles } from "../altimate/prompts/profiles" +import { Log } from "../util/log" + +const log = Log.create({ service: "pre-execution-scope" }) + +/** Files that mark a directory as a dbt project root. */ +const PROJECT_FILES = ["dbt_project.yml", "dbt_project.yaml"] as const + +/** + * Subdirectories never considered candidates for a nested dbt project, mirroring + * `findDbtProjectRoot`'s skip list so a fixture project shipped inside + * `node_modules/foo/` or a compiled artifact in `target/` is not mistaken for + * the user's real project. + */ +const SKIP_DIRS = new Set(["node_modules", "target"]) + +/** + * The `errno` code of a filesystem rejection, when it carries one. + * + * `ENOENT` and `ENOTDIR` are real answers — nothing is there. Every other code, + * and an error carrying no code at all, means the question went unanswered. + */ +function errnoCode(err: unknown): string | undefined { + if (typeof err !== "object" || err === null || !("code" in err)) return undefined + const code = err.code + return typeof code === "string" ? code : undefined +} + +/** True when a rejection means "nothing is there", rather than "could not tell". */ +function meansAbsent(err: unknown): boolean { + const code = errnoCode(err) + return code === "ENOENT" || code === "ENOTDIR" +} + +/** + * How many directory levels below a candidate to search for a nested dbt + * project. A single level missed real monorepo layouts — a project at + * `repo/platform/analytics/dbt_project.yml` when the candidate is `repo` is + * two levels down. Chosen generously for realistic layouts while keeping the + * scan bounded: unlike the ancestor walk above (naturally bounded by + * filesystem depth, and cheap — two `stat`s per level), a downward scan can + * visit an unbounded number of directories in a large tree. + */ +const MAX_DOWNWARD_LEVELS = 4 + +/** + * Hard cap on directories enumerated during one downward scan, so a candidate + * sitting above a huge non-dbt tree cannot make every run-mode builder step + * pay for an unbounded `readdir` fan-out. + */ +const MAX_DOWNWARD_SCANS = 2000 + +/** + * Breadth-first search for a dbt project file below `start`, down to + * `MAX_DOWNWARD_LEVELS` levels (the same skip list as the top-level scan, so + * a fixture project inside `node_modules/` or `target/` is never mistaken for + * the user's own). + * + * Returns `"dbt"` on the first project file found. Returns `"non-dbt"` only + * if the ENTIRE bounded tree was enumerated with none found. Returns + * `"unknown"` if the scan bound (levels or directory count) was hit, or any + * directory could not be enumerated, before the answer was settled — hitting + * a bound is "did not finish", not "found nothing", the same honesty rule the + * ancestor walk applies to its own (unbounded) limit. + */ +async function scanDownward(start: string): Promise { + let frontier = [start] + let scanned = 0 + for (let level = 1; level <= MAX_DOWNWARD_LEVELS; level++) { + const next: string[] = [] + for (const dir of frontier) { + if (scanned >= MAX_DOWNWARD_SCANS) return "unknown" + scanned++ + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch (err) { + if (!meansAbsent(err)) log.warn("workspace enumeration failed", { dir, code: errnoCode(err) }) + return "unknown" + } + // Probe everything that is not plainly a regular file. Filtering on + // `isDirectory()` would silently skip symlinked directories and any + // entry whose type the filesystem did not report, and a skipped entry + // is an unexamined one. `hasProjectFile` on a non-directory just gets + // ENOTDIR, which is a real "nothing there". + const children = entries + .filter((e) => !e.isFile() && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name)) + // Deterministic order: fs.readdir's order varies across filesystems. + .sort((a, b) => a.name.localeCompare(b.name)) + for (const child of children) { + const childPath = path.join(dir, child.name) + const found = await hasProjectFile(childPath) + if (found === true) return "dbt" + if (found === undefined) return "unknown" + next.push(childPath) + } + } + frontier = next + } + // Directories still queued at the level bound means the walk stopped + // before finishing, not that it finished and found nothing. + return frontier.length > 0 ? "unknown" : "non-dbt" +} + +/** + * How a workspace classifies for the purpose of this gate. + * + * `unknown` is a real, load-bearing state, not a placeholder: it is what we + * report when the filesystem could not answer the question, and it keeps the + * protocol. The existing `findDbtProjectRoot` helper cannot serve this gate + * directly because it collapses "no project here" and "could not look here" + * into the same `null`, and this gate turns a directive off on the difference. + */ +export type WorkspaceShape = "dbt" | "non-dbt" | "unknown" + +/** + * Does `dir` itself contain a dbt project file? + * + * Returns `undefined` — not `false` — when the filesystem could not answer. + * ENOENT and ENOTDIR are real answers ("nothing there"); anything else (EACCES, + * EIO, a transient network mount failure) is not, and must not be read as + * "no dbt project here". + */ +async function hasProjectFile(dir: string): Promise { + let sawUnknown = false + for (const name of PROJECT_FILES) { + try { + if ((await fs.stat(path.join(dir, name))).isFile()) return true + } catch (err) { + if (meansAbsent(err)) continue + log.warn("project-file probe failed", { dir, name, code: errnoCode(err) }) + sawUnknown = true + } + } + return sawUnknown ? undefined : false +} + +/** + * Classify a workspace by the presence of a dbt project. + * + * `dbt` requires an actual `dbt_project.yml` (or `.yaml`) FILE at, above, or + * one level below a candidate directory: + * + * - **at** the candidate, + * - **above** it, every ancestor up to the filesystem root. A session is + * routinely started inside `models/`, and on a non-git project the worktree + * candidate is the same directory, so nothing else would find the project. + * The walk is deliberately unbounded: a depth limit would have to report + * "I stopped early" as `unknown` to stay honest, which on any deep tree + * turns the gate off entirely. Two `stat` calls per level, in run mode + * only, is not worth that. + * - **up to `MAX_DOWNWARD_LEVELS` levels below** it, which is how benchmark + * and monorepo layouts nest a project — a bounded breadth-first walk (see + * `scanDownward`), not the single-level check `findDbtProjectRoot` uses; + * a real monorepo project nested two or more directories down must still + * read as `dbt`, not `non-dbt`. + * + * A project found in an unrelated ancestor is a false positive that KEEPS the + * protocol, which is the safe direction. + * + * `non-dbt` requires at least one candidate the scan examined COMPLETELY — its + * symlinks resolved, every ancestor probe answered up to the root, and its own + * children enumerated and probed — with no project found. If no candidate + * managed that, the answer is `unknown`, which keeps the protocol. One complete + * answer is enough: the ancestor walk from that candidate already covers the + * worktree above it, so a partner candidate that could not be read has nothing + * left to contribute. + */ +export async function classifyWorkspace(candidates: (string | undefined)[]): Promise { + const dirs = [...new Set(candidates.filter((d): d is string => !!d))] + let sawCompleteAnswer = false + + for (const dir of dirs) { + let complete = true + + // Resolve symlinks first. `path.resolve` is lexical, so a symlinked cwd + // (`/tmp/ws` -> `/repo/models`) would walk `/tmp` and `/` and never see the + // project the session is actually inside. + let start: string + try { + start = await fs.realpath(dir) + } catch (err) { + if (!meansAbsent(err)) log.warn("candidate realpath failed", { dir, code: errnoCode(err) }) + continue + } + + // At the candidate, then upwards to the filesystem root. + let current = start + for (;;) { + const found = await hasProjectFile(current) + if (found === true) return "dbt" + if (found === undefined) complete = false + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + + // Below the candidate, down to MAX_DOWNWARD_LEVELS. The filesystem root + // is a legitimate stop rather than a directory to enumerate — a non-git + // project sets worktree to it, and scanning its children is meaningless + // and can be slow or permission-denied — so the root on its own never + // yields a complete answer. + if (start === path.parse(start).root) { + complete = false + } else { + const below = await scanDownward(start) + if (below === "dbt") return "dbt" + if (below === "unknown") complete = false + } + + if (complete) sawCompleteAnswer = true + } + + return sawCompleteAnswer ? "non-dbt" : "unknown" +} + +/** + * The sole gate for scoping the pre-execution protocol (the `sql-guard` pack) + * out of the builder prompt. + * + * Returns a full replacement prompt to use INSTEAD of the agent's default + * `.prompt`, or `undefined` to mean "use the default, unchanged" — the + * default already carries the protocol, since it ships statically in + * `PromptProfiles.PROMPT_BUILDER`. The ONLY case this returns an override is + * the one the ablation measured: + * + * run mode (headless / CI, the `run` CLI) AND + * the builder agent (the only profile that ever carried the pack) AND + * the agent's prompt is STILL the stock default (see `input.prompt`) AND + * a workspace confidently classified as having no dbt project. + * + * Everything else returns `undefined`, including `unknown`. Note the asymmetry + * is deliberate: run mode is not itself a task-shape signal, it is the surface + * the evidence covers. Widening this to interactive chat needs its own + * measurement. + * + * Classification is only performed when the cheap conditions already hold, so + * an interactive session pays no filesystem cost for this gate. + */ +export async function scopedBuilderPrompt(input: { + runMode: boolean + /** + * The agent's REGISTRY KEY — the string used to look it up (e.g. the + * `"builder"` in `Agent.get("builder")` / `cfg.agent.builder`) — NOT + * `Info.name`, which a user can rename via `agent.builder.name` in config + * while the agent stays registered under the `builder` key. Keying on the + * mutable display name would (a) silently stop scoping a renamed builder + * agent forever, and (b) start scoping a differently-purposed custom agent + * a user happens to name `"builder"`. + */ + agent: string + /** + * The resolved agent's CURRENT `.prompt`. The override is only ever safe to + * apply when this is still exactly `PromptProfiles.PROMPT_BUILDER` — a + * builder prompt customized via `agent.builder.prompt` in config or a + * `.altimate-code/agents/builder.md` override must never be silently + * discarded in favour of the stock scoped variant. + */ + prompt: string | undefined + /** Candidate directories to classify — typically the cwd and the worktree root. */ + directories: (string | undefined)[] +}): Promise { + // Only builder ever carried this pack; analyst and reviewer never did. + if (input.agent !== "builder") return undefined + if (input.prompt !== PromptProfiles.PROMPT_BUILDER) return undefined + if (!input.runMode) return undefined + const shape = await classifyWorkspace(input.directories) + if (shape !== "non-dbt") return undefined + log.info("pre-execution protocol scoped out", { agent: input.agent, shape }) + return PromptProfiles.PROMPT_BUILDER_SCOPED +} + +/** + * Wrap `scopedBuilderPrompt` in a cache scoped to the lifetime of whatever the + * caller holds the returned function for. + * + * `session/prompt.ts`'s `loop()` calls this gate on every step of its + * `while (true)` turn loop — and that loop already documents, at its own + * trace-span guard, that "the system prompt is functionally identical across + * steps within a single loop() invocation (same agent, same environment)". + * `classifyWorkspace`'s filesystem walk is exactly such invariant work, so + * re-running it every step wastes real I/O (a `readdir` fan-out per step) on + * the exact run-mode/non-dbt workload this whole gate exists to make faster. + * + * The cache MUST be created fresh per loop() invocation, never held at module + * scope: workspace state can legitimately change BETWEEN turns (a prior turn + * could itself run `dbt init`), and a cache surviving across turns would + * silently keep serving a stale answer. Scoping it to one loop() invocation + * — a tight, synchronous sequence of the same turn's tool calls — accepts + * that same invariant the surrounding loop already relies on, and nothing + * more. + */ +export function createScopedBuilderPromptCache(): ( + input: Parameters[0], +) => Promise { + let cached: { value: string | undefined } | undefined + return async (input) => { + if (!cached) cached = { value: await scopedBuilderPrompt(input) } + return cached.value + } +} + +export * as SessionPreExecution from "./pre-execution" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e711e3f90..e0d4d9350 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -20,6 +20,8 @@ import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions, asSchema } import { SessionCompaction } from "./compaction" import { NudgeArbiter } from "./nudge" import { SessionTermination } from "./termination" +// altimate_change — task-shape-scoped pre-execution protocol (see pre-execution.ts) +import { SessionPreExecution } from "./pre-execution" import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" @@ -608,6 +610,13 @@ export namespace SessionPrompt { // compaction, context overflow), so "step === 1" is not "first catalog". let catalogued = false // altimate_change end + // altimate_change start — one cache per loop() invocation for the + // task-shape-scoped pre-execution gate (see the call site below and + // SessionPreExecution.createScopedBuilderPromptCache's doc comment). The + // classification is invariant across this loop's steps, so computing it + // once here and reusing it avoids a filesystem scan on every step. + const getScopedBuilderPrompt = SessionPreExecution.createScopedBuilderPromptCache() + // altimate_change end // altimate_change start (AI-7519) — capture bootstrap start; emitted as a // single "bootstrap" span right before the first processor.process call so // the pre-first-generation region has a visible parent duration in traces. @@ -1469,6 +1478,37 @@ export namespace SessionPrompt { ...(await InstructionPrompt.system()), ...hoistedReminders, ] + // altimate_change start — task-shape-scoped pre-execution protocol. + // builder is a PRIMARY agent, so its static prompt (the `sql-guard` pack, + // assembled into `.prompt` by altimate/prompts/profiles.ts) reaches every + // builder surface: dbt authoring, interactive chat, and headless + // question-answering runs. A paired ablation measured it as pure overhead + // on headless question-answering (2,805 ritual tool calls → 0, no score + // effect) but says nothing about dbt work or interactive chat, so those + // keep it. See session/pre-execution.ts for the gate and the numbers. + // + // Unlike the completion instruction below, this is not additive text: + // the protocol already ships inside `agent.prompt` by default (it is + // part of the byte-pinned default builder profile), so scoping it out + // means swapping in a pack-excluded prompt variant for THIS session only + // — the registered agent and its default `.prompt` are never mutated. + // + // `agent: lastUser.agent` — the REGISTRY KEY (e.g. "builder"), not + // `agent.name`, which config can rename independently of the key an + // agent is registered under (`agent.builder.name` in config). Keying on + // the mutable display name would stop scoping a renamed builder forever + // and start scoping an unrelated custom agent a user happens to name + // "builder". `prompt: agent.prompt` lets the gate refuse to touch a + // customized builder prompt (config override or markdown agent file) — + // see scopedBuilderPrompt's doc comment. + const scopedBuilderPrompt = await getScopedBuilderPrompt({ + runMode: Flag.ALTIMATE_RUN_MODE, + agent: lastUser.agent, + prompt: agent.prompt, + directories: [Instance.directory, Instance.worktree], + }) + const effectiveAgent = scopedBuilderPrompt ? { ...agent, prompt: scopedBuilderPrompt } : agent + // altimate_change end // altimate_change start — run-mode-only completion instruction. This text // used to sit in builder.txt, but builder is a PRIMARY agent, so it also // reached interactive chat, where nothing interprets or strips the token @@ -1522,7 +1562,11 @@ export namespace SessionPrompt { const result = await processor.process({ user: lastUser, - agent, + // altimate_change start — pass the task-shape-scoped agent (see above): + // only `.prompt` may differ from `agent`, and only for a run-mode + // builder session confidently classified as having no dbt project. + agent: effectiveAgent, + // altimate_change end abort, sessionID, system, diff --git a/packages/opencode/test/altimate/sql-validation-e2e.test.ts b/packages/opencode/test/altimate/sql-validation-e2e.test.ts index c69c20421..e0e187bad 100644 --- a/packages/opencode/test/altimate/sql-validation-e2e.test.ts +++ b/packages/opencode/test/altimate/sql-validation-e2e.test.ts @@ -8,6 +8,7 @@ * 4. altimate_core_check composite pipeline works end-to-end * 5. sql.analyze composite pipeline works end-to-end * 6. Pre-execution protocol tools are callable (sql_analyze → altimate_core_validate → sql_execute) + * (the protocol TEXT itself is gated in session/pre-execution.ts, tested there) * 7. sql-classify correctly gates sql_execute * 8. Analyst and builder agent permissions are consistent with their prompts */ @@ -15,6 +16,7 @@ import { describe, expect, test, beforeAll, afterAll } from "bun:test" import fs from "fs" import path from "path" +import { SessionPreExecution } from "../../src/session/pre-execution" import * as Dispatcher from "../../src/altimate/native/dispatcher" import { registerAll } from "../../src/altimate/native/altimate-core" import { registerAllSql } from "../../src/altimate/native/sql/register" @@ -95,19 +97,42 @@ describe("Tool name consistency in prompts", () => { }) }) - test("builder prompt contains pre-execution protocol with correct tool names", async () => { + test("builder prompt names the pre-execution tools", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { const builder = await Agent.get("builder") expect(builder).toBeDefined() - // Pre-Execution Protocol references: expect(builder!.prompt).toContain("sql_analyze") expect(builder!.prompt).toContain("altimate_core_validate") expect(builder!.prompt).toContain("sql_execute") - // The protocol section itself + }, + }) + }) + + // The protocol section ships statically in the default builder profile (the + // `sql-guard` pack, assembled by altimate/prompts/profiles.ts) — every + // builder session, interactive or headless, gets it by default. It is + // scoped OUT per-session only by the gate in session/pre-execution.ts, which + // drops it only for headless builder runs in a workspace with no dbt + // project; see test/session/pre-execution.test.ts for that gate. + test("the pre-execution protocol reaches an interactive builder", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const builder = await Agent.get("builder") expect(builder!.prompt).toContain("Pre-Execution Protocol") + // Interactive (runMode: false) never gets an override — the default, + // unaltered agent prompt already carries the protocol. + const override = await SessionPreExecution.scopedBuilderPrompt({ + runMode: false, + agent: "builder", + prompt: builder!.prompt, + directories: [tmp.path], + }) + expect(override).toBeUndefined() }, }) }) diff --git a/packages/opencode/test/session/pre-execution.test.ts b/packages/opencode/test/session/pre-execution.test.ts new file mode 100644 index 000000000..ce4bc89cf --- /dev/null +++ b/packages/opencode/test/session/pre-execution.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import { SessionPreExecution } from "../../src/session/pre-execution" +import { PromptProfiles } from "../../src/altimate/prompts/profiles" + +/** + * `fs.symlink`'s `"dir"` type link requires elevated privileges (or Developer + * Mode) on Windows; `"junction"` does not and works for the absolute + * directory targets these tests use. Everywhere else `"dir"` is correct. + */ +const DIR_LINK_TYPE = process.platform === "win32" ? "junction" : "dir" + +describe("workspace classification", () => { + test("a dbt_project.yml at the root classifies as dbt", async () => { + await using dir = await tmpdir() + await fs.writeFile(path.join(dir.path, "dbt_project.yml"), "name: demo\n") + expect(await SessionPreExecution.classifyWorkspace([dir.path])).toBe("dbt") + }) + + // Benchmark and monorepo layouts nest the project one level down; this gate + // inherits `findDbtProjectRoot`'s rule and skip list, otherwise a real dbt + // task would be misread as question-answering. + test("a dbt_project.yml one level down still classifies as dbt", async () => { + await using dir = await tmpdir() + await fs.mkdir(path.join(dir.path, "warehouse")) + await fs.writeFile(path.join(dir.path, "warehouse", "dbt_project.yml"), "name: demo\n") + expect(await SessionPreExecution.classifyWorkspace([dir.path])).toBe("dbt") + }) + + // The load-bearing safety fix: `findDbtProjectRoot` (and the pre-rework + // version of this scan) only checked ONE level below the candidate. A real + // monorepo dbt project nested deeper — e.g. `repo/platform/analytics/ + // dbt_project.yml` when the candidate is `repo` — was silently classified + // `non-dbt`, dropping the mandatory protocol on actual dbt work. This test + // fails against the single-level scan and must keep passing against any + // future change to the downward walk. + test("a dbt_project.yml nested three levels down still classifies as dbt", async () => { + await using dir = await tmpdir() + const nested = path.join(dir.path, "platform", "analytics", "warehouse") + await fs.mkdir(nested, { recursive: true }) + await fs.writeFile(path.join(nested, "dbt_project.yml"), "name: demo\n") + expect(await SessionPreExecution.classifyWorkspace([dir.path])).toBe("dbt") + }) + + // A project past the downward scan bound is not found — but that must + // report `unknown` (keep the protocol), never a confident `non-dbt`, since + // the scan genuinely did not look that far. + test("a project past the downward scan bound is unknown, not non-dbt", async () => { + await using dir = await tmpdir() + const tooDeep = path.join(dir.path, "a", "b", "c", "d", "e", "f") + await fs.mkdir(tooDeep, { recursive: true }) + await fs.writeFile(path.join(tooDeep, "dbt_project.yml"), "name: demo\n") + expect(await SessionPreExecution.classifyWorkspace([dir.path])).toBe("unknown") + }) + + test("a readable directory with no dbt project classifies as non-dbt", async () => { + await using dir = await tmpdir() + await fs.writeFile(path.join(dir.path, "questions.duckdb"), "") + expect(await SessionPreExecution.classifyWorkspace([dir.path])).toBe("non-dbt") + }) + + // The two-directory case: the cwd may be a plain subfolder of a dbt project. + test("any readable candidate carrying a project wins", async () => { + await using root = await tmpdir() + await fs.writeFile(path.join(root.path, "dbt_project.yml"), "name: demo\n") + const cwd = path.join(root.path, "analyses") + await fs.mkdir(cwd) + expect(await SessionPreExecution.classifyWorkspace([cwd, root.path])).toBe("dbt") + }) + + // The load-bearing case. A scan that collapses "no project here" and "could + // not look here" into one answer silently drops the protocol whenever the + // filesystem misbehaves. + test("a directory that does not exist is unknown, never non-dbt", async () => { + await using dir = await tmpdir() + const missing = path.join(dir.path, "gone") + expect(await SessionPreExecution.classifyWorkspace([missing])).toBe("unknown") + }) + + test("no candidates at all is unknown", async () => { + expect(await SessionPreExecution.classifyWorkspace([])).toBe("unknown") + expect(await SessionPreExecution.classifyWorkspace([undefined, ""])).toBe("unknown") + }) + + // A non-git project sets worktree to the filesystem root. Scanning it is + // meaningless, and it must not count as the readable directory that licenses + // a non-dbt verdict on its own. + test("the filesystem root is not a candidate", async () => { + const root = path.parse(process.cwd()).root + expect(await SessionPreExecution.classifyWorkspace([root])).toBe("unknown") + }) + + // A directory named dbt_project.yml is not a dbt project. + test("a dbt_project.yml directory is not a project", async () => { + await using dir = await tmpdir() + await fs.mkdir(path.join(dir.path, "dbt_project.yml")) + expect(await SessionPreExecution.classifyWorkspace([dir.path])).toBe("non-dbt") + }) + + test("dbt_project.yaml counts as well as .yml", async () => { + await using dir = await tmpdir() + await fs.writeFile(path.join(dir.path, "dbt_project.yaml"), "name: demo\n") + expect(await SessionPreExecution.classifyWorkspace([dir.path])).toBe("dbt") + }) + + // Sessions are routinely started inside `models/` or deeper. On a non-git + // project the worktree candidate is the same directory, so the ancestor walk + // is the only thing that finds the project — without it a real dbt session + // classifies as non-dbt and loses the protocol. + test("a project above the candidate is found", async () => { + await using root = await tmpdir() + await fs.writeFile(path.join(root.path, "dbt_project.yml"), "name: demo\n") + const deep = path.join(root.path, "models", "marts", "finance") + await fs.mkdir(deep, { recursive: true }) + expect(await SessionPreExecution.classifyWorkspace([deep])).toBe("dbt") + }) + + // The walk runs to the filesystem root rather than stopping at a depth + // limit. A limit would have to report "I stopped early" as unknown to stay + // honest, which on any deep tree switches the gate off entirely. + test("the ancestor walk is not depth-limited", async () => { + await using root = await tmpdir() + await fs.writeFile(path.join(root.path, "dbt_project.yml"), "name: demo\n") + const deep = path.join(root.path, "a", "b", "c", "d", "e", "f", "g", "h", "i", "j") + await fs.mkdir(deep, { recursive: true }) + expect(await SessionPreExecution.classifyWorkspace([deep])).toBe("dbt") + }) + + // `path.resolve` is lexical. A symlinked cwd would walk the link's own + // parents and never see the project the session is actually inside. + test("a symlinked candidate is resolved before the walk", async () => { + await using project = await tmpdir() + await fs.writeFile(path.join(project.path, "dbt_project.yml"), "name: demo\n") + const inner = path.join(project.path, "models") + await fs.mkdir(inner) + await using elsewhere = await tmpdir() + const link = path.join(elsewhere.path, "ws") + await fs.symlink(inner, link, DIR_LINK_TYPE) + expect(await SessionPreExecution.classifyWorkspace([link])).toBe("dbt") + }) + + // Filtering children on isDirectory() would skip symlinked directories, and + // a skipped entry is an unexamined one. + test("a symlinked child project is found", async () => { + await using project = await tmpdir() + await fs.writeFile(path.join(project.path, "dbt_project.yml"), "name: demo\n") + await using workspace = await tmpdir() + await fs.symlink(project.path, path.join(workspace.path, "warehouse"), DIR_LINK_TYPE) + expect(await SessionPreExecution.classifyWorkspace([workspace.path])).toBe("dbt") + }) + + // One completely examined candidate settles it. Its ancestor walk already + // covers the worktree above it, so a partner that could not be read has + // nothing left to contribute — and vetoing on it would return unknown for + // every non-git project, where the worktree candidate is the filesystem root. + test("a complete candidate is not vetoed by a missing or root-only partner", async () => { + await using dir = await tmpdir() + expect(await SessionPreExecution.classifyWorkspace([dir.path, path.join(dir.path, "gone")])).toBe("non-dbt") + expect(await SessionPreExecution.classifyWorkspace([dir.path, path.parse(dir.path).root])).toBe("non-dbt") + }) + + // The genuinely PERMISSION-unreadable case — the previous test's "missing" + // and "root" partners are both readable-but-absent, which is a different + // failure mode from a directory that exists and cannot be opened. Both must + // fail the same way: the complete partner still wins. + test("a complete candidate is not vetoed by a genuinely unreadable partner", async () => { + await using dir = await tmpdir() + await using other = await tmpdir() + const locked = path.join(other.path, "locked") + await fs.mkdir(locked) + await fs.chmod(locked, 0o000) + try { + let enumerable = true + try { + await fs.readdir(locked) + } catch { + enumerable = false + } + // Running as root defeats the permission bit; the assertion is only + // meaningful when the mode actually blocks the read. + if (!enumerable) { + expect(await SessionPreExecution.classifyWorkspace([dir.path, locked])).toBe("non-dbt") + } + } finally { + await fs.chmod(locked, 0o755) + } + }) + + // The failure that matters most: a directory that stats fine but cannot be + // enumerated. Collapsing that into "no dbt project" would drop the protocol + // on a workspace nobody ever looked inside. + test("a directory that cannot be enumerated is unknown, not non-dbt", async () => { + await using dir = await tmpdir() + const locked = path.join(dir.path, "locked") + await fs.mkdir(locked) + await fs.chmod(locked, 0o000) + try { + // Running as root defeats the permission bit; the assertion is only + // meaningful when the mode actually blocks the read. + let enumerable = true + try { + await fs.readdir(locked) + } catch { + enumerable = false + } + if (enumerable) return + expect(await SessionPreExecution.classifyWorkspace([locked])).toBe("unknown") + } finally { + await fs.chmod(locked, 0o755) + } + }) +}) + +// The reworked mechanism (onto PR #1217's pack architecture): the protocol is +// the `sql-guard` pack, and it ships INSIDE the default builder profile +// unconditionally (see test/altimate/prompt-profiles.test.ts for the +// byte-identity pin). `scopedBuilderPrompt` no longer injects text — it +// returns a full prompt OVERRIDE (the profile with sql-guard excluded) for the +// one cell the ablation measured, or `undefined` everywhere else, meaning +// "use the agent's default `.prompt`, unchanged". +describe("pre-execution protocol gate", () => { + // The ONLY combination that drops the protocol is the one the ablation + // measured: headless, builder, stock default prompt, no dbt project. + test("headless builder in a non-dbt workspace returns the sql-guard-excluded override", async () => { + await using dir = await tmpdir() + const override = await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dir.path], + }) + // Would fail if the gate silently no-ops and returns the default profile. + expect(override).toBe(PromptProfiles.PROMPT_BUILDER_SCOPED) + expect(override).not.toContain("## Pre-Execution Protocol") + expect(override).not.toBe(PromptProfiles.PROMPT_BUILDER) + }) + + test("headless builder in a dbt workspace keeps the default (no override)", async () => { + await using dir = await tmpdir() + await fs.writeFile(path.join(dir.path, "dbt_project.yml"), "name: demo\n") + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dir.path], + }), + ).toBeUndefined() + }) + + // Interactive chat is a builder surface the ablation never covered, so it is + // unchanged from before this PR regardless of what the workspace looks like. + test("interactive builder always keeps the default (no override), dbt project or not", async () => { + await using dir = await tmpdir() + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: false, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dir.path], + }), + ).toBeUndefined() + }) + + // Ambiguity keeps the protocol. Wrongly dropping it has an unmeasured cost; + // wrongly keeping it costs latency on one workload. + test("an unclassifiable workspace keeps the default (no override)", async () => { + await using dir = await tmpdir() + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [path.join(dir.path, "does-not-exist")], + }), + ).toBeUndefined() + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [], + }), + ).toBeUndefined() + }) + + // Only the builder profile ever carried this pack, so overriding it for + // analyst or reviewer would be a behavior change, not a preserved one. + test("no other agent receives an override", async () => { + await using dir = await tmpdir() + for (const agent of ["analyst", "reviewer", "plan", "general"]) { + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: false, + agent, + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dir.path], + }), + ).toBeUndefined() + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent, + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dir.path], + }), + ).toBeUndefined() + } + }) + + // `agent` here must be the registry KEY, not `Info.name` — config can rename + // the builder agent's display name (`agent.builder.name`) while it stays + // registered under the `builder` key. Passing the STRING "renamed-builder" + // simulates the caller mistakenly keying on a display name. + test("a builder agent renamed via config is still recognized by its registry key", async () => { + await using dir = await tmpdir() + // Simulates prompt.ts passing `lastUser.agent` ("builder", the registry + // key) even though `agent.name` ("renamed-builder") differs. + const override = await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dir.path], + }) + expect(override).toBe(PromptProfiles.PROMPT_BUILDER_SCOPED) + // The converse: a custom agent merely DISPLAYED as "builder" (its + // registry key is something else) must never receive the override. + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "my-custom-agent", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dir.path], + }), + ).toBeUndefined() + }) + + // A customized builder prompt (config override or a markdown agent file) + // must never be silently discarded in favour of the stock scoped variant — + // the gate only ever touches the prompt when it is STILL exactly the + // default, unmodified `PROMPT_BUILDER`. + test("a customized builder prompt is never overridden, even when every other condition fires", async () => { + await using dir = await tmpdir() + const customPrompt = "You are a custom builder agent. Do custom things.\n" + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: customPrompt, + directories: [dir.path], + }), + ).toBeUndefined() + // Sanity: the identical scenario WITH the stock prompt does fire, so the + // above isn't passing because some other condition failed to hold. + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dir.path], + }), + ).toBe(PromptProfiles.PROMPT_BUILDER_SCOPED) + }) + + // Even the ALREADY-scoped prompt (e.g. a second pass, or a caller reusing a + // previous result as input) must not be treated as "the default" — only + // byte-identical `PROMPT_BUILDER` qualifies. + test("the scoped prompt itself does not count as the default", async () => { + await using dir = await tmpdir() + expect( + await SessionPreExecution.scopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER_SCOPED, + directories: [dir.path], + }), + ).toBeUndefined() + }) +}) + +describe("scoped-prompt cache (hot-path fix)", () => { + // `classifyWorkspace`'s filesystem walk must not re-run on every call once + // cached — proven here by giving the SECOND call a directory that would + // classify differently, and asserting it still returns the FIRST call's + // answer. If the cache silently no-ops (recomputes every call), this fails: + // the second call would legitimately see the dbt project and return + // `undefined` instead of the first call's override. + test("memoizes across calls — a differing second call is never actually consulted", async () => { + await using nonDbtDir = await tmpdir() + await using dbtDir = await tmpdir() + await fs.writeFile(path.join(dbtDir.path, "dbt_project.yml"), "name: demo\n") + + const getScopedBuilderPrompt = SessionPreExecution.createScopedBuilderPromptCache() + const first = await getScopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [nonDbtDir.path], + }) + const second = await getScopedBuilderPrompt({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dbtDir.path], + }) + expect(first).toBe(PromptProfiles.PROMPT_BUILDER_SCOPED) + expect(second).toBe(first) + }) + + // A fresh cache instance (a new loop() invocation, i.e. a new turn) must + // recompute independently — proving the memoization is per-instance, not a + // module-level cache that would go stale across turns. + test("a fresh cache instance recomputes independently of any prior instance", async () => { + await using dbtDir = await tmpdir() + await fs.writeFile(path.join(dbtDir.path, "dbt_project.yml"), "name: demo\n") + + const first = SessionPreExecution.createScopedBuilderPromptCache() + await first({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [dbtDir.path], + }) + + await using nonDbtDir = await tmpdir() + const second = SessionPreExecution.createScopedBuilderPromptCache() + const result = await second({ + runMode: true, + agent: "builder", + prompt: PromptProfiles.PROMPT_BUILDER, + directories: [nonDbtDir.path], + }) + expect(result).toBe(PromptProfiles.PROMPT_BUILDER_SCOPED) + }) +}) + +describe("prompt override composition", () => { + // The override must drop ONLY the sql-guard pack — every neighbouring pack + // (and the invariant core) must survive untouched, otherwise the gate is + // silently scoping more than the protocol. + test("PROMPT_BUILDER_SCOPED omits sql-guard and nothing else", () => { + expect(PromptProfiles.PROMPT_BUILDER_SCOPED).not.toContain("## Pre-Execution Protocol") + expect(PromptProfiles.PROMPT_BUILDER_SCOPED).not.toContain("This sequence is NOT optional.") + expect(PromptProfiles.PROMPT_BUILDER_SCOPED).toContain("## dbt Verification Workflow") + expect(PromptProfiles.PROMPT_BUILDER_SCOPED).toContain("## Finish Protocol") + expect(PromptProfiles.BUILDER_PROFILE_SCOPED).toEqual( + PromptProfiles.BUILDER_PROFILE.filter((name) => name !== "sql-guard"), + ) + }) + + // Selecting the override must never perturb the default byte-pinned + // profile — the two constants are independent, computed once at module load. + test("computing the override cannot change the default profile bytes", () => { + void PromptProfiles.PROMPT_BUILDER_SCOPED + expect(PromptProfiles.PROMPT_BUILDER).toContain("## Pre-Execution Protocol") + }) + + test("prompt assembly wires the gate to the run-mode flag, the registry key, the current prompt, and both directories", async () => { + const prompt = await Bun.file(new URL("../../src/session/prompt.ts", import.meta.url).pathname).text() + expect(prompt).toMatch( + /getScopedBuilderPrompt\(\{\s*runMode: Flag\.ALTIMATE_RUN_MODE,\s*agent: lastUser\.agent,\s*prompt: agent\.prompt,\s*directories: \[Instance\.directory, Instance\.worktree\],\s*\}\)/, + ) + // The cache must be created once per loop() invocation (before the + // while-loop that re-enters this call every step), not per step. + const cacheDeclAt = prompt.indexOf("const getScopedBuilderPrompt = SessionPreExecution.createScopedBuilderPromptCache()") + const whileLoopAt = prompt.indexOf("while (true) {") + expect(cacheDeclAt).toBeGreaterThan(-1) + expect(whileLoopAt).toBeGreaterThan(-1) + expect(cacheDeclAt).toBeLessThan(whileLoopAt) + // The override must actually reach the model call (via a cloned agent + // passed to processor.process), not just be computed and discarded. + expect(prompt).toMatch(/effectiveAgent = scopedBuilderPrompt \? \{ \.\.\.agent, prompt: scopedBuilderPrompt \} : agent/) + expect(prompt).toMatch(/agent: effectiveAgent,/) + }) +})