/** * Fix-generation stage for the self-improvement loop (T11889 · T11975). * * This is the missing autonomous link the v1 loop explicitly stubbed out * ("NO autonomous fix-gen in v1", see {@link "./run-loop.js"}): given a detected * regression (an open `selfimprove_dhq` + its envelope-diff) plus minimal repo * context, it asks the LLM to PROPOSE a unified-diff patch and writes that patch * to the path the draft-PR egress ({@link "./draft-pr.js".openDraftPr}) already * `existsSync`-checks: `/selfimprove-.patch`. Once the patch is on * disk the existing egress guard opens ONE DRAFT PR — closing the * DHQ → fix → draft-PR pipeline end-to-end. * * ## Hard safety posture (unchanged from v1 — P5 spec §B.7) * * - **LLM ONLY via the E9 chokepoint.** The real generator * ({@link createLlmFixGenerator}) resolves its model strictly through * {@link "../llm/system-resolver.js".resolveLLMForSystem} → {@link ModelRunner} * (Gate-13). It constructs NO raw provider client, reads NO `*_API_KEY` env, * and hardcodes no model id — the resolver/registry is the SSoT. The * plaintext token is materialized only at the wire boundary (E10). * - **Draft-PR-ONLY, never auto-merge.** This module produces a PATCH FILE only; * the egress that consumes it always opens a `--draft` PR against a feature * branch (never `main`). This module performs NO git/gh action. * - **Graceful degrade, never a rogue mutation.** Every failure (no credential, * LLM error, empty / non-unified-diff output) returns a typed * {@link FixGenResult} of kind `'skipped'` and writes NO patch file — so the * existing egress guard (`existsSync` on the patch path) simply skips the PR. * This function NEVER throws. * - **Injectable seam.** The LLM dependency is the {@link FixGenerator} port; * unit tests inject a deterministic fake that returns a canned patch, so NO * real LLM is reached in tests. * * CORE-first: this engine lives in `core` and owns the `FixGenerator` port TYPE. * Import-time side-effect-free. * * @module @cleocode/core/selfimprove/fix-gen * @epic T11889 * @task T11975 */ import type { Logger } from 'pino'; import type { DiffEntry } from './envelope-diff.js'; import { type LoadedFileContext, type LoadFileContextOptions } from './fix-gen-context.js'; /** * The structured request handed to a {@link FixGenerator}. Carries only what the * generator needs to draft a patch: the DHQ identity, the scenario, the * regression diff, and a minimal slice of repo context. */ export interface FixGenRequest { /** The stable `'DHQ-###'` handle of the open regression. */ readonly dhqId: string; /** The scenario whose replay diverged from the golden. */ readonly scenario: string; /** The idempotency hash tying this request to ONE open DHQ row. */ readonly questionHash: string; /** The structured regression entries (the evidence) from the envelope diff. */ readonly regressions: readonly DiffEntry[]; /** * Minimal repo context the generator may use to ground the patch (e.g. the * project root, a short description). Kept small on purpose — the loop does NOT * stuff the whole repo into the prompt. */ readonly repoContext: FixGenRepoContext; } /** Minimal repo context grounding a fix-generation request. */ export interface FixGenRepoContext { /** Absolute project root the patch is generated against. */ readonly projectRoot: string; /** Optional one-line summary of what the scenario exercises. */ readonly summary?: string; /** * Optional override for the file-context loading options (per-file budget, * total budget). When absent the defaults from {@link "./fix-gen-context.js"} * apply ({@link DEFAULT_PER_FILE_BUDGET} / {@link DEFAULT_TOTAL_BUDGET}). * * Setting `perFileBudget: 0` and `totalBudget: 0` effectively disables file * context injection (the prompt degrades to the regression-only view — the model * may still produce NO_PATCH, which is the honest outcome when context is * insufficient). * * @task T11988 */ readonly contextBudget?: Pick; } /** A generated patch, or an explicit "no usable patch" signal. */ export type FixGenOutput = { /** The generator produced a candidate unified-diff patch. */ readonly kind: 'patch'; /** The raw unified-diff text (validated by {@link generateFixPatch}). */ readonly diff: string; /** The model id that produced it (for audit). */ readonly model: string; } | { /** The generator produced no usable patch (degrade — never an error throw). */ readonly kind: 'none'; /** Machine-stable reason for the absence. */ readonly reason: string; /** * Credential-redacted, byte-bounded excerpt of the raw model reply (when the * reason is `'model-declined'`). Absent for failure modes that have no reply * (e.g. `'no-credential-resolved'`). Callers that log or persist this field * MUST NOT additionally redact — it is pre-sanitized by the generator. */ readonly rawReply?: string; }; /** * The injectable fix-generation port (the LLM seam). * * The real implementation ({@link createLlmFixGenerator}) drives the E9 chokepoint; * tests inject a deterministic fake returning a canned patch. A `FixGenerator` * MUST NOT throw — it encodes "no patch" as `{ kind: 'none', reason }`. */ export interface FixGenerator { /** * Propose a unified-diff patch for one regression. * * @param request - The structured {@link FixGenRequest}. * @returns A {@link FixGenOutput} — a candidate patch or an explicit "none". */ propose(request: FixGenRequest): Promise; } /** The terminal outcome of {@link generateFixPatch}. */ export type FixGenResult = { /** A validated patch was written to {@link FixGenResultWritten.patchPath}. */ readonly kind: 'written'; /** Absolute path the patch was written to. */ readonly patchPath: string; /** Byte length of the written patch. */ readonly bytes: number; /** The model id that produced the patch. */ readonly model: string; } | { /** No patch was written; the egress guard will skip the PR. */ readonly kind: 'skipped'; /** Machine-stable reason recorded on the DHQ evidence. */ readonly reason: string; /** * Credential-redacted, byte-bounded excerpt of the raw model reply for * `'model-declined'` and `'fixgen-not-a-diff'` outcomes. Absent for * generator errors and other failure modes that have no usable reply text. * Persisted on the DHQ evidence row so the loop operator can diagnose the * model's actual output without re-running. */ readonly replyExcerpt?: string; }; /** * Options for {@link generateFixPatch}. */ export interface GenerateFixPatchOptions { /** The structured fix-generation request. */ readonly request: FixGenRequest; /** The injected generator (real LLM impl or a test fake). */ readonly generator: FixGenerator; /** * Working directory the patch path is resolved against — MUST match the `cwd` * the downstream {@link "./draft-pr.js".openDraftPr} uses, so the file it writes * is the file the egress finds. */ readonly cwd?: string; /** Injectable logger (defaults to the module logger). */ readonly logger?: Logger; } /** * Compute the patch path the draft-PR egress expects for a scenario: * `/selfimprove-.patch`. The scenario is sanitized to a safe file * stem so a hostile scenario name cannot escape the cwd. * * @param scenario - The scenario name. * @param cwd - The working directory to resolve against (defaults to `process.cwd`). * @returns The absolute patch path. */ export declare function fixPatchPath(scenario: string, cwd?: string): string; /** * Is `text` a plausible unified diff? * * A real `git apply`-able patch always carries at least one of the canonical * unified-diff markers ({@link UNIFIED_DIFF_MARKERS}). An LLM that returns prose, * an apology, or an empty string fails this guard, so the loop degrades to "no * patch" rather than writing junk the egress would then fail to `git apply`. * * This is a cheap structural sniff, NOT a full parse — the authoritative * applicability check is `git apply` inside the egress (which fails the PR cut on * a bad patch, never `main`). * * @param text - Candidate patch text. * @returns `true` when the text looks like a unified diff. */ export declare function looksLikeUnifiedDiff(text: string): boolean; /** * Build the system + user prompt the real generator sends to the LLM. Pure * (no IO) so it is unit-testable in isolation when an explicit `fileContext` is * passed; the default path calls {@link loadFileContext} to resolve the relevant * source files for each regressing op-coordinate (IO-bearing path used in * production). * * The prompt is deliberately strict: "respond with ONLY a unified diff" — anything * else fails {@link looksLikeUnifiedDiff} downstream and degrades to "no patch". * When file context is available it is embedded between the regression description * and the "produce the diff" instruction so the model can locate the responsible * code. When context is absent (unmapped op or budget exhausted) the model is * explicitly told it may respond `NO_PATCH` — this keeps the prompt honest. * * @param request - The fix-generation request. * @param fileContext - Optional pre-loaded file context (injected for pure unit * tests). When absent, {@link loadFileContext} is called with the request's * `repoContext.projectRoot` and the op-coordinates from `regressions`. * @returns The `{ system, user }` prompt pair. * * @task T11988 */ export declare function buildFixGenPrompt(request: FixGenRequest, fileContext?: LoadedFileContext): { system: string; user: string; }; /** * Produce a credential-redacted, byte-bounded excerpt of a raw model reply for * safe logging and DHQ evidence attachment. * * The excerpt is at most {@link REPLY_EXCERPT_MAX_BYTES} UTF-8 bytes. When the * reply is longer a `…[truncated bytes]` marker replaces the tail so the * caller can distinguish a genuinely-short reply from a truncated one. The * returned string is already passed through {@link redact} so no downstream * scrubbing is needed. * * @param reply - The raw model reply text. * @returns A scrubbed, bounded excerpt string. */ export declare function truncateReply(reply: string): string; /** * Run the fix-generation stage: ask the injected {@link FixGenerator} for a patch, * validate it as a unified diff, and write it to the egress-expected patch path. * * This function NEVER throws and NEVER mutates anything but the single patch file. * On ANY degrade path — generator returns `'none'`, the generator throws, or the * returned text is not a unified diff — it writes NO file and returns * `{ kind: 'skipped', reason }`, so the downstream `existsSync` egress guard * simply skips the PR. The caller records the `reason` on the DHQ evidence. * * @param opts - See {@link GenerateFixPatchOptions}. * @returns A {@link FixGenResult}. * * @example * ```ts * const res = await generateFixPatch({ * request: { dhqId: 'DHQ-abcd1234', scenario, questionHash, regressions, repoContext }, * generator: createLlmFixGenerator(), * cwd: projectRoot, * }); * if (res.kind === 'written') { // openDraftPr will now find selfimprove-.patch } * ``` */ export declare function generateFixPatch(opts: GenerateFixPatchOptions): Promise; /** * Construct the REAL fix generator — the one that drives the E9 LLM chokepoint. * * Resolution funnels through {@link resolveLLMForSystem}(`'task-executor'`) → * {@link ModelRunner.build} → `session.send` (ONE non-streaming completion). * It constructs NO raw provider client, reads NO API-key env, and hardcodes no * model id (Gate-13). The plaintext credential is materialized ONLY at the * `ModelRunner` wire boundary (E10) via the sealed handle. When no credential is * reachable it returns `{ kind: 'none' }` — the loop degrades to "no patch", no * PR. This function constructs nothing at call time (lazy dynamic imports keep the * module import-time side-effect-free + the E9 deps off the hot import path). * * @param opts - Optional project root threaded into the resolver. * @returns A {@link FixGenerator} backed by the E9 chokepoint. * * @example * ```ts * const gen = createLlmFixGenerator({ projectRoot }); * const out = await gen.propose(request); // resolves model via E9, never raw client * ``` */ export declare function createLlmFixGenerator(opts?: { projectRoot?: string; }): FixGenerator; /** * Strip a single surrounding markdown code fence (```diff … ```), if present, so a * model that wraps its diff still yields an applyable patch. A bare diff passes * through untouched. * * @param text - Candidate patch text (already trimmed). * @returns The fence-stripped text. */ export declare function stripCodeFences(text: string): string; //# sourceMappingURL=fix-gen.d.ts.map