diff --git a/src/queue/processors.ts b/src/queue/processors.ts index e23601783b..c124aa770b 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -173,7 +173,7 @@ import type { CheckFailureDetail, MergeReadiness } from "../review/unified-comme import { buildIssueSlopAssessment, buildSlopAssessment, type SlopBand } from "../signals/slop"; import { runGittensoryAiSlopAdvisory } from "../services/ai-slop"; import { decidePublicSurface } from "../signals/settings-preview"; -import { buildFocusManifestGuidance } from "../signals/focus-manifest"; +import { buildFocusManifestGuidance, type ReviewProfile } from "../signals/focus-manifest"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { resolveRepositorySettings } from "../settings/repository-settings"; import type { LocalBranchAnalysisInput } from "../signals/local-branch"; @@ -2022,6 +2022,10 @@ export async function runAiReviewForAdvisory( // review + grounding + RAG use these instead of re-reading the stored rows — so a review that fired before // detail-sync still sees the REAL diff (FIX B). Omitted (e.g. unit tests) → fall back to the stored read. files?: Awaited> | undefined; + // `.gittensory.yml` review.profile (#review-profile), resolved by the caller from the (already-cached) + // manifest. Threaded in (not loaded here) so the AI review path makes no extra manifest fetch — absent ⇒ + // null ⇒ balanced ⇒ the reviewer prompt is byte-identical. + reviewProfile?: ReviewProfile | null | undefined; }, ): Promise<{ notes: string; reviewerCount: number } | undefined> { const packAllowsAnyAuthorBlockingReview = args.settings.gatePack === "oss-anti-slop" && args.settings.aiReviewMode === "block"; @@ -2087,6 +2091,7 @@ export async function runAiReviewForAdvisory( providerKey, grounding, ragContext, + profile: args.reviewProfile ?? null, }); if (result.status !== "ok") return undefined; if (result.consensusDefect) { @@ -2515,6 +2520,10 @@ async function maybePublishPrPublicSurface( // to keep gate-only and advisory-sweep repos free of an extra file resolve. const aiReviewWillRun = !webhook.skipAiReview && settings.aiReviewMode !== "off" && Boolean(advisory.headSha); if (aiReviewWillRun) { + // `.gittensory.yml` review.profile (#review-profile): resolve from the manifest (cached from settings + // resolution, so a cheap cache hit — no extra fetch) and thread it into the AI review so chill/assertive + // shapes the write-up. Absent ⇒ null ⇒ balanced ⇒ byte-identical prompt. Fail-safe to null on any read error. + const reviewProfile = (await loadRepoFocusManifest(env, repoFullName).catch(() => null))?.review.profile ?? null; aiReview = await runAiReviewForAdvisory(env, { settings, advisory, @@ -2523,6 +2532,7 @@ async function maybePublishPrPublicSurface( author, confirmedContributor, files: await getReviewFiles(), + reviewProfile, }); } diff --git a/src/services/ai-review.ts b/src/services/ai-review.ts index 48d43b65f6..f9cb391eb4 100644 --- a/src/services/ai-review.ts +++ b/src/services/ai-review.ts @@ -20,6 +20,7 @@ import { countByokAiEventsForRepoSince, recordAiUsageEvent, sumAiEstimatedNeuron import { sanitizePublicComment } from "../queue-intelligence"; import { defangReviewInput, isSafetyEnabled } from "../review/safety"; import { isConvergenceRepoAllowed } from "../review/cutover-gate"; +import type { ReviewProfile } from "../signals/focus-manifest"; /** * The best free Workers-AI model pair for review accuracy — two different families for independence, @@ -88,6 +89,13 @@ export type GittensoryAiReviewInput = { * to today — no section is appended. */ ragContext?: string | null | undefined; + /** + * `.gittensory.yml` `review.profile` (#review-profile): adjusts how nitpicky the maintainer review write-up is. + * `chill` → surface only blocking defects; `assertive` → also raise minor improvements & nits; absent/`balanced` + * → the reviewer prompt is byte-identical to today. PRESENTATION ONLY — it never changes the gate verdict (the + * consensus-defect pass still runs the same), just how much advisory detail the prose carries. + */ + profile?: ReviewProfile | null | undefined; }; /** A consensus critical defect, already public-safe, ready to become a gate blocker finding. */ @@ -254,11 +262,22 @@ function buildUserPrompt(input: GittensoryAiReviewInput): string { return lines.join("\n"); } +// `.gittensory.yml` review.profile → an appended tone instruction (#review-profile). `balanced`/absent appends +// nothing (byte-identical). PRESENTATION ONLY: it shapes how many nits the write-up surfaces, never the verdict. +const REVIEW_PROFILE_SUFFIX: Record<"chill" | "assertive", string> = { + chill: + "\n\nReview profile: CHILL. Report ONLY blocking, must-fix defects (bugs, security, data loss, breaking changes). Do NOT raise style preferences, naming, or minor nitpicks — omit them entirely.", + assertive: + "\n\nReview profile: ASSERTIVE. Beyond blocking defects, also surface minor improvements, style/consistency suggestions, and nitpicks — be thorough and exacting, clearly marking each non-blocking item as a nit.", +}; + /** The effective reviewer SYSTEM prompt. Appends the grounding-discipline suffix when the caller supplied one - * (flag GITTENSORY_REVIEW_GROUNDING on); absent/empty (default) → the base prompt, byte-identical to today. */ + * (flag GITTENSORY_REVIEW_GROUNDING on), then the `review.profile` tone suffix when set; both absent (default) + * → the base prompt, byte-identical to today. */ function buildSystemPrompt(input: GittensoryAiReviewInput): string { - const suffix = input.grounding?.systemSuffix; - return suffix ? `${REVIEW_SYSTEM_PROMPT}${suffix}` : REVIEW_SYSTEM_PROMPT; + const groundingSuffix = input.grounding?.systemSuffix ?? ""; + const profileSuffix = input.profile === "chill" || input.profile === "assertive" ? REVIEW_PROFILE_SUFFIX[input.profile] : ""; + return `${REVIEW_SYSTEM_PROMPT}${groundingSuffix}${profileSuffix}`; } /** One Workers-AI opinion with a per-slot reliable fallback and a 3× retry on the primary. */ diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index ee33682178..b0e1f69b7c 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -80,6 +80,13 @@ export type FocusManifestSettings = Partial< export const REVIEW_FIELD_KEYS = ["linkedIssue", "relatedWork", "reviewLoad", "validationEvidence", "openPrQueue", "contributorContext", "gateResult"] as const; export type ReviewFieldKey = (typeof REVIEW_FIELD_KEYS)[number]; +// `review.profile` (#review-profile): how nitpicky the AI maintainer review is. `chill` = surface only blocking +// defects (bugs/security/breakage), suppress style nits; `assertive` = also raise minor improvements & nits; +// `balanced` (default / absent) leaves the reviewer prompt byte-identical. A presentation knob only — it NEVER +// changes the gate verdict, only how much advisory detail the review write-up carries. +export const REVIEW_PROFILES = ["chill", "balanced", "assertive"] as const; +export type ReviewProfile = (typeof REVIEW_PROFILES)[number]; + /** * Maintainer overrides for the public review-panel CONTENT, declared under `review:`. Customizes the * panel without changing what gittensory measures: a custom public-safe footer lead line, a custom intro @@ -92,6 +99,8 @@ export type FocusManifestReviewConfig = { footerText: string | null; note: string | null; fields: Partial>; + /** `review.profile`: chill / balanced / assertive. null (absent) = balanced = byte-identical reviewer prompt. */ + profile: ReviewProfile | null; }; /** @@ -186,7 +195,7 @@ const EMPTY_MANIFEST: FocusManifest = { publicNotes: [], gate: { ...EMPTY_GATE_CONFIG }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {} }, + review: { present: false, footerText: null, note: null, fields: {}, profile: null }, warnings: [], }; @@ -199,7 +208,7 @@ export function isFocusManifestPublicSafe(text: string): boolean { } function emptyManifest(source: FocusManifestSource, warnings: string[] = []): FocusManifest { - return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {} } }; + return { ...EMPTY_MANIFEST, source, warnings, gate: { ...EMPTY_GATE_CONFIG }, settings: {}, review: { present: false, footerText: null, note: null, fields: {}, profile: null } }; } function normalizeStringList(value: JsonValue | undefined, field: string, warnings: string[]): string[] { @@ -477,7 +486,7 @@ function parsePublicSafeText(value: JsonValue | undefined, field: string, warnin * throws; invalid/unsafe values are dropped with warnings. */ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestReviewConfig { - const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {} }; + const empty: FocusManifestReviewConfig = { present: false, footerText: null, note: null, fields: {}, profile: null }; if (value === undefined || value === null) return empty; if (typeof value !== "object" || Array.isArray(value)) { warnings.push(`Manifest field "review" must be a mapping; ignoring it.`); @@ -497,7 +506,23 @@ function parseReviewConfig(value: JsonValue | undefined, warnings: string[]): Fo } const footerText = footerRecord ? parsePublicSafeText(footerRecord.text, "review.footer.text", warnings) : null; const note = parsePublicSafeText(r.note, "review.note", warnings); - return { present: footerText !== null || note !== null || Object.keys(fields).length > 0, footerText, note, fields }; + const profile = parseReviewProfile(r.profile, warnings); + return { present: footerText !== null || note !== null || profile !== null || Object.keys(fields).length > 0, footerText, note, fields, profile }; +} + +/** Parse `review.profile` — one of chill / balanced / assertive (case-insensitive). `balanced` normalizes to + * null (the default, so the reviewer prompt stays byte-identical). Any other value is ignored with a warning. */ +function parseReviewProfile(value: JsonValue | undefined, warnings: string[]): ReviewProfile | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + warnings.push(`Manifest "review.profile" must be a string (chill | balanced | assertive); ignoring it.`); + return null; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "balanced") return null; // default → no prompt change + if (normalized === "chill" || normalized === "assertive") return normalized; + warnings.push(`Manifest "review.profile" must be one of chill / balanced / assertive; ignoring "${value.slice(0, 32)}".`); + return null; } /** Serialize the review config for the cache round-trip; returns null when nothing is set. */ @@ -506,6 +531,7 @@ export function reviewConfigToJson(review: FocusManifestReviewConfig): JsonValue const out: Record = {}; if (review.footerText !== null) out.footer = { text: review.footerText }; if (review.note !== null) out.note = review.note; + if (review.profile !== null) out.profile = review.profile; if (Object.keys(review.fields).length > 0) out.fields = { ...review.fields } as Record; return out; } diff --git a/test/unit/ai-review.test.ts b/test/unit/ai-review.test.ts index 3b778e11b4..7c87679f16 100644 --- a/test/unit/ai-review.test.ts +++ b/test/unit/ai-review.test.ts @@ -123,6 +123,36 @@ describe("runGittensoryAiReview advisory mode", () => { }); }); +describe("review.profile shapes the reviewer system prompt (#review-profile)", () => { + const systemPromptOf = (run: ReturnType): string => ((run.mock.calls[0]?.[1] as { messages?: Array<{ content?: string }> })?.messages?.[0]?.content ?? ""); + const runProfile = async (profile: GittensoryAiReviewInput["profile"]) => { + const run = vi.fn(async () => ({ response: reviewJson() })); + const env = createTestEnv({ AI: { run } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000" }); + await runGittensoryAiReview(env, { ...baseInput, profile }); + return systemPromptOf(run); + }; + + it("chill appends the CHILL tone instruction (suppress nits)", async () => { + const system = await runProfile("chill"); + expect(system).toContain("CHILL"); + expect(system).not.toContain("ASSERTIVE"); + }); + + it("assertive appends the ASSERTIVE tone instruction (also raise nits)", async () => { + const system = await runProfile("assertive"); + expect(system).toContain("ASSERTIVE"); + expect(system).not.toContain("CHILL"); + }); + + it("absent / null profile leaves the prompt byte-identical (no profile suffix)", async () => { + const withNull = await runProfile(null); + const without = await runProfile(undefined); + expect(withNull).not.toMatch(/CHILL|ASSERTIVE/); + expect(without).not.toMatch(/CHILL|ASSERTIVE/); + expect(withNull).toBe(without); + }); +}); + describe("runGittensoryAiReview block mode (consensus)", () => { function envWith(run: (model: string) => Promise) { return createTestEnv({ AI: { run: vi.fn(run) } as unknown as Ai, AI_SUMMARIES_ENABLED: "true", AI_PUBLIC_COMMENTS_ENABLED: "true", AI_DAILY_NEURON_BUDGET: "100000" }); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index f0473de165..dae48d5604 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -428,7 +428,7 @@ describe("compileFocusManifestPolicy", () => { publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, selfAuthoredLinkedIssue: null, manifestPolicy: null, firstTimeContributorGrace: null }, settings: {}, - review: { present: false, footerText: null, note: null, fields: {} }, + review: { present: false, footerText: null, note: null, fields: {}, profile: null }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -1035,4 +1035,25 @@ describe("parseFocusManifest review config", () => { expect(reparsed.review).toEqual(original.review); expect(reviewConfigToJson(parseFocusManifest({}).review)).toBeNull(); }); + + it("parses review.profile (chill/assertive), normalizes balanced→null, and round-trips (#review-profile)", () => { + expect(parseFocusManifest({ review: { profile: "chill" } }).review.profile).toBe("chill"); + expect(parseFocusManifest({ review: { profile: "ASSERTIVE" } }).review.profile).toBe("assertive"); // case-insensitive + // `balanced` is the default → normalizes to null, and a balanced-only block is NOT "present". + expect(parseFocusManifest({ review: { profile: "balanced" } }).review.profile).toBeNull(); + expect(parseFocusManifest({ review: { profile: "balanced" } }).review.present).toBe(false); + // A profile-only manifest IS present and survives the reviewConfigToJson round-trip. + const chill = parseFocusManifest({ review: { profile: "chill" } }); + expect(chill.review.present).toBe(true); + expect(parseFocusManifest({ review: reviewConfigToJson(chill.review) }).review).toEqual(chill.review); + }); + + it("ignores an invalid review.profile with a warning", () => { + const m = parseFocusManifest({ review: { profile: "spicy" } }); + expect(m.review.profile).toBeNull(); + expect(m.warnings.some((w) => /review\.profile.*chill.*balanced.*assertive/.test(w))).toBe(true); + const m2 = parseFocusManifest({ review: { profile: 42 } }); + expect(m2.review.profile).toBeNull(); + expect(m2.warnings.some((w) => /review\.profile.*must be a string/.test(w))).toBe(true); + }); }); diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index f6dfcb9448..615dff0298 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -904,7 +904,7 @@ describe("signal coverage edge cases", () => { collisions: buildCollisionReport(directRepo.fullName, [], [currentPr]), preflight: buildPreflightResult({ repoFullName: directRepo.fullName, title: "Fix isolated issue", body: "Fixes #99", linkedIssues: [99] }, directRepo, [], [currentPr]), settings: gateSettings, - review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false } }, + review: { present: true, footerText: "Reviewed by the Acme maintainer bot.", note: "Run npm test before pushing.", fields: { relatedWork: false }, profile: null }, aiReview: { notes: "The change is focused.\n\n**Suggestions**\n- Add a test for the edge case." }, }); expect(customizedComment).toContain("Reviewed by the Acme maintainer bot."); // custom footer lead