From 6aac22ae1daf7d0aabcf0696f668eb626f9d7b39 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Sat, 15 Aug 2026 17:25:30 +0200 Subject: [PATCH] feat(runtime): explicit dev-binary override with forward decoder support --- extensions/gentle-ai.ts | 84 ++++- lib/gentle-ai-binary.ts | 169 ++++++++- lib/native-review-cli.ts | 55 ++- lib/review-integration-v2.ts | 294 +++++++++++++-- runtime/gentle-ai-binary.mjs | 169 ++++++++- runtime/native-review-cli.mjs | 55 ++- runtime/review-integration-v2.mjs | 294 +++++++++++++-- .../devbinary/capabilities-v2.1.derived.json | 331 +++++++++++++++++ .../devbinary/capabilities-v2.2.captured.json | 340 ++++++++++++++++++ .../devbinary/status-v5.captured.json | 88 +++++ tests/gentle-ai-dev-binary-surfacing.test.ts | 195 ++++++++++ tests/gentle-ai-dev-binary.test.ts | 336 +++++++++++++++++ tests/review-integration-v2-forward.test.ts | 276 ++++++++++++++ 13 files changed, 2625 insertions(+), 61 deletions(-) create mode 100644 tests/fixtures/devbinary/capabilities-v2.1.derived.json create mode 100644 tests/fixtures/devbinary/capabilities-v2.2.captured.json create mode 100644 tests/fixtures/devbinary/status-v5.captured.json create mode 100644 tests/gentle-ai-dev-binary-surfacing.test.ts create mode 100644 tests/gentle-ai-dev-binary.test.ts create mode 100644 tests/review-integration-v2-forward.test.ts diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index d19eab5e5..eab0d8661 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -107,8 +107,16 @@ import { } from "../lib/review-snapshot.ts"; import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts"; import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts"; +import { + GentleAiDevBinaryOverrideError, + registerGentleAiDevBinary, + resolveGentleAiDevBinaryOverride, + unregisterGentleAiDevBinary, + type GentleAiDevBinaryOverride, +} from "../lib/gentle-ai-binary.ts"; import { createNativeReviewCli, + createNodeExecFileAdapter, isCanonicalProcessString, nativeReviewAbandonAuthorization, nativeReviewLegacyAliasRepairAuthorization, @@ -6513,6 +6521,15 @@ function createGentleAiExtensionForTesting( } pi.on("session_start", async (_event, ctx) => { + // Loud, every session: an active dev-binary override means this session + // runs an unpinned gentle-ai. Announce which one before anything else. + try { + const devBinary = await describeDevBinaryOverride(); + if (ctx.hasUI && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning"); + if (ctx.hasUI && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error"); + } catch (error) { + if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning"); + } try { const transactionRecovery = reconcileCommitTransaction(ctx.cwd); if (ctx.hasUI && transactionRecovery.status !== "clean") { @@ -6825,6 +6842,66 @@ function createGentleAiExtensionForTesting( }, }); + // Dev-binary override surfacing (unpinned field-test mode). While the + // override is active every diagnostic surface names the exact binary, its + // live version, and its fresh content digest, so the maintainer always + // knows which gentle-ai actually answered. An invalid override surfaces as + // a failure — it is never silently ignored, because the native resolver + // refuses to fall back to the pin while an override is declared. + const describeDevBinaryOverride = async (): Promise< + | { state: "inactive" } + | { state: "active"; line: string; override: GentleAiDevBinaryOverride } + | { state: "invalid"; line: string } + > => { + let override: GentleAiDevBinaryOverride | undefined; + try { + override = resolveGentleAiDevBinaryOverride(); + } catch (error) { + if (error instanceof GentleAiDevBinaryOverrideError) return { state: "invalid", line: `Gentle AI dev binary override invalid — ${error.message}` }; + throw error; + } + if (override === undefined) return { state: "inactive" }; + let version = "version unavailable"; + try { + const adapter = createNodeExecFileAdapter(); + const result = await adapter({ file: override.path, arguments: ["version"], cwd: dirname(override.path), timeoutMs: 10_000, maxBufferBytes: 1024 * 1024 }); + const banner = result.stdout.trim(); + if (result.exitCode === 0 && banner.startsWith("gentle-ai ")) version = banner.slice("gentle-ai ".length); + } catch { + // The doctor line still names the binary; the version stays unavailable. + } + return { + state: "active", + override, + line: `Gentle AI dev binary override active (unpinned, field-test only): ${override.path} ${version} sha256:${override.sha256.slice(0, 16)}`, + }; + }; + + pi.registerCommand("gentle:dev-binary", { + description: "Register, inspect, or clear the persistent Gentle AI dev-binary override (status | | off). Unpinned, field-test only.", + handler: async (args, ctx) => { + const argument = args.trim(); + try { + if (argument === "off") { + const removed = unregisterGentleAiDevBinary(); + ctx.ui.notify(removed ? "Gentle AI dev binary registration removed; the pinned binary is active again." : "No dev binary registration to remove.", "info"); + return; + } + if (argument === "" || argument === "status") { + const described = await describeDevBinaryOverride(); + if (described.state === "inactive") ctx.ui.notify("No dev binary override; the pinned Gentle AI binary is active.", "info"); + else ctx.ui.notify(described.line, described.state === "active" ? "warning" : "error"); + return; + } + registerGentleAiDevBinary(argument); + const described = await describeDevBinaryOverride(); + ctx.ui.notify(described.state === "inactive" ? "Dev binary registration written." : described.line, "warning"); + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); + } + }, + }); + pi.registerCommand("gentle:doctor", { description: "Run read-only Gentle AI diagnostics for this Pi workspace.", handler: async (_args, ctx) => { @@ -6844,6 +6921,7 @@ function createGentleAiExtensionForTesting( const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd); const modelConfig = await readSavedModelConfigAsync(ctx.cwd); const engramActive = hasWritableEngramTool(pi); + const devBinary = await describeDevBinaryOverride(); const lines = [ "el Gentleman doctor", `${agentsInstalled ? "pass" : "fail"}: Global SDD agents ${agentsInstalled ? "installed" : "missing"}`, @@ -6855,6 +6933,8 @@ function createGentleAiExtensionForTesting( `${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`, "pass: Sensitive-path guard active for read/write/edit tools", `${engramActive ? "pass" : "warn"}: Engram memory tools ${engramActive ? "active" : "not active in this session"}`, + ...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []), + ...(devBinary.state === "invalid" ? [`fail: ${devBinary.line}`, "remedy: fix the dev binary override or clear it with /gentle:dev-binary off (or unset GENTLE_PI_GENTLE_AI_DEV_BINARY)"] : []), ]; if (!agentsInstalled || !chainsInstalled) { lines.push("remedy: run /gentle:install-sdd --force to refresh global SDD assets intentionally"); @@ -6931,9 +7011,11 @@ function createGentleAiExtensionForTesting( const staleSddAssets = sddGlobalAssetDriftCount(); const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd); const modelConfig = await readModelConfigAsync(ctx.cwd); + const devBinary = await describeDevBinaryOverride(); ctx.ui.notify( [ "el Gentleman package is active.", + ...(devBinary.state === "inactive" ? [] : [devBinary.line]), `Persona: ${readPersonaMode(ctx.cwd)}`, `Global SDD agents: ${agentsInstalled ? "installed" : "not installed"}`, `Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`, @@ -6951,7 +7033,7 @@ function createGentleAiExtensionForTesting( `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`, ...describeModelConfig(ctx.cwd, modelConfig), ].join("\n"), - staleSddAssets > 0 || localSddAgentOverrides > 0 ? "warning" : "info", + staleSddAssets > 0 || localSddAgentOverrides > 0 || devBinary.state !== "inactive" ? "warning" : "info", ); }, }); diff --git a/lib/gentle-ai-binary.ts b/lib/gentle-ai-binary.ts index fbdb8b0cb..fe13d03c1 100644 --- a/lib/gentle-ai-binary.ts +++ b/lib/gentle-ai-binary.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; -import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { GENTLE_AI_INSTALL_METHOD, @@ -46,6 +47,165 @@ function sha256(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); } +// --------------------------------------------------------------------------- +// Dev-binary override — the maintainer field-test lane. +// +// Two explicit activation paths, in precedence order: +// 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then +// 2. the persistent registration file at +// /dev-binary.json with the strict +// shape {"schema":"gentle-pi.dev-binary/v1","path":""}. +// +// The registration deliberately pins no digest: it is the unpinned field-test +// mode, and the binary at that path changes on every rebuild. Every resolution +// re-validates the file and recomputes the sha256, so a rebuilt binary is +// followed automatically with a fresh digest and no re-registration. +// +// Guardrails per resolution: absolute path, regular non-symlink file, POSIX +// executable. Any failure — including a malformed registration document or a +// registered-but-missing binary — is a typed error naming its origin, never a +// silent fallback to the pinned binary: silently running the pin while the +// maintainer believes he is field-testing main is the worst possible outcome. +// With neither activation path present, the pinned supply-chain resolution +// below stays byte-identical. +// --------------------------------------------------------------------------- + +export const GENTLE_AI_DEV_BINARY_ENV = "GENTLE_PI_GENTLE_AI_DEV_BINARY"; +export const GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA = "gentle-pi.dev-binary/v1"; +export const GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE = "dev-binary-override-invalid"; + +export interface GentleAiDevBinaryEnvironment { + env: Record; + home: string; +} + +export interface GentleAiDevBinaryOverride { + source: "env" | "registration"; + /** The env var name or registration file path that selected this binary. */ + origin: string; + path: string; + sha256: string; +} + +export class GentleAiDevBinaryOverrideError extends Error { + readonly code = GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE; + readonly source: "env" | "registration"; + readonly origin: string; + constructor(source: "env" | "registration", origin: string, reason: string) { + super(`${GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE}: ${origin} ${reason}. Fix or remove the override; the pinned binary is never used silently while an override is declared.`); + this.name = "GentleAiDevBinaryOverrideError"; + this.source = source; + this.origin = origin; + } +} + +let devBinaryEnvironmentTestingOverlay: GentleAiDevBinaryEnvironment | undefined; + +/** Testing-only environment overlay; production code never calls this. */ +export function setGentleAiDevBinaryEnvironmentForTesting(environment: GentleAiDevBinaryEnvironment | undefined): void { + devBinaryEnvironmentTestingOverlay = environment; +} + +function ambientDevBinaryEnvironment(): GentleAiDevBinaryEnvironment { + return devBinaryEnvironmentTestingOverlay ?? { env: process.env, home: homedir() }; +} + +export function gentleAiDevBinaryRegistrationPath(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): string { + const configHome = environment.env.GENTLE_PI_CONFIG_HOME ?? join(environment.home, ".pi", "gentle-ai"); + return join(configHome, "dev-binary.json"); +} + +function validateDevBinary(source: "env" | "registration", origin: string, path: string, platform: string): GentleAiDevBinaryOverride { + if (typeof path !== "string" || path.length === 0) throw new GentleAiDevBinaryOverrideError(source, origin, "declares an empty dev binary path"); + if (!isAbsolute(path)) throw new GentleAiDevBinaryOverrideError(source, origin, `must name an absolute path, received "${path}"`); + let details: ReturnType; + try { + details = lstatSync(path); + } catch { + throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which does not exist`); + } + if (!details.isFile() || details.isSymbolicLink()) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a regular non-symlink file`); + if (platform !== "win32" && (details.mode & 0o111) === 0) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a POSIX executable`); + let digest: string; + try { + digest = sha256(readFileSync(path)); + } catch { + throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which could not be read`); + } + return { source, origin, path, sha256: digest }; +} + +function readDevBinaryRegistration(registrationPath: string): string { + let contents: string; + try { + contents = readFileSync(registrationPath, "utf8"); + } catch { + throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "could not be read"); + } + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch { + throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "is not valid JSON"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must be a JSON object"); + const record = parsed as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== 2 || keys[0] !== "path" || keys[1] !== "schema") throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must carry exactly the keys "schema" and "path"`); + if (record.schema !== GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must declare schema ${GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA}`); + if (typeof record.path !== "string" || record.path.length === 0) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must declare a non-empty string path"); + return record.path; +} + +/** + * Resolves the active dev-binary override, if any. Returns undefined only when + * neither activation path is present; a present-but-invalid override always + * throws a typed GentleAiDevBinaryOverrideError naming its origin. + */ +export function resolveGentleAiDevBinaryOverride( + environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(), + platform = process.platform, +): GentleAiDevBinaryOverride | undefined { + const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV]; + if (envValue !== undefined && envValue.length > 0) return validateDevBinary("env", GENTLE_AI_DEV_BINARY_ENV, envValue, platform); + const registrationPath = gentleAiDevBinaryRegistrationPath(environment); + if (!existsSync(registrationPath)) return undefined; + return validateDevBinary("registration", registrationPath, readDevBinaryRegistration(registrationPath), platform); +} + +/** + * Cheap presence probe: is a dev-binary override declared at all? Used by the + * native CLI to select the unpinned version gate without hashing the binary. + * Declared-but-invalid still counts as configured — the resolution path will + * fail loudly with the typed error instead of quietly using the pin. + */ +export function gentleAiDevBinaryOverrideConfigured(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): boolean { + const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV]; + if (envValue !== undefined && envValue.length > 0) return true; + return existsSync(gentleAiDevBinaryRegistrationPath(environment)); +} + +/** Validates and persistently registers a dev binary; returns the fresh override. */ +export function registerGentleAiDevBinary( + path: string, + environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(), + platform = process.platform, +): { registrationPath: string; override: GentleAiDevBinaryOverride } { + const registrationPath = gentleAiDevBinaryRegistrationPath(environment); + const validated = validateDevBinary("registration", registrationPath, path, platform); + mkdirSync(dirname(registrationPath), { recursive: true }); + writeFileSync(registrationPath, `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path })}\n`); + return { registrationPath, override: validated }; +} + +/** Deletes the persistent registration; returns whether one existed. */ +export function unregisterGentleAiDevBinary(environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment()): boolean { + const registrationPath = gentleAiDevBinaryRegistrationPath(environment); + if (!existsSync(registrationPath)) return false; + rmSync(registrationPath); + return true; +} + function isConfined(path: string, directory: string): boolean { const relativePath = relative(directory, path); return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); @@ -110,7 +270,14 @@ export function resolveGentleAiBinary( packageRoot = dirname(dirname(fileURLToPath(import.meta.url))), platform = process.platform, readBinary: (path: string) => Buffer = readFileSync, + environment: GentleAiDevBinaryEnvironment = ambientDevBinaryEnvironment(), ): string { + // The explicit dev-binary override wins over the pinned supply-chain path. + // Its typed errors propagate: a declared override never falls back to the + // pin. Without a declared override this call returns undefined and the + // pinned resolution below is byte-identical to the pre-override behavior. + const override = resolveGentleAiDevBinaryOverride(environment, platform); + if (override !== undefined) return override.path; const binaryPath = gentleAiBinaryPath(packageRoot, platform); const versionDirectory = dirname(binaryPath); const manifestPath = join(versionDirectory, "integrity.json"); diff --git a/lib/native-review-cli.ts b/lib/native-review-cli.ts index c4ce78fe1..0b3cd1a49 100644 --- a/lib/native-review-cli.ts +++ b/lib/native-review-cli.ts @@ -5,7 +5,7 @@ import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, posix, win32 } from "node:path"; import { promisify } from "node:util"; -import { GENTLE_AI_VERSION, PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.ts"; +import { GENTLE_AI_VERSION, PackageLocalGentleAiBinaryMissingError, gentleAiDevBinaryOverrideConfigured, resolveGentleAiBinary } from "./gentle-ai-binary.ts"; import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts"; import { REVIEW_INTEGRATION_CONTRACT, @@ -228,6 +228,22 @@ function stderrIsTolerated(stderr: string, tolerated: readonly string[]): boolea return lines.length > 0 && lines.every((line) => tolerated.includes(line)); } +// gentle-ai main narrates the negotiated STATUS forecast head to a human on +// stderr while keeping the machine envelope on stdout (reviewNarrateForecast +// in internal/cli/review_narration.go, ground-truthed live against a main-line +// dev build). The pinned release does not emit it, so this narration is +// tolerated only for negotiated STATUS while the dev-binary override is +// configured; any other stderr keeps failing closed. +const FORECAST_NARRATION_LINES = Object.freeze([ + /^Forecast horizon: (?:partial|terminal)$/, + /^step [0-9]+: (?:execute|collect|stop); reason_code=[a-z0-9_]+; description=.+$/, + /^Re-query STATUS after completing this partial head\.$/, +]); +function stderrIsForecastNarration(stderr: string): boolean { + const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); + return lines.length > 0 && lines.every((line) => FORECAST_NARRATION_LINES.some((pattern) => pattern.test(line))); +} + export const NATIVE_REVIEW_RECOVER_DISPOSITION = ["scope_changed", "invalidated", "escalated"] as const; export type NativeReviewRecoverDisposition = (typeof NATIVE_REVIEW_RECOVER_DISPOSITION)[number]; @@ -750,6 +766,21 @@ function resolvedNativeCliContract(version: string): Record | undefined>)[version]; } +// The latest known contract row, used as the capability floor for the +// explicit dev-binary override (unpinned field-test mode): a maintainer +// dev build is at least as capable as the newest release Pi knows. The table +// is declared in ascending release order, so its last key is the latest. +const LATEST_NATIVE_CLI_CONTRACT_VERSION = Object.keys(NATIVE_CLI_CONTRACTS).at(-1) as keyof typeof NATIVE_CLI_CONTRACTS; + +// Dev-binary override version discipline: with the override configured, a +// version reported outside the pinned table resolves to the latest known row; +// with the override absent, behavior is byte-identical to the pinned gate. +function effectiveNativeCliContract(version: string): Record | undefined { + const pinned = resolvedNativeCliContract(version); + if (pinned !== undefined) return pinned; + return gentleAiDevBinaryOverrideConfigured() ? NATIVE_CLI_CONTRACTS[LATEST_NATIVE_CLI_CONTRACT_VERSION] : undefined; +} + export interface NativeReviewStructuredDenial { schema: "gentle-ai.review-gate-result/v1"; result: "scope-changed" | "invalidated" | "escalated"; @@ -1330,8 +1361,14 @@ export class NativeReviewCliV214 { if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, NATIVE_REVIEW_OPERATION.VERSION, false, "version process timed out", result); if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, NATIVE_REVIEW_OPERATION.VERSION, false, "version process was signalled", result); if (result.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.VERSION, false, "version process failed", result); - const version = /^gentle-ai ([0-9]+\.[0-9]+\.[0-9]+)\n$/.exec(result.stdout.replace(/\r\n$/, "\n"))?.[1]; - const contract = version === undefined ? undefined : resolvedNativeCliContract(version); + const normalized = result.stdout.replace(/\r\n$/, "\n"); + const pinnedVersion = /^gentle-ai ([0-9]+\.[0-9]+\.[0-9]+)\n$/.exec(normalized)?.[1]; + // Dev-binary override (unpinned field-test mode): accept the binary's + // reported version even when it is not a pinned three-part release + // version (a main-line dev build reports e.g. "2.4.0-rc.8+fix..."). + // The banner shape itself stays strict. Pinned mode is byte-identical. + const version = pinnedVersion ?? (gentleAiDevBinaryOverrideConfigured() ? /^gentle-ai (\S+)\n$/.exec(normalized)?.[1] : undefined); + const contract = version === undefined ? undefined : effectiveNativeCliContract(version); if (result.stderr.trim().length > 0 || contract === undefined || capabilities.some((capability) => !contract[capability])) throw nativeError(NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `native gentle-ai lacks required capabilities: expected v${GENTLE_AI_VERSION}, found v${version ?? "unparseable"}`); return version as keyof typeof NATIVE_CLI_CONTRACTS; } @@ -1342,7 +1379,7 @@ export class NativeReviewCliV214 { if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement"); if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef"); const version = await this.verifyVersion(request.cwd, request.signal, ["start"]); - const toleratedStderr = resolvedNativeCliContract(version)?.mode === true ? REVIEW_CONSENT_NOTICES : []; + const toleratedStderr = effectiveNativeCliContract(version)?.mode === true ? REVIEW_CONSENT_NOTICES : []; const { body: result } = await this.execute(NATIVE_REVIEW_OPERATION.START, request.cwd, ["review", "start", "--cwd", request.cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...(request.lineageId ? ["--lineage", request.lineageId] : []), ...(request.policyPath ? ["--policy", request.policyPath] : []), ...(request.focus ? ["--focus", request.focus] : [])], true, request.signal, toleratedStderr); return decode(NATIVE_REVIEW_OPERATION.START, true, () => { // `target_identity` and `lens_bindings` are real, unconditionally-present @@ -2060,7 +2097,8 @@ export class NativeReviewCliV216 implements NativeReviewCli { throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native negotiated operation failed without a valid failure envelope", result); } } - if (result.stderr.trim().length > 0 && !stderrIsTolerated(result.stderr, toleratedStderr)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); + const forecastNarrationTolerated = operation === NATIVE_REVIEW_OPERATION.STATUS && gentleAiDevBinaryOverrideConfigured() && stderrIsForecastNarration(result.stderr); + if (result.stderr.trim().length > 0 && !stderrIsTolerated(result.stderr, toleratedStderr) && !forecastNarrationTolerated) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); return { body, exitCode: result.exitCode }; } @@ -2089,7 +2127,12 @@ export class NativeReviewCliV216 implements NativeReviewCli { if (error instanceof NativeReviewCliError) throw error; throw nativeError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `expected gentle-ai v${GENTLE_AI_VERSION}; the installed runtime is incompatible — reinstall gentle-pi`); } - if (capabilities.packageVersion !== GENTLE_AI_VERSION) { + // Dev-binary override (unpinned field-test mode): the session accepts + // the binary's self-reported package version instead of the exact pin. + // The full capabilities decode above still applies unchanged, so an + // incompatible envelope is refused either way. Pinned mode keeps the + // exact equality byte-identical. + if (capabilities.packageVersion !== GENTLE_AI_VERSION && !gentleAiDevBinaryOverrideConfigured()) { throw nativeError(NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `expected gentle-ai v${GENTLE_AI_VERSION}, provider reported v${capabilities.packageVersion}`); } return capabilities; diff --git a/lib/review-integration-v2.ts b/lib/review-integration-v2.ts index 8f6ec9078..5c940dbf1 100644 --- a/lib/review-integration-v2.ts +++ b/lib/review-integration-v2.ts @@ -95,21 +95,24 @@ const RECEIPT_STATUSES = ["expected_missing", "present", "publication_pending", const REQUIRED_OPERATIONS = Object.freeze(Object.values(REVIEW_INTEGRATION_OPERATION)); const REQUIRED_GATES = Object.freeze(["post-apply", "pre-commit", "pre-push", "pre-pr", "release"] as const); const REQUIRED_PROJECTIONS = Object.freeze(Object.values(REVIEW_PROJECTION)); -const REQUIRED_SCHEMAS = Object.freeze([ +// The schema floor shared by every accepted capabilities identity. Each minor +// then adds its own capabilities/consent/status identities below: the provider +// swaps those three advertisements as the protocol minor advances (v2.1 moved +// consent to v3; v2.2 moved status to v5), ground-truthed against the vendored +// capabilities[-v2.1|-v2.2].schema.json contracts on gentle-ai main and a live +// v2.2 capture from a main-line dev build. +const REQUIRED_SCHEMAS_COMMON = Object.freeze([ "gentle-ai.review-admitted-result/v2", "gentle-ai.review-artifact-subject/v2", "gentle-ai.review-authority-repair-assessment/v1", "gentle-ai.review-authority-status/v1", "gentle-ai.review-gate-request/v1", - "gentle-ai.review-integration.capabilities/v2", - "gentle-ai.review-integration.consent/v2", "gentle-ai.review-integration.failure/v2", "gentle-ai.review-final-verification-incident/v1", "gentle-ai.review-integration.operation/v2", "gentle-ai.review-integration.projection/v1", "gentle-ai.review-integration.repair/v2", "gentle-ai.review-integration.start/v3", - "gentle-ai.review-integration.status/v3", "gentle-ai.review-receipt/v1", "gentle-ai.review-receipt/v2", "gentle-ai.review-result-artifact/v2", @@ -119,6 +122,20 @@ const REQUIRED_SCHEMAS = Object.freeze([ "https://gentle-ai.dev/schema/review/reviewer/v1", "https://gentle-ai.dev/schema/review/validator/v1", ] as const); +const CAPABILITIES_SCHEMA_IDENTITIES: Readonly> = Object.freeze({ + "gentle-ai.review-integration.capabilities/v2": Object.freeze({ + protocolMinor: 0, + requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON, "gentle-ai.review-integration.capabilities/v2", "gentle-ai.review-integration.consent/v2", "gentle-ai.review-integration.status/v3"]), + }), + "gentle-ai.review-integration.capabilities/v2.1": Object.freeze({ + protocolMinor: 1, + requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON, "gentle-ai.review-integration.capabilities/v2.1", "gentle-ai.review-integration.consent/v3", "gentle-ai.review-integration.status/v3"]), + }), + "gentle-ai.review-integration.capabilities/v2.2": Object.freeze({ + protocolMinor: 2, + requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON, "gentle-ai.review-integration.capabilities/v2.2", "gentle-ai.review-integration.consent/v3", "gentle-ai.review-integration.status/v5"]), + }), +}); const OPTIONAL_FEATURE_NAMES = Object.freeze([ "base_ref_workspace_overlay", "bounded_process_waits", @@ -341,6 +358,72 @@ export const REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION = { export type ReviewProviderRoleCaptureOperation = (typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION)[keyof typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION]; export const REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS = Object.freeze(Object.values(REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION)); +// status/v5 structural surfaces. Pi has no consumer for these yet; they are +// decoded strictly (exact key sets per the vendored status-v5.schema.json and +// correction-plan-request.schema.json on gentle-ai main) and carried through +// so a v5 provider payload is never rejected for being newer than v3. +export interface ReviewForecastStepV1 { + step: 1; + kind: "execute" | "collect" | "stop"; + reasonCode: string; + description: string; +} + +export interface ReviewForecastV1 { + horizon: "partial" | "terminal"; + steps: readonly ReviewForecastStepV1[]; +} + +export interface ReviewProviderTaskV1 { + agent: "review-refuter" | "review-validator"; + role: "refuter" | "targeted-validator"; + prompt: string; +} + +export interface ReviewSubmissionDescriptorValueV5 { + slot: string; + domain: string; + substitutionLocation: number; + schema?: string; + minimum?: number; + maximum?: number; + allowedValues?: readonly string[]; +} + +// The two v5 Go-owned submission descriptor forms (finalize carries one +// singular `value`; capture-evidence carries an outcome/input `values` pair). +// Distinct from the host-relay ReviewCaptureSubmissionV1 form, which stays +// exactly as it is. +export interface ReviewSubmissionDescriptorV5 { + operationToken: "finalize" | "capture-evidence"; + argumentTokens: readonly string[]; + value?: ReviewSubmissionDescriptorValueV5; + values?: readonly ReviewSubmissionDescriptorValueV5[]; +} + +export interface ReviewCorrectionPlanFindingV1 { + id: string; + lens: "risk" | "resilience" | "readability" | "reliability"; + location: string; + severity: "BLOCKER" | "CRITICAL"; + claim: string; + proofRefs: readonly string[]; + evidence: string; + evidenceClass: "deterministic" | "inferential"; + causalDisposition: "introduced" | "behavior-activated" | "worsened"; +} + +export interface ReviewCorrectionPlanRequestV1 { + schema: "gentle-ai.review-correction-plan-request/v1"; + requestHash: string; + lineageId: string; + expectedRevision: string; + targetIdentity: string; + correctionBudget: number; + fixFindingIds: readonly string[]; + findings: readonly ReviewCorrectionPlanFindingV1[]; +} + export interface ReviewCollectInputV3 { name: string; schema: string; @@ -352,6 +435,10 @@ export interface ReviewCollectInputV3 { changedPathManifest?: readonly ChangedPathEntry[]; validationRequest?: ReviewTargetedValidationRequestV1; submission?: ReviewCaptureSubmissionV1; + /** status/v5 only: the finalize/capture-evidence descriptor forms. */ + submissionDescriptor?: ReviewSubmissionDescriptorV5; + /** status/v5 only: the self-contained external.run_provider_role task. */ + providerTask?: ReviewProviderTaskV1; } export interface ReviewNextTransitionV3 { @@ -359,6 +446,8 @@ export interface ReviewNextTransitionV3 { reasonCode: string; execute?: ReviewNextTransitionExecuteV3; collect?: { inputs: readonly ReviewCollectInputV3[] }; + /** status/v5 only: the bounded correction plan request. */ + correctionRequest?: ReviewCorrectionPlanRequestV1; } export interface ReviewTargetedValidationRequestV1 { @@ -403,6 +492,8 @@ export interface ReviewStatusV3 { nextTransition?: ReviewNextTransitionV3; validationRequest?: ReviewTargetedValidationRequestV1; finalVerificationRetry?: ReviewFinalVerificationRetryV1; + /** status/v5 only: the descriptive, non-routing transition preview. */ + forecast?: ReviewForecastV1; raw: Readonly>; } @@ -719,10 +810,15 @@ function decodeOptionalFeature(value: unknown, label: string): { name: string; s export function decodeReviewCapabilitiesV2(value: unknown, verifiedExecutableDigest: string): ReviewCapabilitiesV2 { const requiredFields = ["schema", "contract", "protocol", "package", "build", "executable", "operations", "gates", "projections", "schemas", "features", "compatibility"] as const; const body = exactRecord(value, "capabilities", requiredFields, ["bootstrap"]); - requireIdentity(body, "gentle-ai.review-integration.capabilities/v2"); + // Additive minor acceptance: each accepted schema identity keeps its own + // exact protocol minor and required-schema floor; v2 stays exactly as it + // was, and an unknown identity is rejected before anything else. + const identity = CAPABILITIES_SCHEMA_IDENTITIES[typeof body.schema === "string" ? body.schema : ""]; + if (identity === undefined) throw new TypeError(`schema must be one of ${Object.keys(CAPABILITIES_SCHEMA_IDENTITIES).join(", ")}`); + requireIdentity(body, body.schema as string); const protocol = exactRecord(body.protocol, "capabilities.protocol", ["major", "minor"]); - if (protocol.major !== 2 || protocol.minor !== 0) throw new TypeError("incompatible review integration protocol"); + if (protocol.major !== 2 || protocol.minor !== identity.protocolMinor) throw new TypeError("incompatible review integration protocol"); const packageIdentity = exactRecord(body.package, "capabilities.package", ["name", "version", "release_channel"]); if (packageIdentity.name !== "gentle-ai") throw new TypeError("capabilities package identity mismatch"); @@ -749,11 +845,11 @@ export function decodeReviewCapabilitiesV2(value: unknown, verifiedExecutableDig // unknown addition is not rejected before assertSupersetOf can even run. const advertisedGates = stringArray(body.gates, "capabilities.gates", { minimum: REQUIRED_GATES.length, unique: true }); const advertisedProjections = stringArray(body.projections, "capabilities.projections", { minimum: REQUIRED_PROJECTIONS.length, unique: true }); - const advertisedSchemas = stringArray(body.schemas, "capabilities.schemas", { minimum: REQUIRED_SCHEMAS.length, unique: true }); + const advertisedSchemas = stringArray(body.schemas, "capabilities.schemas", { minimum: identity.requiredSchemas.length, unique: true }); assertSupersetOf(advertisedOperations, REQUIRED_OPERATIONS, "capabilities operations"); assertSupersetOf(advertisedGates, REQUIRED_GATES, "capabilities gates"); assertSupersetOf(advertisedProjections, REQUIRED_PROJECTIONS, "capabilities projections"); - assertSupersetOf(advertisedSchemas, REQUIRED_SCHEMAS, "capabilities schemas"); + assertSupersetOf(advertisedSchemas, identity.requiredSchemas, "capabilities schemas"); const features = exactRecord(body.features, "capabilities.features", ["mandatory", "optional"]); const mandatory = array(features.mandatory, "capabilities.features.mandatory", (entry, label) => decodeFeature(entry, label), { minimum: 10 }); @@ -801,7 +897,7 @@ export function decodeReviewCapabilitiesV2(value: unknown, verifiedExecutableDig operations: new Set(REQUIRED_OPERATIONS), gates: new Set(REQUIRED_GATES), projections: new Set(REQUIRED_PROJECTIONS), - schemas: new Set(REQUIRED_SCHEMAS), + schemas: new Set(identity.requiredSchemas), mandatoryFeatures: new Set(mandatoryNames), optionalFeatures: new Set(optional.filter((feature) => feature.supported && (FEATURE_NAMES as readonly string[]).includes(feature.name)).map((feature) => feature.name)), raw: body, @@ -1092,8 +1188,107 @@ function decodeCaptureSubmission(value: unknown, label: string): ReviewCaptureSu return { operationToken, argumentTokens, values }; } -function decodeCollectInput(value: unknown, label: string): ReviewCollectInputV3 { - const input = exactRecord(value, label, ["name", "schema", "capture_operation", "arguments"], ["artifact_subject", "base_tree", "candidate_tree", "changed_path_manifest", "validation_request", "submission"]); +// v5-only structural decoders, exact to the vendored status-v5.schema.json +// and correction-plan-request.schema.json on gentle-ai main. + +function decodeProviderTask(value: unknown, label: string): ReviewProviderTaskV1 { + const task = exactRecord(value, label, ["agent", "role", "prompt"]); + return { + agent: enumeration(task.agent, ["review-refuter", "review-validator"] as const, `${label}.agent`), + role: enumeration(task.role, ["refuter", "targeted-validator"] as const, `${label}.role`), + prompt: nonempty(task.prompt, `${label}.prompt`), + }; +} + +function decodeSubmissionDescriptorV5(value: unknown, label: string, operationToken: "finalize" | "capture-evidence"): ReviewSubmissionDescriptorV5 { + if (operationToken === "finalize") { + const descriptor = exactRecord(value, label, ["operation_token", "argument_tokens", "value"]); + const argumentTokens = stringArray(descriptor.argument_tokens, `${label}.argument_tokens`, { minimum: 7 }); + const source = exactRecord(descriptor.value, `${label}.value`, ["slot", "domain", "substitution_location"], ["schema", "minimum", "maximum"]); + const slot = enumeration(source.slot, ["correction_lines", "validation"] as const, `${label}.value.slot`); + return { + operationToken, + argumentTokens, + value: { + slot, + domain: nonempty(source.domain, `${label}.value.domain`), + substitutionLocation: integer(source.substitution_location, `${label}.value.substitution_location`, 0, argumentTokens.length - 1), + ...(source.schema === undefined ? {} : { schema: nonempty(source.schema, `${label}.value.schema`) }), + ...(source.minimum === undefined ? {} : { minimum: integer(source.minimum, `${label}.value.minimum`, 1, 200) }), + ...(source.maximum === undefined ? {} : { maximum: integer(source.maximum, `${label}.value.maximum`, 1, 200) }), + }, + }; + } + const descriptor = exactRecord(value, label, ["operation_token", "argument_tokens", "values"]); + const argumentTokens = stringArray(descriptor.argument_tokens, `${label}.argument_tokens`, { minimum: 6, maximum: 6 }); + const values = array(descriptor.values, `${label}.values`, (entry, entryLabel) => { + const source = exactRecord(entry, entryLabel, ["slot", "domain", "substitution_location"], ["schema", "allowed_values"]); + return { + slot: nonempty(source.slot, `${entryLabel}.slot`), + domain: nonempty(source.domain, `${entryLabel}.domain`), + substitutionLocation: integer(source.substitution_location, `${entryLabel}.substitution_location`, 0, argumentTokens.length - 1), + ...(source.schema === undefined ? {} : { schema: nonempty(source.schema, `${entryLabel}.schema`) }), + ...(source.allowed_values === undefined ? {} : { allowedValues: stringArray(source.allowed_values, `${entryLabel}.allowed_values`, { minimum: 1, unique: true }) }), + }; + }, { minimum: 2, maximum: 2 }); + return { operationToken, argumentTokens, values }; +} + +function decodeCorrectionPlanRequestV1(value: unknown, label: string): ReviewCorrectionPlanRequestV1 { + const body = exactRecord(value, label, ["schema", "request_hash", "lineage_id", "expected_revision", "target_identity", "correction_budget", "fix_finding_ids", "findings"]); + if (body.schema !== "gentle-ai.review-correction-plan-request/v1") throw new TypeError(`${label}.schema must be gentle-ai.review-correction-plan-request/v1`); + return { + schema: "gentle-ai.review-correction-plan-request/v1", + requestHash: sha256(body.request_hash, `${label}.request_hash`), + lineageId: lineage(body.lineage_id, `${label}.lineage_id`), + expectedRevision: sha256(body.expected_revision, `${label}.expected_revision`), + targetIdentity: sha256(body.target_identity, `${label}.target_identity`), + correctionBudget: integer(body.correction_budget, `${label}.correction_budget`, 1, 200), + fixFindingIds: stringArray(body.fix_finding_ids, `${label}.fix_finding_ids`, { minimum: 1, unique: true }), + findings: array(body.findings, `${label}.findings`, (entry, entryLabel) => { + const finding = exactRecord(entry, entryLabel, ["id", "lens", "location", "severity", "claim", "proof_refs", "evidence", "evidence_class", "causal_disposition"]); + return { + id: nonempty(finding.id, `${entryLabel}.id`), + lens: enumeration(finding.lens, ["risk", "resilience", "readability", "reliability"] as const, `${entryLabel}.lens`), + location: nonempty(finding.location, `${entryLabel}.location`), + severity: enumeration(finding.severity, ["BLOCKER", "CRITICAL"] as const, `${entryLabel}.severity`), + claim: nonempty(finding.claim, `${entryLabel}.claim`), + proofRefs: stringArray(finding.proof_refs, `${entryLabel}.proof_refs`, { minimum: 1 }), + evidence: nonempty(finding.evidence, `${entryLabel}.evidence`), + evidenceClass: enumeration(finding.evidence_class, ["deterministic", "inferential"] as const, `${entryLabel}.evidence_class`), + causalDisposition: enumeration(finding.causal_disposition, ["introduced", "behavior-activated", "worsened"] as const, `${entryLabel}.causal_disposition`), + }; + }, { minimum: 1 }), + }; +} + +export function decodeReviewForecastV1(value: unknown, label = "status.forecast"): ReviewForecastV1 { + const body = exactRecord(value, label, ["horizon", "steps"]); + const horizon = enumeration(body.horizon, ["partial", "terminal"] as const, `${label}.horizon`); + const steps = array(body.steps, `${label}.steps`, (entry, entryLabel) => { + const step = exactRecord(entry, entryLabel, ["step", "kind", "reason_code", "description"]); + if (step.step !== 1) throw new TypeError(`${entryLabel}.step must be 1`); + return { + step: 1 as const, + kind: enumeration(step.kind, ["execute", "collect", "stop"] as const, `${entryLabel}.kind`), + reasonCode: text(step.reason_code, `${entryLabel}.reason_code`, { minimum: 1, pattern: /^[a-z0-9_]+$/ }), + description: nonempty(step.description, `${entryLabel}.description`), + }; + }, { minimum: 1, maximum: 1 }); + // The published horizon-to-step invariant: a stop head is terminal, any + // other head is partial. + if ((horizon === "terminal") !== (steps[0]!.kind === "stop")) throw new TypeError(`${label}.horizon does not match its step kind`); + return { horizon, steps }; +} + +// The two v5 reason codes whose transitions must carry the correction plan +// request; every other reason code must not. +const CORRECTION_REQUEST_REASON_CODES = Object.freeze(["correction_plan_required", "corrected_candidate_unavailable"] as const); +// v5 capture operations that must carry a submission descriptor. +const V5_SUBMISSION_CAPTURE_OPERATIONS = Object.freeze(["external.plan_correction", "external.run_targeted_validation", "review.capture-evidence"] as const); + +function decodeCollectInput(value: unknown, label: string, v5: boolean): ReviewCollectInputV3 { + const input = exactRecord(value, label, ["name", "schema", "capture_operation", "arguments"], ["artifact_subject", "base_tree", "candidate_tree", "changed_path_manifest", "validation_request", "submission", ...(v5 ? ["provider_task"] : [])]); const name = text(input.name, `${label}.name`, { minimum: 1, pattern: /^[a-z0-9_]+$/ }); const schema = nonempty(input.schema, `${label}.schema`); const captureOperation = nonempty(input.capture_operation, `${label}.capture_operation`); @@ -1108,6 +1303,22 @@ function decodeCollectInput(value: unknown, label: string): ReviewCollectInputV3 throw new TypeError(`${label}.validation_request is only valid for external.run_targeted_validation or review.capture-validation`); } + // v5-only surfaces (vendored status-v5.schema.json): the provider role task + // rides exactly the external.run_provider_role vector, and the finalize/ + // capture-evidence submission descriptor forms ride their own vectors. + if (v5) { + if (captureOperation === "external.run_provider_role") { + if (input.provider_task === undefined) throw new TypeError(`${label}.provider_task is required for external.run_provider_role`); + } else if (input.provider_task !== undefined) { + throw new TypeError(`${label}.provider_task is only valid for external.run_provider_role`); + } + if ((V5_SUBMISSION_CAPTURE_OPERATIONS as readonly string[]).includes(captureOperation)) { + if (input.submission === undefined) throw new TypeError(`${label}.submission is required for ${captureOperation}`); + if (captureOperation === "external.plan_correction" && schema !== "gentle-ai.review-correction-plan/v1") throw new TypeError(`${label}.schema must be gentle-ai.review-correction-plan/v1`); + if (captureOperation === "review.capture-evidence" && schema !== "https://gentle-ai.dev/schema/review/verification-evidence/v1") throw new TypeError(`${label}.schema must be https://gentle-ai.dev/schema/review/verification-evidence/v1`); + } + } + // gentle-pi#311 P4-roles: the two Go-owned non-lens provider role capture // operations render SELF-CONTAINED authority-advancing vectors. Executing // the exact rendered tokens makes Go materialize the role prompt, run its @@ -1133,8 +1344,22 @@ function decodeCollectInput(value: unknown, label: string): ReviewCollectInputV3 } else if (input.artifact_subject !== undefined || input.base_tree !== undefined || input.candidate_tree !== undefined || input.changed_path_manifest !== undefined) { throw new TypeError(`${label} carries capture-result fields without review.capture-result`); } - if (input.submission !== undefined && captureOperation !== "review.capture-result") { - throw new TypeError(`${label}.submission is only valid for review.capture-result`); + const submissionOperations: readonly string[] = v5 ? ["review.capture-result", ...V5_SUBMISSION_CAPTURE_OPERATIONS] : ["review.capture-result"]; + if (input.submission !== undefined && !submissionOperations.includes(captureOperation)) { + throw new TypeError(v5 ? `${label}.submission is only valid for ${submissionOperations.join(", ")}` : `${label}.submission is only valid for review.capture-result`); + } + + // The v5 descriptor forms are discriminated by their closed operation + // tokens; every other submission stays the host-relay completing form. + let submission: ReviewCaptureSubmissionV1 | undefined; + let submissionDescriptor: ReviewSubmissionDescriptorV5 | undefined; + if (input.submission !== undefined) { + const operationToken = (input.submission as Record | null)?.operation_token; + if (v5 && (operationToken === "finalize" || operationToken === "capture-evidence")) { + submissionDescriptor = decodeSubmissionDescriptorV5(input.submission, `${label}.submission`, operationToken); + } else { + submission = decodeCaptureSubmission(input.submission, `${label}.submission`); + } } return { @@ -1147,15 +1372,28 @@ function decodeCollectInput(value: unknown, label: string): ReviewCollectInputV3 ...(input.candidate_tree === undefined ? {} : { candidateTree: gitTree(input.candidate_tree, `${label}.candidate_tree`) }), ...(input.changed_path_manifest === undefined ? {} : { changedPathManifest: array(input.changed_path_manifest, `${label}.changed_path_manifest`, decodeChangedPathEntry, { unique: true }) }), ...(input.validation_request === undefined ? {} : { validationRequest: decodeTargetedValidationRequestV1(input.validation_request, `${label}.validation_request`) }), - ...(input.submission === undefined ? {} : { submission: decodeCaptureSubmission(input.submission, `${label}.submission`) }), + ...(submission === undefined ? {} : { submission }), + ...(submissionDescriptor === undefined ? {} : { submissionDescriptor }), + ...(v5 && input.provider_task !== undefined ? { providerTask: decodeProviderTask(input.provider_task, `${label}.provider_task`) } : {}), }; } -export function decodeReviewNextTransitionV3(value: unknown): ReviewNextTransitionV3 { - const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect"]); +export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boolean } = {}): ReviewNextTransitionV3 { + const v5 = options.v5 === true; + const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : [])]); const kind = enumeration(transition.kind, ["execute", "collect", "stop"] as const, "next_transition.kind"); const reasonCode = text(transition.reason_code, "next_transition.reason_code", { minimum: 1, pattern: /^[a-z0-9_]+$/ }); + // status/v5: the bounded correction plan request rides exactly its two + // reason codes and never any other (vendored status-v5.schema.json). + let correctionRequest: ReviewCorrectionPlanRequestV1 | undefined; + if (v5) { + const required = (CORRECTION_REQUEST_REASON_CODES as readonly string[]).includes(reasonCode); + if (required && transition.correction_request === undefined) throw new TypeError(`next_transition.correction_request is required for ${reasonCode}`); + if (!required && transition.correction_request !== undefined) throw new TypeError(`next_transition.correction_request is only valid for ${CORRECTION_REQUEST_REASON_CODES.join(", ")}`); + if (transition.correction_request !== undefined) correctionRequest = decodeCorrectionPlanRequestV1(transition.correction_request, "next_transition.correction_request"); + } + if (kind === "execute") { // `command` is an optional, ready-to-paste rendering of `arguments` (the // exact same binding as a single shell-ready string) — observed live @@ -1182,16 +1420,16 @@ export function decodeReviewNextTransitionV3(value: unknown): ReviewNextTransiti const revision = binding.revision === undefined ? undefined : sha256(binding.revision, "next_transition.execute.binding.revision"); const command = execute.command === undefined ? undefined : nonempty(execute.command, "next_transition.execute.command"); if (transition.collect !== undefined) throw new TypeError("next_transition.collect is incompatible with execute"); - return { kind, reasonCode, execute: { operation, arguments: argumentsList, preconditions, binding: { targetIdentity, ...(lineageId === undefined ? {} : { lineageId }), ...(revision === undefined ? {} : { revision }) }, ...(command === undefined ? {} : { command }) } }; + return { kind, reasonCode, execute: { operation, arguments: argumentsList, preconditions, binding: { targetIdentity, ...(lineageId === undefined ? {} : { lineageId }), ...(revision === undefined ? {} : { revision }) }, ...(command === undefined ? {} : { command }) }, ...(correctionRequest === undefined ? {} : { correctionRequest }) }; } if (kind === "collect") { const collect = exactRecord(transition.collect, "next_transition.collect", ["inputs"]); - const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label), { minimum: 1 }); + const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label, v5), { minimum: 1 }); if (transition.execute !== undefined) throw new TypeError("next_transition.execute is incompatible with collect"); - return { kind, reasonCode, collect: { inputs } }; + return { kind, reasonCode, collect: { inputs }, ...(correctionRequest === undefined ? {} : { correctionRequest }) }; } if (transition.execute !== undefined || transition.collect !== undefined) throw new TypeError("next_transition stop cannot carry a transition"); - return { kind, reasonCode }; + return { kind, reasonCode, ...(correctionRequest === undefined ? {} : { correctionRequest }) }; } // --------------------------------------------------------------------------- @@ -1251,10 +1489,15 @@ function decodeFinalVerificationRetry(value: unknown, label: string): ReviewFina // --------------------------------------------------------------------------- export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 { + // Additive forward acceptance: status/v5 (gentle-ai main; ground-truthed + // against a live capture and the vendored status-v5.schema.json) is the v3 + // key set plus the optional forecast and the v5-only next_transition + // surfaces. The v3 identity keeps rejecting every v5-only field. + const v5 = typeof value === "object" && value !== null && (value as Record).schema === "gentle-ai.review-integration.status/v5"; const body = exactRecord(value, "status", [ "schema", "contract", "operation", "applicability", "receipt", "action", "replayability", "target_identity", "projection", "repair", "candidates", - ], ["authority", "frozen", "reconciliation", "action_disposition", "eligibility", "next_transition", "authority_target_identity", "validation_request", "final_verification_retry"]); - requireIdentity(body, "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS); + ], ["authority", "frozen", "reconciliation", "action_disposition", "eligibility", "next_transition", "authority_target_identity", "validation_request", "final_verification_retry", ...(v5 ? ["forecast"] : [])]); + requireIdentity(body, v5 ? "gentle-ai.review-integration.status/v5" : "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS); const applicability = enumeration(body.applicability, ["current_target", "unrelated", "ambiguous", "corrupted"] as const, "status.applicability"); const receiptBody = exactRecord(body.receipt, "status.receipt", ["status"], ["identity"]); @@ -1295,7 +1538,11 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 { if ((action === "recover" || action === "retry_final_verification") && actionDisposition === undefined) throw new TypeError(`${action} status requires action_disposition`); if (action !== "recover" && action !== "retry_final_verification" && actionDisposition !== undefined) throw new TypeError("status.action_disposition is only valid for the recover or retry_final_verification action"); if (body.eligibility !== undefined) decodeEligibility(body.eligibility, "status.eligibility"); - const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition); + const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition, { v5 }); + // status/v5 dependentRequired: a forecast previews the transition head, so + // it can only accompany an actual next_transition. + const forecast = v5 && body.forecast !== undefined ? decodeReviewForecastV1(body.forecast) : undefined; + if (forecast !== undefined && nextTransition === undefined) throw new TypeError("status.forecast requires status.next_transition"); const replayability = enumeration(body.replayability, Object.values(REVIEW_REPLAYABILITY), "status.replayability"); let reconciliation: ReviewStatusReconciliationV1 | undefined; @@ -1348,6 +1595,7 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 { ...(nextTransition === undefined ? {} : { nextTransition }), ...(validationRequest === undefined ? {} : { validationRequest }), ...(finalVerificationRetry === undefined ? {} : { finalVerificationRetry }), + ...(forecast === undefined ? {} : { forecast }), raw: body, }; } diff --git a/runtime/gentle-ai-binary.mjs b/runtime/gentle-ai-binary.mjs index 0a91ccd4b..2137f71b9 100644 --- a/runtime/gentle-ai-binary.mjs +++ b/runtime/gentle-ai-binary.mjs @@ -1,6 +1,7 @@ // Generated by scripts/build-git-commit-transaction-runner.mjs. Do not edit. import { createHash } from "node:crypto"; -import { existsSync, lstatSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { GENTLE_AI_INSTALL_METHOD, @@ -47,6 +48,165 @@ function sha256(value ) { return createHash("sha256").update(value).digest("hex"); } +// --------------------------------------------------------------------------- +// Dev-binary override — the maintainer field-test lane. +// +// Two explicit activation paths, in precedence order: +// 1. GENTLE_PI_GENTLE_AI_DEV_BINARY (session override, absolute path), then +// 2. the persistent registration file at +// /dev-binary.json with the strict +// shape {"schema":"gentle-pi.dev-binary/v1","path":""}. +// +// The registration deliberately pins no digest: it is the unpinned field-test +// mode, and the binary at that path changes on every rebuild. Every resolution +// re-validates the file and recomputes the sha256, so a rebuilt binary is +// followed automatically with a fresh digest and no re-registration. +// +// Guardrails per resolution: absolute path, regular non-symlink file, POSIX +// executable. Any failure — including a malformed registration document or a +// registered-but-missing binary — is a typed error naming its origin, never a +// silent fallback to the pinned binary: silently running the pin while the +// maintainer believes he is field-testing main is the worst possible outcome. +// With neither activation path present, the pinned supply-chain resolution +// below stays byte-identical. +// --------------------------------------------------------------------------- + +export const GENTLE_AI_DEV_BINARY_ENV = "GENTLE_PI_GENTLE_AI_DEV_BINARY"; +export const GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA = "gentle-pi.dev-binary/v1"; +export const GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE = "dev-binary-override-invalid"; + + + + + + + + + + + + + + +export class GentleAiDevBinaryOverrideError extends Error { + code = GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE; + source ; + origin ; + constructor(source , origin , reason ) { + super(`${GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE}: ${origin} ${reason}. Fix or remove the override; the pinned binary is never used silently while an override is declared.`); + this.name = "GentleAiDevBinaryOverrideError"; + this.source = source; + this.origin = origin; + } +} + +let devBinaryEnvironmentTestingOverlay ; + +/** Testing-only environment overlay; production code never calls this. */ +export function setGentleAiDevBinaryEnvironmentForTesting(environment ) { + devBinaryEnvironmentTestingOverlay = environment; +} + +function ambientDevBinaryEnvironment() { + return devBinaryEnvironmentTestingOverlay ?? { env: process.env, home: homedir() }; +} + +export function gentleAiDevBinaryRegistrationPath(environment = ambientDevBinaryEnvironment()) { + const configHome = environment.env.GENTLE_PI_CONFIG_HOME ?? join(environment.home, ".pi", "gentle-ai"); + return join(configHome, "dev-binary.json"); +} + +function validateDevBinary(source , origin , path , platform ) { + if (typeof path !== "string" || path.length === 0) throw new GentleAiDevBinaryOverrideError(source, origin, "declares an empty dev binary path"); + if (!isAbsolute(path)) throw new GentleAiDevBinaryOverrideError(source, origin, `must name an absolute path, received "${path}"`); + let details ; + try { + details = lstatSync(path); + } catch { + throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which does not exist`); + } + if (!details.isFile() || details.isSymbolicLink()) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a regular non-symlink file`); + if (platform !== "win32" && (details.mode & 0o111) === 0) throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which is not a POSIX executable`); + let digest ; + try { + digest = sha256(readFileSync(path)); + } catch { + throw new GentleAiDevBinaryOverrideError(source, origin, `names "${path}", which could not be read`); + } + return { source, origin, path, sha256: digest }; +} + +function readDevBinaryRegistration(registrationPath ) { + let contents ; + try { + contents = readFileSync(registrationPath, "utf8"); + } catch { + throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "could not be read"); + } + let parsed ; + try { + parsed = JSON.parse(contents); + } catch { + throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "is not valid JSON"); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must be a JSON object"); + const record = parsed ; + const keys = Object.keys(record).sort(); + if (keys.length !== 2 || keys[0] !== "path" || keys[1] !== "schema") throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must carry exactly the keys "schema" and "path"`); + if (record.schema !== GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, `must declare schema ${GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA}`); + if (typeof record.path !== "string" || record.path.length === 0) throw new GentleAiDevBinaryOverrideError("registration", registrationPath, "must declare a non-empty string path"); + return record.path; +} + +/** + * Resolves the active dev-binary override, if any. Returns undefined only when + * neither activation path is present; a present-but-invalid override always + * throws a typed GentleAiDevBinaryOverrideError naming its origin. + */ +export function resolveGentleAiDevBinaryOverride( + environment = ambientDevBinaryEnvironment(), + platform = process.platform, +) { + const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV]; + if (envValue !== undefined && envValue.length > 0) return validateDevBinary("env", GENTLE_AI_DEV_BINARY_ENV, envValue, platform); + const registrationPath = gentleAiDevBinaryRegistrationPath(environment); + if (!existsSync(registrationPath)) return undefined; + return validateDevBinary("registration", registrationPath, readDevBinaryRegistration(registrationPath), platform); +} + +/** + * Cheap presence probe: is a dev-binary override declared at all? Used by the + * native CLI to select the unpinned version gate without hashing the binary. + * Declared-but-invalid still counts as configured — the resolution path will + * fail loudly with the typed error instead of quietly using the pin. + */ +export function gentleAiDevBinaryOverrideConfigured(environment = ambientDevBinaryEnvironment()) { + const envValue = environment.env[GENTLE_AI_DEV_BINARY_ENV]; + if (envValue !== undefined && envValue.length > 0) return true; + return existsSync(gentleAiDevBinaryRegistrationPath(environment)); +} + +/** Validates and persistently registers a dev binary; returns the fresh override. */ +export function registerGentleAiDevBinary( + path , + environment = ambientDevBinaryEnvironment(), + platform = process.platform, +) { + const registrationPath = gentleAiDevBinaryRegistrationPath(environment); + const validated = validateDevBinary("registration", registrationPath, path, platform); + mkdirSync(dirname(registrationPath), { recursive: true }); + writeFileSync(registrationPath, `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path })}\n`); + return { registrationPath, override: validated }; +} + +/** Deletes the persistent registration; returns whether one existed. */ +export function unregisterGentleAiDevBinary(environment = ambientDevBinaryEnvironment()) { + const registrationPath = gentleAiDevBinaryRegistrationPath(environment); + if (!existsSync(registrationPath)) return false; + rmSync(registrationPath); + return true; +} + function isConfined(path , directory ) { const relativePath = relative(directory, path); return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); @@ -111,7 +271,14 @@ export function resolveGentleAiBinary( packageRoot = dirname(dirname(fileURLToPath(import.meta.url))), platform = process.platform, readBinary = readFileSync, + environment = ambientDevBinaryEnvironment(), ) { + // The explicit dev-binary override wins over the pinned supply-chain path. + // Its typed errors propagate: a declared override never falls back to the + // pin. Without a declared override this call returns undefined and the + // pinned resolution below is byte-identical to the pre-override behavior. + const override = resolveGentleAiDevBinaryOverride(environment, platform); + if (override !== undefined) return override.path; const binaryPath = gentleAiBinaryPath(packageRoot, platform); const versionDirectory = dirname(binaryPath); const manifestPath = join(versionDirectory, "integrity.json"); diff --git a/runtime/native-review-cli.mjs b/runtime/native-review-cli.mjs index e0e124def..c6b94591e 100644 --- a/runtime/native-review-cli.mjs +++ b/runtime/native-review-cli.mjs @@ -6,7 +6,7 @@ import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, posix, win32 } from "node:path"; import { promisify } from "node:util"; -import { GENTLE_AI_VERSION, PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.mjs"; +import { GENTLE_AI_VERSION, PackageLocalGentleAiBinaryMissingError, gentleAiDevBinaryOverrideConfigured, resolveGentleAiBinary } from "./gentle-ai-binary.mjs"; import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.mjs"; import { REVIEW_INTEGRATION_CONTRACT, @@ -229,6 +229,22 @@ function stderrIsTolerated(stderr , tolerated ) return lines.length > 0 && lines.every((line) => tolerated.includes(line)); } +// gentle-ai main narrates the negotiated STATUS forecast head to a human on +// stderr while keeping the machine envelope on stdout (reviewNarrateForecast +// in internal/cli/review_narration.go, ground-truthed live against a main-line +// dev build). The pinned release does not emit it, so this narration is +// tolerated only for negotiated STATUS while the dev-binary override is +// configured; any other stderr keeps failing closed. +const FORECAST_NARRATION_LINES = Object.freeze([ + /^Forecast horizon: (?:partial|terminal)$/, + /^step [0-9]+: (?:execute|collect|stop); reason_code=[a-z0-9_]+; description=.+$/, + /^Re-query STATUS after completing this partial head\.$/, +]); +function stderrIsForecastNarration(stderr ) { + const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); + return lines.length > 0 && lines.every((line) => FORECAST_NARRATION_LINES.some((pattern) => pattern.test(line))); +} + export const NATIVE_REVIEW_RECOVER_DISPOSITION = ["scope_changed", "invalidated", "escalated"] ; @@ -751,6 +767,21 @@ function resolvedNativeCliContract(version ) return nativeCliContractsTestingOverlay.get(version) ?? (NATIVE_CLI_CONTRACTS )[version]; } +// The latest known contract row, used as the capability floor for the +// explicit dev-binary override (unpinned field-test mode): a maintainer +// dev build is at least as capable as the newest release Pi knows. The table +// is declared in ascending release order, so its last key is the latest. +const LATEST_NATIVE_CLI_CONTRACT_VERSION = Object.keys(NATIVE_CLI_CONTRACTS).at(-1) ; + +// Dev-binary override version discipline: with the override configured, a +// version reported outside the pinned table resolves to the latest known row; +// with the override absent, behavior is byte-identical to the pinned gate. +function effectiveNativeCliContract(version ) { + const pinned = resolvedNativeCliContract(version); + if (pinned !== undefined) return pinned; + return gentleAiDevBinaryOverrideConfigured() ? NATIVE_CLI_CONTRACTS[LATEST_NATIVE_CLI_CONTRACT_VERSION] : undefined; +} + @@ -1331,8 +1362,14 @@ export class NativeReviewCliV214 { if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, NATIVE_REVIEW_OPERATION.VERSION, false, "version process timed out", result); if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, NATIVE_REVIEW_OPERATION.VERSION, false, "version process was signalled", result); if (result.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.VERSION, false, "version process failed", result); - const version = /^gentle-ai ([0-9]+\.[0-9]+\.[0-9]+)\n$/.exec(result.stdout.replace(/\r\n$/, "\n"))?.[1]; - const contract = version === undefined ? undefined : resolvedNativeCliContract(version); + const normalized = result.stdout.replace(/\r\n$/, "\n"); + const pinnedVersion = /^gentle-ai ([0-9]+\.[0-9]+\.[0-9]+)\n$/.exec(normalized)?.[1]; + // Dev-binary override (unpinned field-test mode): accept the binary's + // reported version even when it is not a pinned three-part release + // version (a main-line dev build reports e.g. "2.4.0-rc.8+fix..."). + // The banner shape itself stays strict. Pinned mode is byte-identical. + const version = pinnedVersion ?? (gentleAiDevBinaryOverrideConfigured() ? /^gentle-ai (\S+)\n$/.exec(normalized)?.[1] : undefined); + const contract = version === undefined ? undefined : effectiveNativeCliContract(version); if (result.stderr.trim().length > 0 || contract === undefined || capabilities.some((capability) => !contract[capability])) throw nativeError(NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `native gentle-ai lacks required capabilities: expected v${GENTLE_AI_VERSION}, found v${version ?? "unparseable"}`); return version ; } @@ -1343,7 +1380,7 @@ export class NativeReviewCliV214 { if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement"); if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef"); const version = await this.verifyVersion(request.cwd, request.signal, ["start"]); - const toleratedStderr = resolvedNativeCliContract(version)?.mode === true ? REVIEW_CONSENT_NOTICES : []; + const toleratedStderr = effectiveNativeCliContract(version)?.mode === true ? REVIEW_CONSENT_NOTICES : []; const { body: result } = await this.execute(NATIVE_REVIEW_OPERATION.START, request.cwd, ["review", "start", "--cwd", request.cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...(request.lineageId ? ["--lineage", request.lineageId] : []), ...(request.policyPath ? ["--policy", request.policyPath] : []), ...(request.focus ? ["--focus", request.focus] : [])], true, request.signal, toleratedStderr); return decode(NATIVE_REVIEW_OPERATION.START, true, () => { // `target_identity` and `lens_bindings` are real, unconditionally-present @@ -2061,7 +2098,8 @@ export class NativeReviewCliV216 { throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native negotiated operation failed without a valid failure envelope", result); } } - if (result.stderr.trim().length > 0 && !stderrIsTolerated(result.stderr, toleratedStderr)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); + const forecastNarrationTolerated = operation === NATIVE_REVIEW_OPERATION.STATUS && gentleAiDevBinaryOverrideConfigured() && stderrIsForecastNarration(result.stderr); + if (result.stderr.trim().length > 0 && !stderrIsTolerated(result.stderr, toleratedStderr) && !forecastNarrationTolerated) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); return { body, exitCode: result.exitCode }; } @@ -2090,7 +2128,12 @@ export class NativeReviewCliV216 { if (error instanceof NativeReviewCliError) throw error; throw nativeError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `expected gentle-ai v${GENTLE_AI_VERSION}; the installed runtime is incompatible — reinstall gentle-pi`); } - if (capabilities.packageVersion !== GENTLE_AI_VERSION) { + // Dev-binary override (unpinned field-test mode): the session accepts + // the binary's self-reported package version instead of the exact pin. + // The full capabilities decode above still applies unchanged, so an + // incompatible envelope is refused either way. Pinned mode keeps the + // exact equality byte-identical. + if (capabilities.packageVersion !== GENTLE_AI_VERSION && !gentleAiDevBinaryOverrideConfigured()) { throw nativeError(NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `expected gentle-ai v${GENTLE_AI_VERSION}, provider reported v${capabilities.packageVersion}`); } return capabilities; diff --git a/runtime/review-integration-v2.mjs b/runtime/review-integration-v2.mjs index b4dba02d8..7b82faf8d 100644 --- a/runtime/review-integration-v2.mjs +++ b/runtime/review-integration-v2.mjs @@ -96,21 +96,24 @@ const RECEIPT_STATUSES = ["expected_missing", "present", "publication_pending", const REQUIRED_OPERATIONS = Object.freeze(Object.values(REVIEW_INTEGRATION_OPERATION)); const REQUIRED_GATES = Object.freeze(["post-apply", "pre-commit", "pre-push", "pre-pr", "release"] ); const REQUIRED_PROJECTIONS = Object.freeze(Object.values(REVIEW_PROJECTION)); -const REQUIRED_SCHEMAS = Object.freeze([ +// The schema floor shared by every accepted capabilities identity. Each minor +// then adds its own capabilities/consent/status identities below: the provider +// swaps those three advertisements as the protocol minor advances (v2.1 moved +// consent to v3; v2.2 moved status to v5), ground-truthed against the vendored +// capabilities[-v2.1|-v2.2].schema.json contracts on gentle-ai main and a live +// v2.2 capture from a main-line dev build. +const REQUIRED_SCHEMAS_COMMON = Object.freeze([ "gentle-ai.review-admitted-result/v2", "gentle-ai.review-artifact-subject/v2", "gentle-ai.review-authority-repair-assessment/v1", "gentle-ai.review-authority-status/v1", "gentle-ai.review-gate-request/v1", - "gentle-ai.review-integration.capabilities/v2", - "gentle-ai.review-integration.consent/v2", "gentle-ai.review-integration.failure/v2", "gentle-ai.review-final-verification-incident/v1", "gentle-ai.review-integration.operation/v2", "gentle-ai.review-integration.projection/v1", "gentle-ai.review-integration.repair/v2", "gentle-ai.review-integration.start/v3", - "gentle-ai.review-integration.status/v3", "gentle-ai.review-receipt/v1", "gentle-ai.review-receipt/v2", "gentle-ai.review-result-artifact/v2", @@ -120,6 +123,20 @@ const REQUIRED_SCHEMAS = Object.freeze([ "https://gentle-ai.dev/schema/review/reviewer/v1", "https://gentle-ai.dev/schema/review/validator/v1", ] ); +const CAPABILITIES_SCHEMA_IDENTITIES = Object.freeze({ + "gentle-ai.review-integration.capabilities/v2": Object.freeze({ + protocolMinor: 0, + requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON, "gentle-ai.review-integration.capabilities/v2", "gentle-ai.review-integration.consent/v2", "gentle-ai.review-integration.status/v3"]), + }), + "gentle-ai.review-integration.capabilities/v2.1": Object.freeze({ + protocolMinor: 1, + requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON, "gentle-ai.review-integration.capabilities/v2.1", "gentle-ai.review-integration.consent/v3", "gentle-ai.review-integration.status/v3"]), + }), + "gentle-ai.review-integration.capabilities/v2.2": Object.freeze({ + protocolMinor: 2, + requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON, "gentle-ai.review-integration.capabilities/v2.2", "gentle-ai.review-integration.consent/v3", "gentle-ai.review-integration.status/v5"]), + }), +}); const OPTIONAL_FEATURE_NAMES = Object.freeze([ "base_ref_workspace_overlay", "bounded_process_waits", @@ -342,6 +359,80 @@ export const REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION = { export const REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS = Object.freeze(Object.values(REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION)); +// status/v5 structural surfaces. Pi has no consumer for these yet; they are +// decoded strictly (exact key sets per the vendored status-v5.schema.json and +// correction-plan-request.schema.json on gentle-ai main) and carried through +// so a v5 provider payload is never rejected for being newer than v3. + + + + + + + + + + + + + + + + + + + + + + + + + + + + +// The two v5 Go-owned submission descriptor forms (finalize carries one +// singular `value`; capture-evidence carries an outcome/input `values` pair). +// Distinct from the host-relay ReviewCaptureSubmissionV1 form, which stays +// exactly as it is. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -720,10 +811,15 @@ function decodeOptionalFeature(value , label ) export function decodeReviewCapabilitiesV2(value , verifiedExecutableDigest ) { const requiredFields = ["schema", "contract", "protocol", "package", "build", "executable", "operations", "gates", "projections", "schemas", "features", "compatibility"] ; const body = exactRecord(value, "capabilities", requiredFields, ["bootstrap"]); - requireIdentity(body, "gentle-ai.review-integration.capabilities/v2"); + // Additive minor acceptance: each accepted schema identity keeps its own + // exact protocol minor and required-schema floor; v2 stays exactly as it + // was, and an unknown identity is rejected before anything else. + const identity = CAPABILITIES_SCHEMA_IDENTITIES[typeof body.schema === "string" ? body.schema : ""]; + if (identity === undefined) throw new TypeError(`schema must be one of ${Object.keys(CAPABILITIES_SCHEMA_IDENTITIES).join(", ")}`); + requireIdentity(body, body.schema ); const protocol = exactRecord(body.protocol, "capabilities.protocol", ["major", "minor"]); - if (protocol.major !== 2 || protocol.minor !== 0) throw new TypeError("incompatible review integration protocol"); + if (protocol.major !== 2 || protocol.minor !== identity.protocolMinor) throw new TypeError("incompatible review integration protocol"); const packageIdentity = exactRecord(body.package, "capabilities.package", ["name", "version", "release_channel"]); if (packageIdentity.name !== "gentle-ai") throw new TypeError("capabilities package identity mismatch"); @@ -750,11 +846,11 @@ export function decodeReviewCapabilitiesV2(value , verifiedExecutableDig // unknown addition is not rejected before assertSupersetOf can even run. const advertisedGates = stringArray(body.gates, "capabilities.gates", { minimum: REQUIRED_GATES.length, unique: true }); const advertisedProjections = stringArray(body.projections, "capabilities.projections", { minimum: REQUIRED_PROJECTIONS.length, unique: true }); - const advertisedSchemas = stringArray(body.schemas, "capabilities.schemas", { minimum: REQUIRED_SCHEMAS.length, unique: true }); + const advertisedSchemas = stringArray(body.schemas, "capabilities.schemas", { minimum: identity.requiredSchemas.length, unique: true }); assertSupersetOf(advertisedOperations, REQUIRED_OPERATIONS, "capabilities operations"); assertSupersetOf(advertisedGates, REQUIRED_GATES, "capabilities gates"); assertSupersetOf(advertisedProjections, REQUIRED_PROJECTIONS, "capabilities projections"); - assertSupersetOf(advertisedSchemas, REQUIRED_SCHEMAS, "capabilities schemas"); + assertSupersetOf(advertisedSchemas, identity.requiredSchemas, "capabilities schemas"); const features = exactRecord(body.features, "capabilities.features", ["mandatory", "optional"]); const mandatory = array(features.mandatory, "capabilities.features.mandatory", (entry, label) => decodeFeature(entry, label), { minimum: 10 }); @@ -802,7 +898,7 @@ export function decodeReviewCapabilitiesV2(value , verifiedExecutableDig operations: new Set(REQUIRED_OPERATIONS), gates: new Set(REQUIRED_GATES), projections: new Set(REQUIRED_PROJECTIONS), - schemas: new Set(REQUIRED_SCHEMAS), + schemas: new Set(identity.requiredSchemas), mandatoryFeatures: new Set(mandatoryNames), optionalFeatures: new Set(optional.filter((feature) => feature.supported && (FEATURE_NAMES ).includes(feature.name)).map((feature) => feature.name)), raw: body, @@ -1093,8 +1189,107 @@ function decodeCaptureSubmission(value , label ) return { operationToken, argumentTokens, values }; } -function decodeCollectInput(value , label ) { - const input = exactRecord(value, label, ["name", "schema", "capture_operation", "arguments"], ["artifact_subject", "base_tree", "candidate_tree", "changed_path_manifest", "validation_request", "submission"]); +// v5-only structural decoders, exact to the vendored status-v5.schema.json +// and correction-plan-request.schema.json on gentle-ai main. + +function decodeProviderTask(value , label ) { + const task = exactRecord(value, label, ["agent", "role", "prompt"]); + return { + agent: enumeration(task.agent, ["review-refuter", "review-validator"] , `${label}.agent`), + role: enumeration(task.role, ["refuter", "targeted-validator"] , `${label}.role`), + prompt: nonempty(task.prompt, `${label}.prompt`), + }; +} + +function decodeSubmissionDescriptorV5(value , label , operationToken ) { + if (operationToken === "finalize") { + const descriptor = exactRecord(value, label, ["operation_token", "argument_tokens", "value"]); + const argumentTokens = stringArray(descriptor.argument_tokens, `${label}.argument_tokens`, { minimum: 7 }); + const source = exactRecord(descriptor.value, `${label}.value`, ["slot", "domain", "substitution_location"], ["schema", "minimum", "maximum"]); + const slot = enumeration(source.slot, ["correction_lines", "validation"] , `${label}.value.slot`); + return { + operationToken, + argumentTokens, + value: { + slot, + domain: nonempty(source.domain, `${label}.value.domain`), + substitutionLocation: integer(source.substitution_location, `${label}.value.substitution_location`, 0, argumentTokens.length - 1), + ...(source.schema === undefined ? {} : { schema: nonempty(source.schema, `${label}.value.schema`) }), + ...(source.minimum === undefined ? {} : { minimum: integer(source.minimum, `${label}.value.minimum`, 1, 200) }), + ...(source.maximum === undefined ? {} : { maximum: integer(source.maximum, `${label}.value.maximum`, 1, 200) }), + }, + }; + } + const descriptor = exactRecord(value, label, ["operation_token", "argument_tokens", "values"]); + const argumentTokens = stringArray(descriptor.argument_tokens, `${label}.argument_tokens`, { minimum: 6, maximum: 6 }); + const values = array(descriptor.values, `${label}.values`, (entry, entryLabel) => { + const source = exactRecord(entry, entryLabel, ["slot", "domain", "substitution_location"], ["schema", "allowed_values"]); + return { + slot: nonempty(source.slot, `${entryLabel}.slot`), + domain: nonempty(source.domain, `${entryLabel}.domain`), + substitutionLocation: integer(source.substitution_location, `${entryLabel}.substitution_location`, 0, argumentTokens.length - 1), + ...(source.schema === undefined ? {} : { schema: nonempty(source.schema, `${entryLabel}.schema`) }), + ...(source.allowed_values === undefined ? {} : { allowedValues: stringArray(source.allowed_values, `${entryLabel}.allowed_values`, { minimum: 1, unique: true }) }), + }; + }, { minimum: 2, maximum: 2 }); + return { operationToken, argumentTokens, values }; +} + +function decodeCorrectionPlanRequestV1(value , label ) { + const body = exactRecord(value, label, ["schema", "request_hash", "lineage_id", "expected_revision", "target_identity", "correction_budget", "fix_finding_ids", "findings"]); + if (body.schema !== "gentle-ai.review-correction-plan-request/v1") throw new TypeError(`${label}.schema must be gentle-ai.review-correction-plan-request/v1`); + return { + schema: "gentle-ai.review-correction-plan-request/v1", + requestHash: sha256(body.request_hash, `${label}.request_hash`), + lineageId: lineage(body.lineage_id, `${label}.lineage_id`), + expectedRevision: sha256(body.expected_revision, `${label}.expected_revision`), + targetIdentity: sha256(body.target_identity, `${label}.target_identity`), + correctionBudget: integer(body.correction_budget, `${label}.correction_budget`, 1, 200), + fixFindingIds: stringArray(body.fix_finding_ids, `${label}.fix_finding_ids`, { minimum: 1, unique: true }), + findings: array(body.findings, `${label}.findings`, (entry, entryLabel) => { + const finding = exactRecord(entry, entryLabel, ["id", "lens", "location", "severity", "claim", "proof_refs", "evidence", "evidence_class", "causal_disposition"]); + return { + id: nonempty(finding.id, `${entryLabel}.id`), + lens: enumeration(finding.lens, ["risk", "resilience", "readability", "reliability"] , `${entryLabel}.lens`), + location: nonempty(finding.location, `${entryLabel}.location`), + severity: enumeration(finding.severity, ["BLOCKER", "CRITICAL"] , `${entryLabel}.severity`), + claim: nonempty(finding.claim, `${entryLabel}.claim`), + proofRefs: stringArray(finding.proof_refs, `${entryLabel}.proof_refs`, { minimum: 1 }), + evidence: nonempty(finding.evidence, `${entryLabel}.evidence`), + evidenceClass: enumeration(finding.evidence_class, ["deterministic", "inferential"] , `${entryLabel}.evidence_class`), + causalDisposition: enumeration(finding.causal_disposition, ["introduced", "behavior-activated", "worsened"] , `${entryLabel}.causal_disposition`), + }; + }, { minimum: 1 }), + }; +} + +export function decodeReviewForecastV1(value , label = "status.forecast") { + const body = exactRecord(value, label, ["horizon", "steps"]); + const horizon = enumeration(body.horizon, ["partial", "terminal"] , `${label}.horizon`); + const steps = array(body.steps, `${label}.steps`, (entry, entryLabel) => { + const step = exactRecord(entry, entryLabel, ["step", "kind", "reason_code", "description"]); + if (step.step !== 1) throw new TypeError(`${entryLabel}.step must be 1`); + return { + step: 1 , + kind: enumeration(step.kind, ["execute", "collect", "stop"] , `${entryLabel}.kind`), + reasonCode: text(step.reason_code, `${entryLabel}.reason_code`, { minimum: 1, pattern: /^[a-z0-9_]+$/ }), + description: nonempty(step.description, `${entryLabel}.description`), + }; + }, { minimum: 1, maximum: 1 }); + // The published horizon-to-step invariant: a stop head is terminal, any + // other head is partial. + if ((horizon === "terminal") !== (steps[0] .kind === "stop")) throw new TypeError(`${label}.horizon does not match its step kind`); + return { horizon, steps }; +} + +// The two v5 reason codes whose transitions must carry the correction plan +// request; every other reason code must not. +const CORRECTION_REQUEST_REASON_CODES = Object.freeze(["correction_plan_required", "corrected_candidate_unavailable"] ); +// v5 capture operations that must carry a submission descriptor. +const V5_SUBMISSION_CAPTURE_OPERATIONS = Object.freeze(["external.plan_correction", "external.run_targeted_validation", "review.capture-evidence"] ); + +function decodeCollectInput(value , label , v5 ) { + const input = exactRecord(value, label, ["name", "schema", "capture_operation", "arguments"], ["artifact_subject", "base_tree", "candidate_tree", "changed_path_manifest", "validation_request", "submission", ...(v5 ? ["provider_task"] : [])]); const name = text(input.name, `${label}.name`, { minimum: 1, pattern: /^[a-z0-9_]+$/ }); const schema = nonempty(input.schema, `${label}.schema`); const captureOperation = nonempty(input.capture_operation, `${label}.capture_operation`); @@ -1109,6 +1304,22 @@ function decodeCollectInput(value , label ) throw new TypeError(`${label}.validation_request is only valid for external.run_targeted_validation or review.capture-validation`); } + // v5-only surfaces (vendored status-v5.schema.json): the provider role task + // rides exactly the external.run_provider_role vector, and the finalize/ + // capture-evidence submission descriptor forms ride their own vectors. + if (v5) { + if (captureOperation === "external.run_provider_role") { + if (input.provider_task === undefined) throw new TypeError(`${label}.provider_task is required for external.run_provider_role`); + } else if (input.provider_task !== undefined) { + throw new TypeError(`${label}.provider_task is only valid for external.run_provider_role`); + } + if ((V5_SUBMISSION_CAPTURE_OPERATIONS ).includes(captureOperation)) { + if (input.submission === undefined) throw new TypeError(`${label}.submission is required for ${captureOperation}`); + if (captureOperation === "external.plan_correction" && schema !== "gentle-ai.review-correction-plan/v1") throw new TypeError(`${label}.schema must be gentle-ai.review-correction-plan/v1`); + if (captureOperation === "review.capture-evidence" && schema !== "https://gentle-ai.dev/schema/review/verification-evidence/v1") throw new TypeError(`${label}.schema must be https://gentle-ai.dev/schema/review/verification-evidence/v1`); + } + } + // gentle-pi#311 P4-roles: the two Go-owned non-lens provider role capture // operations render SELF-CONTAINED authority-advancing vectors. Executing // the exact rendered tokens makes Go materialize the role prompt, run its @@ -1134,8 +1345,22 @@ function decodeCollectInput(value , label ) } else if (input.artifact_subject !== undefined || input.base_tree !== undefined || input.candidate_tree !== undefined || input.changed_path_manifest !== undefined) { throw new TypeError(`${label} carries capture-result fields without review.capture-result`); } - if (input.submission !== undefined && captureOperation !== "review.capture-result") { - throw new TypeError(`${label}.submission is only valid for review.capture-result`); + const submissionOperations = v5 ? ["review.capture-result", ...V5_SUBMISSION_CAPTURE_OPERATIONS] : ["review.capture-result"]; + if (input.submission !== undefined && !submissionOperations.includes(captureOperation)) { + throw new TypeError(v5 ? `${label}.submission is only valid for ${submissionOperations.join(", ")}` : `${label}.submission is only valid for review.capture-result`); + } + + // The v5 descriptor forms are discriminated by their closed operation + // tokens; every other submission stays the host-relay completing form. + let submission ; + let submissionDescriptor ; + if (input.submission !== undefined) { + const operationToken = (input.submission )?.operation_token; + if (v5 && (operationToken === "finalize" || operationToken === "capture-evidence")) { + submissionDescriptor = decodeSubmissionDescriptorV5(input.submission, `${label}.submission`, operationToken); + } else { + submission = decodeCaptureSubmission(input.submission, `${label}.submission`); + } } return { @@ -1148,15 +1373,28 @@ function decodeCollectInput(value , label ) ...(input.candidate_tree === undefined ? {} : { candidateTree: gitTree(input.candidate_tree, `${label}.candidate_tree`) }), ...(input.changed_path_manifest === undefined ? {} : { changedPathManifest: array(input.changed_path_manifest, `${label}.changed_path_manifest`, decodeChangedPathEntry, { unique: true }) }), ...(input.validation_request === undefined ? {} : { validationRequest: decodeTargetedValidationRequestV1(input.validation_request, `${label}.validation_request`) }), - ...(input.submission === undefined ? {} : { submission: decodeCaptureSubmission(input.submission, `${label}.submission`) }), + ...(submission === undefined ? {} : { submission }), + ...(submissionDescriptor === undefined ? {} : { submissionDescriptor }), + ...(v5 && input.provider_task !== undefined ? { providerTask: decodeProviderTask(input.provider_task, `${label}.provider_task`) } : {}), }; } -export function decodeReviewNextTransitionV3(value ) { - const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect"]); +export function decodeReviewNextTransitionV3(value , options = {}) { + const v5 = options.v5 === true; + const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : [])]); const kind = enumeration(transition.kind, ["execute", "collect", "stop"] , "next_transition.kind"); const reasonCode = text(transition.reason_code, "next_transition.reason_code", { minimum: 1, pattern: /^[a-z0-9_]+$/ }); + // status/v5: the bounded correction plan request rides exactly its two + // reason codes and never any other (vendored status-v5.schema.json). + let correctionRequest ; + if (v5) { + const required = (CORRECTION_REQUEST_REASON_CODES ).includes(reasonCode); + if (required && transition.correction_request === undefined) throw new TypeError(`next_transition.correction_request is required for ${reasonCode}`); + if (!required && transition.correction_request !== undefined) throw new TypeError(`next_transition.correction_request is only valid for ${CORRECTION_REQUEST_REASON_CODES.join(", ")}`); + if (transition.correction_request !== undefined) correctionRequest = decodeCorrectionPlanRequestV1(transition.correction_request, "next_transition.correction_request"); + } + if (kind === "execute") { // `command` is an optional, ready-to-paste rendering of `arguments` (the // exact same binding as a single shell-ready string) — observed live @@ -1183,16 +1421,16 @@ export function decodeReviewNextTransitionV3(value ) const revision = binding.revision === undefined ? undefined : sha256(binding.revision, "next_transition.execute.binding.revision"); const command = execute.command === undefined ? undefined : nonempty(execute.command, "next_transition.execute.command"); if (transition.collect !== undefined) throw new TypeError("next_transition.collect is incompatible with execute"); - return { kind, reasonCode, execute: { operation, arguments: argumentsList, preconditions, binding: { targetIdentity, ...(lineageId === undefined ? {} : { lineageId }), ...(revision === undefined ? {} : { revision }) }, ...(command === undefined ? {} : { command }) } }; + return { kind, reasonCode, execute: { operation, arguments: argumentsList, preconditions, binding: { targetIdentity, ...(lineageId === undefined ? {} : { lineageId }), ...(revision === undefined ? {} : { revision }) }, ...(command === undefined ? {} : { command }) }, ...(correctionRequest === undefined ? {} : { correctionRequest }) }; } if (kind === "collect") { const collect = exactRecord(transition.collect, "next_transition.collect", ["inputs"]); - const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label), { minimum: 1 }); + const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label, v5), { minimum: 1 }); if (transition.execute !== undefined) throw new TypeError("next_transition.execute is incompatible with collect"); - return { kind, reasonCode, collect: { inputs } }; + return { kind, reasonCode, collect: { inputs }, ...(correctionRequest === undefined ? {} : { correctionRequest }) }; } if (transition.execute !== undefined || transition.collect !== undefined) throw new TypeError("next_transition stop cannot carry a transition"); - return { kind, reasonCode }; + return { kind, reasonCode, ...(correctionRequest === undefined ? {} : { correctionRequest }) }; } // --------------------------------------------------------------------------- @@ -1252,10 +1490,15 @@ function decodeFinalVerificationRetry(value , label ) // --------------------------------------------------------------------------- export function decodeReviewStatusV3(value ) { + // Additive forward acceptance: status/v5 (gentle-ai main; ground-truthed + // against a live capture and the vendored status-v5.schema.json) is the v3 + // key set plus the optional forecast and the v5-only next_transition + // surfaces. The v3 identity keeps rejecting every v5-only field. + const v5 = typeof value === "object" && value !== null && (value ).schema === "gentle-ai.review-integration.status/v5"; const body = exactRecord(value, "status", [ "schema", "contract", "operation", "applicability", "receipt", "action", "replayability", "target_identity", "projection", "repair", "candidates", - ], ["authority", "frozen", "reconciliation", "action_disposition", "eligibility", "next_transition", "authority_target_identity", "validation_request", "final_verification_retry"]); - requireIdentity(body, "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS); + ], ["authority", "frozen", "reconciliation", "action_disposition", "eligibility", "next_transition", "authority_target_identity", "validation_request", "final_verification_retry", ...(v5 ? ["forecast"] : [])]); + requireIdentity(body, v5 ? "gentle-ai.review-integration.status/v5" : "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS); const applicability = enumeration(body.applicability, ["current_target", "unrelated", "ambiguous", "corrupted"] , "status.applicability"); const receiptBody = exactRecord(body.receipt, "status.receipt", ["status"], ["identity"]); @@ -1296,7 +1539,11 @@ export function decodeReviewStatusV3(value ) { if ((action === "recover" || action === "retry_final_verification") && actionDisposition === undefined) throw new TypeError(`${action} status requires action_disposition`); if (action !== "recover" && action !== "retry_final_verification" && actionDisposition !== undefined) throw new TypeError("status.action_disposition is only valid for the recover or retry_final_verification action"); if (body.eligibility !== undefined) decodeEligibility(body.eligibility, "status.eligibility"); - const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition); + const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition, { v5 }); + // status/v5 dependentRequired: a forecast previews the transition head, so + // it can only accompany an actual next_transition. + const forecast = v5 && body.forecast !== undefined ? decodeReviewForecastV1(body.forecast) : undefined; + if (forecast !== undefined && nextTransition === undefined) throw new TypeError("status.forecast requires status.next_transition"); const replayability = enumeration(body.replayability, Object.values(REVIEW_REPLAYABILITY), "status.replayability"); let reconciliation ; @@ -1349,6 +1596,7 @@ export function decodeReviewStatusV3(value ) { ...(nextTransition === undefined ? {} : { nextTransition }), ...(validationRequest === undefined ? {} : { validationRequest }), ...(finalVerificationRetry === undefined ? {} : { finalVerificationRetry }), + ...(forecast === undefined ? {} : { forecast }), raw: body, }; } diff --git a/tests/fixtures/devbinary/capabilities-v2.1.derived.json b/tests/fixtures/devbinary/capabilities-v2.1.derived.json new file mode 100644 index 000000000..9b08d4f57 --- /dev/null +++ b/tests/fixtures/devbinary/capabilities-v2.1.derived.json @@ -0,0 +1,331 @@ +{ + "schema": "gentle-ai.review-integration.capabilities/v2.1", + "contract": "gentle-ai.review-integration/v2", + "protocol": { + "major": 2, + "minor": 1 + }, + "package": { + "name": "gentle-ai", + "version": "2.4.0-rc.8+fix.verify-attestation-recovery", + "release_channel": "prerelease" + }, + "build": { + "id": "sha256:4db874cd6a31fc1bc0deb2b94c22ad4668e878ffc5c0bc9acc2c81bcbe03e054", + "go_version": "go1.26.5", + "module_version": "(devel)", + "vcs": "git", + "vcs_revision": "", + "vcs_time": "", + "vcs_modified": "true" + }, + "executable": { + "sha256": "sha256:ffc91d8fa79c869aba9aa3d1ec80edebb5b1744e5a06fef75d4c8b73c0e46bc1", + "evidence": "self-reported", + "verification": "compare-with-published-manifest" + }, + "operations": [ + "review.bind_sdd", + "review.capabilities", + "review.finalize", + "review.repair", + "review.retry_final_verification", + "review.start", + "review.status", + "review.validate" + ], + "gates": [ + "post-apply", + "pre-commit", + "pre-push", + "pre-pr", + "release" + ], + "projections": [ + "staged", + "workspace" + ], + "schemas": [ + "gentle-ai.review-admitted-result/v2", + "gentle-ai.review-artifact-subject/v2", + "gentle-ai.review-authority-repair-assessment/v1", + "gentle-ai.review-authority-status/v1", + "gentle-ai.review-gate-request/v1", + "gentle-ai.review-integration.capabilities/v2.1", + "gentle-ai.review-integration.failure/v2", + "gentle-ai.review-final-verification-incident/v1", + "gentle-ai.review-integration.operation/v2", + "gentle-ai.review-integration.projection/v1", + "gentle-ai.review-integration.repair/v2", + "gentle-ai.review-integration.start/v3", + "gentle-ai.review-integration.status/v3", + "gentle-ai.review-receipt/v1", + "gentle-ai.review-receipt/v2", + "gentle-ai.review-result-artifact/v2", + "gentle-ai.review-targeted-validation-request/v1", + "gentle-ai.review-verification-evidence/v2", + "https://gentle-ai.dev/schema/review/refuter/v1", + "https://gentle-ai.dev/schema/review/reviewer/v1", + "https://gentle-ai.dev/schema/review/validator/v1", + "gentle-ai.review-integration.consent/v3" + ], + "features": { + "mandatory": [ + { + "name": "compact_v2_authority", + "supported": true, + "requires": [] + }, + { + "name": "exact_receipt_replay", + "supported": true, + "requires": [ + "compact_v2_authority" + ] + }, + { + "name": "five_delivery_gates", + "supported": true, + "requires": [ + "compact_v2_authority" + ] + }, + { + "name": "immutable_snapshot", + "supported": true, + "requires": [] + }, + { + "name": "legacy_v1_target_scoped_read_only", + "supported": true, + "requires": [ + "target_scoped_status" + ] + }, + { + "name": "repository_independent_capabilities", + "supported": true, + "requires": [] + }, + { + "name": "restart_safe_projection", + "supported": true, + "requires": [ + "target_scoped_status" + ] + }, + { + "name": "sdd_receipt_binding", + "supported": true, + "requires": [ + "compact_v2_authority" + ] + }, + { + "name": "target_scoped_status", + "supported": true, + "requires": [ + "repository_independent_capabilities" + ] + }, + { + "name": "uniform_failure_envelope", + "supported": true, + "requires": [ + "repository_independent_capabilities" + ] + } + ], + "optional": [ + { + "name": "base_ref_workspace_overlay", + "supported": true, + "requires": [ + "immutable_snapshot", + "restart_safe_projection" + ] + }, + { + "name": "bounded_process_waits", + "supported": true, + "requires": [ + "uniform_failure_envelope" + ] + }, + { + "name": "classified_authority_repair", + "supported": true, + "requires": [ + "native_next_transition", + "uniform_failure_envelope" + ] + }, + { + "name": "exact_gate_receipt_discovery", + "supported": true, + "requires": [ + "five_delivery_gates" + ] + }, + { + "name": "native_frozen_candidate_context", + "supported": true, + "requires": [ + "immutable_snapshot" + ] + }, + { + "name": "native_low_risk_verification", + "supported": true, + "requires": [ + "compact_v2_authority" + ] + }, + { + "name": "native_next_transition", + "supported": true, + "requires": [ + "target_scoped_status" + ] + }, + { + "name": "one_shot_final_verification_retry", + "supported": true, + "requires": [ + "compact_v2_authority", + "exact_receipt_replay", + "native_next_transition" + ] + }, + { + "name": "opaque_repository_context", + "supported": true, + "requires": [ + "compact_v2_authority", + "native_next_transition" + ] + }, + { + "name": "outcome_bound_verification_evidence", + "supported": true, + "requires": [ + "compact_v2_authority", + "native_next_transition" + ] + }, + { + "name": "provider_artifact_admission", + "supported": true, + "requires": [ + "compact_v2_authority", + "native_frozen_candidate_context", + "opaque_repository_context" + ] + }, + { + "name": "provider_targeted_validation_request", + "supported": true, + "requires": [ + "compact_v2_authority", + "native_next_transition" + ] + }, + { + "name": "recovered_correction_evidence", + "supported": true, + "requires": [ + "compact_v2_authority", + "provider_targeted_validation_request" + ] + }, + { + "name": "risk_reasons", + "supported": true, + "requires": [ + "repository_independent_capabilities" + ] + }, + { + "name": "scope_change_diagnostics", + "supported": true, + "requires": [ + "uniform_failure_envelope" + ] + }, + { + "name": "validating_result_reopen", + "supported": true, + "requires": [ + "compact_v2_authority", + "provider_artifact_admission" + ] + }, + { + "name": "provider_bound_native_git_context", + "supported": true, + "requires": [ + "native_frozen_candidate_context", + "opaque_repository_context", + "provider_artifact_admission" + ] + } + ] + }, + "bootstrap": { + "command": "gentle-ai review status --cwd --contract gentle-ai.review-integration/v2 --next-transition", + "target_selector_variants": [ + { + "target_type": "staged", + "arguments": [ + "--projection", + "staged" + ] + }, + { + "target_type": "base_ref", + "arguments": [ + "--base-ref", + "" + ] + }, + { + "target_type": "workspace_overlay_base_ref", + "arguments": [ + "--workspace-overlay", + "--base-ref", + "" + ] + }, + { + "target_type": "workspace_overlay_base_tree", + "arguments": [ + "--workspace-overlay", + "--base-tree", + "" + ] + } + ], + "required_feature": "native_next_transition", + "unsupported_outcome": "unsupported-capability", + "parent_only": true + }, + "compatibility": { + "minimum_protocol_major": 2, + "maximum_protocol_major": 2, + "additive_minor_policy": "optional-fields-only", + "unknown_mandatory": "reject", + "unknown_optional": "ignore", + "modes": [ + "compact-v2", + "legacy-v1" + ], + "legacy_window": { + "mode": "legacy-v1", + "state": "active", + "read_only": true, + "deprecation_started": true, + "removal": "not-scheduled", + "minimum_compatibility_releases": 1 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/devbinary/capabilities-v2.2.captured.json b/tests/fixtures/devbinary/capabilities-v2.2.captured.json new file mode 100644 index 000000000..5d2c0bcc7 --- /dev/null +++ b/tests/fixtures/devbinary/capabilities-v2.2.captured.json @@ -0,0 +1,340 @@ +{ + "schema": "gentle-ai.review-integration.capabilities/v2.2", + "contract": "gentle-ai.review-integration/v2", + "protocol": { + "major": 2, + "minor": 2 + }, + "package": { + "name": "gentle-ai", + "version": "2.4.0-rc.8+fix.verify-attestation-recovery", + "release_channel": "prerelease" + }, + "build": { + "id": "sha256:4db874cd6a31fc1bc0deb2b94c22ad4668e878ffc5c0bc9acc2c81bcbe03e054", + "go_version": "go1.26.5", + "module_version": "(devel)", + "vcs": "git", + "vcs_revision": "", + "vcs_time": "", + "vcs_modified": "true" + }, + "executable": { + "sha256": "sha256:ffc91d8fa79c869aba9aa3d1ec80edebb5b1744e5a06fef75d4c8b73c0e46bc1", + "evidence": "self-reported", + "verification": "compare-with-published-manifest" + }, + "operations": [ + "review.bind_sdd", + "review.capabilities", + "review.finalize", + "review.repair", + "review.retry_final_verification", + "review.start", + "review.status", + "review.validate" + ], + "gates": [ + "post-apply", + "pre-commit", + "pre-push", + "pre-pr", + "release" + ], + "projections": [ + "staged", + "workspace" + ], + "schemas": [ + "gentle-ai.review-admitted-result/v2", + "gentle-ai.review-artifact-subject/v2", + "gentle-ai.review-authority-repair-assessment/v1", + "gentle-ai.review-authority-status/v1", + "gentle-ai.review-gate-request/v1", + "gentle-ai.review-integration.capabilities/v2.2", + "gentle-ai.review-integration.failure/v2", + "gentle-ai.review-final-verification-incident/v1", + "gentle-ai.review-integration.operation/v2", + "gentle-ai.review-integration.projection/v1", + "gentle-ai.review-integration.repair/v2", + "gentle-ai.review-integration.start/v3", + "gentle-ai.review-integration.status/v5", + "gentle-ai.review-receipt/v1", + "gentle-ai.review-receipt/v2", + "gentle-ai.review-result-artifact/v2", + "gentle-ai.review-targeted-validation-request/v1", + "gentle-ai.review-verification-evidence/v2", + "https://gentle-ai.dev/schema/review/refuter/v1", + "https://gentle-ai.dev/schema/review/reviewer/v1", + "https://gentle-ai.dev/schema/review/validator/v1", + "gentle-ai.review-integration.consent/v3" + ], + "features": { + "mandatory": [ + { + "name": "compact_v2_authority", + "supported": true, + "requires": [] + }, + { + "name": "exact_receipt_replay", + "supported": true, + "requires": [ + "compact_v2_authority" + ] + }, + { + "name": "five_delivery_gates", + "supported": true, + "requires": [ + "compact_v2_authority" + ] + }, + { + "name": "immutable_snapshot", + "supported": true, + "requires": [] + }, + { + "name": "legacy_v1_target_scoped_read_only", + "supported": true, + "requires": [ + "target_scoped_status" + ] + }, + { + "name": "repository_independent_capabilities", + "supported": true, + "requires": [] + }, + { + "name": "restart_safe_projection", + "supported": true, + "requires": [ + "target_scoped_status" + ] + }, + { + "name": "sdd_receipt_binding", + "supported": true, + "requires": [ + "compact_v2_authority" + ] + }, + { + "name": "target_scoped_status", + "supported": true, + "requires": [ + "repository_independent_capabilities" + ] + }, + { + "name": "uniform_failure_envelope", + "supported": true, + "requires": [ + "repository_independent_capabilities" + ] + } + ], + "optional": [ + { + "name": "base_ref_workspace_overlay", + "supported": true, + "requires": [ + "immutable_snapshot", + "restart_safe_projection" + ] + }, + { + "name": "bounded_process_waits", + "supported": true, + "requires": [ + "uniform_failure_envelope" + ] + }, + { + "name": "classified_authority_repair", + "supported": true, + "requires": [ + "native_next_transition", + "uniform_failure_envelope" + ] + }, + { + "name": "exact_gate_receipt_discovery", + "supported": true, + "requires": [ + "five_delivery_gates" + ] + }, + { + "name": "native_frozen_candidate_context", + "supported": true, + "requires": [ + "immutable_snapshot" + ] + }, + { + "name": "native_low_risk_verification", + "supported": true, + "requires": [ + "compact_v2_authority" + ] + }, + { + "name": "native_next_transition", + "supported": true, + "requires": [ + "target_scoped_status" + ] + }, + { + "name": "one_shot_final_verification_retry", + "supported": true, + "requires": [ + "compact_v2_authority", + "exact_receipt_replay", + "native_next_transition" + ] + }, + { + "name": "opaque_repository_context", + "supported": true, + "requires": [ + "compact_v2_authority", + "native_next_transition" + ] + }, + { + "name": "outcome_bound_verification_evidence", + "supported": true, + "requires": [ + "compact_v2_authority", + "native_next_transition" + ] + }, + { + "name": "provider_artifact_admission", + "supported": true, + "requires": [ + "compact_v2_authority", + "native_frozen_candidate_context", + "opaque_repository_context" + ] + }, + { + "name": "provider_targeted_validation_request", + "supported": true, + "requires": [ + "compact_v2_authority", + "native_next_transition" + ] + }, + { + "name": "recovered_correction_evidence", + "supported": true, + "requires": [ + "compact_v2_authority", + "provider_targeted_validation_request" + ] + }, + { + "name": "risk_reasons", + "supported": true, + "requires": [ + "repository_independent_capabilities" + ] + }, + { + "name": "scope_change_diagnostics", + "supported": true, + "requires": [ + "uniform_failure_envelope" + ] + }, + { + "name": "validating_result_reopen", + "supported": true, + "requires": [ + "compact_v2_authority", + "provider_artifact_admission" + ] + }, + { + "name": "provider_bound_native_git_context", + "supported": true, + "requires": [ + "native_frozen_candidate_context", + "opaque_repository_context", + "provider_artifact_admission" + ] + }, + { + "name": "provider_submission_descriptors", + "supported": true, + "requires": [ + "native_next_transition", + "opaque_repository_context", + "provider_targeted_validation_request" + ] + } + ] + }, + "bootstrap": { + "command": "gentle-ai review status --cwd \u003crepo\u003e --contract gentle-ai.review-integration/v2 --next-transition", + "target_selector_variants": [ + { + "target_type": "staged", + "arguments": [ + "--projection", + "staged" + ] + }, + { + "target_type": "base_ref", + "arguments": [ + "--base-ref", + "\u003cref\u003e" + ] + }, + { + "target_type": "workspace_overlay_base_ref", + "arguments": [ + "--workspace-overlay", + "--base-ref", + "\u003cref\u003e" + ] + }, + { + "target_type": "workspace_overlay_base_tree", + "arguments": [ + "--workspace-overlay", + "--base-tree", + "\u003ctree\u003e" + ] + } + ], + "required_feature": "native_next_transition", + "unsupported_outcome": "unsupported-capability", + "parent_only": true + }, + "compatibility": { + "minimum_protocol_major": 2, + "maximum_protocol_major": 2, + "additive_minor_policy": "optional-fields-only", + "unknown_mandatory": "reject", + "unknown_optional": "ignore", + "modes": [ + "compact-v2", + "legacy-v1" + ], + "legacy_window": { + "mode": "legacy-v1", + "state": "active", + "read_only": true, + "deprecation_started": true, + "removal": "not-scheduled", + "minimum_compatibility_releases": 1 + } + } +} diff --git a/tests/fixtures/devbinary/status-v5.captured.json b/tests/fixtures/devbinary/status-v5.captured.json new file mode 100644 index 000000000..acf7cb4dd --- /dev/null +++ b/tests/fixtures/devbinary/status-v5.captured.json @@ -0,0 +1,88 @@ +{ + "schema": "gentle-ai.review-integration.status/v5", + "contract": "gentle-ai.review-integration/v2", + "operation": "review.status", + "applicability": "unrelated", + "receipt": { + "status": "not_applicable" + }, + "action": "start", + "replayability": "not_replayable", + "target_identity": "sha256:50d13aa33f913bbf99df6ad5b94ac1db2aba969445ffc08aadffd8b42b79f1c4", + "projection": { + "schema": "gentle-ai.review-integration.projection/v1", + "kind": "current-changes", + "projection": "workspace", + "base_tree": "619289f137abd616d94899f2c9b6726de091ac92", + "initial_review_tree": "619289f137abd616d94899f2c9b6726de091ac92", + "current_candidate_tree": "619289f137abd616d94899f2c9b6726de091ac92", + "paths_digest": "sha256:2286a180488f90f2e97062d500a629bc747716d55a34c2861a8309e1b02589a1", + "paths": [], + "intended_untracked": [], + "intended_untracked_proof": "sha256:b97229718d4c7fe50a8892eb474ae5aa6491697bccb400e3e31dbaca4894d2f3", + "initial_snapshot_identity": "sha256:50d13aa33f913bbf99df6ad5b94ac1db2aba969445ffc08aadffd8b42b79f1c4", + "current_snapshot_identity": "sha256:50d13aa33f913bbf99df6ad5b94ac1db2aba969445ffc08aadffd8b42b79f1c4" + }, + "repair": { + "schema": "gentle-ai.review-authority-repair-assessment/v1", + "status": "unsupported", + "counts": { + "lineages": 0, + "compact_lineages": 0, + "legacy_lineages": 0, + "events": 0, + "bytes": 0, + "eligible_candidates": 0, + "unsupported_lineages": 0, + "conflicts": 0 + }, + "supported_operations": [ + "review/complete-fix", + "review/validate-fix" + ], + "authorization_schema": "gentle-ai.review-repair-authorization/v1" + }, + "candidates": [], + "forecast": { + "horizon": "partial", + "steps": [ + { + "step": 1, + "kind": "collect", + "reason_code": "empty_candidate_base_ref_required", + "description": "empty candidate base ref required" + } + ] + }, + "next_transition": { + "kind": "collect", + "reason_code": "empty_candidate_base_ref_required", + "collect": { + "inputs": [ + { + "name": "base_ref", + "schema": "gentle-ai.review-base-ref-selection/v1", + "capture_operation": "external.select_base_ref", + "arguments": [ + { + "name": "target_identity", + "value": "sha256:50d13aa33f913bbf99df6ad5b94ac1db2aba969445ffc08aadffd8b42b79f1c4" + }, + { + "name": "projection", + "value": "workspace" + }, + { + "name": "base_tree", + "value": "619289f137abd616d94899f2c9b6726de091ac92" + }, + { + "name": "candidate_tree", + "value": "619289f137abd616d94899f2c9b6726de091ac92" + } + ] + } + ] + } + } +} diff --git a/tests/gentle-ai-dev-binary-surfacing.test.ts b/tests/gentle-ai-dev-binary-surfacing.test.ts new file mode 100644 index 000000000..310dfb968 --- /dev/null +++ b/tests/gentle-ai-dev-binary-surfacing.test.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { createGentleAiExtension } from "../extensions/gentle-ai.ts"; +import { + GENTLE_AI_DEV_BINARY_ENV, + gentleAiDevBinaryRegistrationPath, + setGentleAiDevBinaryEnvironmentForTesting, +} from "../lib/gentle-ai-binary.ts"; + +// Loud surfacing for the dev-binary override: while an override is active, +// every diagnostic surface must say so, name the exact binary, its live +// version, and its content digest — the maintainer must never wonder which +// gentle-ai actually answered. + +interface CommandRegistration { + handler: (args: string, ctx: ExtensionContext) => Promise; +} + +function harness(): { pi: ExtensionAPI; commands: Map } { + const commands = new Map(); + const pi = { + on() {}, + registerCommand(name: string, registration: CommandRegistration) { + commands.set(name, registration); + }, + registerTool() {}, + } as unknown as ExtensionAPI; + return { pi, commands }; +} + +function contextFor(cwd: string, notifications: Array<{ message: string; severity: string }>): ExtensionContext { + return { + cwd, + hasUI: true, + ui: { + notify(message: string, severity: string) { + notifications.push({ message, severity }); + }, + }, + } as unknown as ExtensionContext; +} + +async function withDevOverride(callback: (state: { devBinary: string; sha256: string; home: string }) => Promise): Promise { + const home = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-home-")); + const bin = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-bin-")); + const devBinary = join(bin, "gentle-ai"); + writeFileSync(devBinary, "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+surface'\n"); + chmodSync(devBinary, 0o755); + const sha256 = createHash("sha256").update(readFileSync(devBinary)).digest("hex"); + setGentleAiDevBinaryEnvironmentForTesting({ env: { [GENTLE_AI_DEV_BINARY_ENV]: devBinary }, home }); + try { + return await callback({ devBinary, sha256, home }); + } finally { + setGentleAiDevBinaryEnvironmentForTesting(undefined); + } +} + +test("gentle:doctor and gentle:status surface the active dev-binary override loudly", async () => { + const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME; + process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-dev-agent-home-")); + try { + await withDevOverride(async ({ devBinary, sha256 }) => { + const { pi, commands } = harness(); + createGentleAiExtension({ nativeReviewCli: null })(pi); + const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-")); + const expected = `Gentle AI dev binary override active (unpinned, field-test only): ${devBinary} 9.9.9-dev+surface sha256:${sha256.slice(0, 16)}`; + for (const command of ["gentle:doctor", "gentle:status"]) { + const notifications: Array<{ message: string; severity: string }> = []; + await commands.get(command)!.handler("", contextFor(cwd, notifications)); + assert.equal(notifications.length, 1, command); + assert.ok(notifications[0]!.message.includes(expected), `${command}: ${notifications[0]!.message}`); + } + }); + } finally { + if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME; + else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome; + } +}); + +test("without an override the surfaces stay silent about dev binaries", async () => { + const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME; + process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-dev-agent-home-")); + const home = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-home-")); + setGentleAiDevBinaryEnvironmentForTesting({ env: {}, home }); + try { + const { pi, commands } = harness(); + createGentleAiExtension({ nativeReviewCli: null })(pi); + const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-")); + for (const command of ["gentle:doctor", "gentle:status"]) { + const notifications: Array<{ message: string; severity: string }> = []; + await commands.get(command)!.handler("", contextFor(cwd, notifications)); + assert.equal(notifications.length, 1, command); + assert.doesNotMatch(notifications[0]!.message, /dev binary/i, command); + } + } finally { + setGentleAiDevBinaryEnvironmentForTesting(undefined); + if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME; + else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome; + } +}); + +test("an invalid override is surfaced as a failure, never silently ignored", async () => { + const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME; + process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-dev-agent-home-")); + const home = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-home-")); + setGentleAiDevBinaryEnvironmentForTesting({ env: { [GENTLE_AI_DEV_BINARY_ENV]: "/nonexistent/gentle-ai" }, home }); + try { + const { pi, commands } = harness(); + createGentleAiExtension({ nativeReviewCli: null })(pi); + const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-")); + const notifications: Array<{ message: string; severity: string }> = []; + await commands.get("gentle:doctor")!.handler("", contextFor(cwd, notifications)); + assert.equal(notifications.length, 1); + assert.match(notifications[0]!.message, /fail: Gentle AI dev binary override/); + assert.match(notifications[0]!.message, new RegExp(GENTLE_AI_DEV_BINARY_ENV)); + } finally { + setGentleAiDevBinaryEnvironmentForTesting(undefined); + if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME; + else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome; + } +}); + +test("gentle:dev-binary registers, reports, and clears the persistent override", async () => { + const home = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-home-")); + const bin = await mkdtemp(join(tmpdir(), "gentle-pi-dev-surface-bin-")); + const devBinary = join(bin, "gentle-ai"); + writeFileSync(devBinary, "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+register'\n"); + chmodSync(devBinary, 0o755); + setGentleAiDevBinaryEnvironmentForTesting({ env: {}, home }); + try { + const { pi, commands } = harness(); + createGentleAiExtension({ nativeReviewCli: null })(pi); + const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-")); + const command = commands.get("gentle:dev-binary"); + assert.ok(command, "gentle:dev-binary command is registered"); + const registrationPath = gentleAiDevBinaryRegistrationPath({ env: {}, home }); + + let notifications: Array<{ message: string; severity: string }> = []; + await command!.handler("status", contextFor(cwd, notifications)); + assert.match(notifications[0]!.message, /no dev binary override/i); + + notifications = []; + await command!.handler(devBinary, contextFor(cwd, notifications)); + assert.equal(existsSync(registrationPath), true); + assert.match(notifications[0]!.message, /dev binary override active \(unpinned, field-test only\)/); + assert.ok(notifications[0]!.message.includes(devBinary)); + + notifications = []; + await command!.handler("relative/gentle-ai", contextFor(cwd, notifications)); + assert.equal(notifications[0]!.severity, "error"); + + notifications = []; + await command!.handler("off", contextFor(cwd, notifications)); + assert.equal(existsSync(registrationPath), false); + assert.match(notifications[0]!.message, /removed|cleared/i); + } finally { + setGentleAiDevBinaryEnvironmentForTesting(undefined); + } +}); + +test("session start announces the active override once, loudly", async () => { + const previousAgentHome = process.env.GENTLE_PI_AGENT_HOME; + process.env.GENTLE_PI_AGENT_HOME = await mkdtemp(join(tmpdir(), "gentle-pi-dev-agent-home-")); + try { + await withDevOverride(async ({ devBinary, sha256 }) => { + const handlers = new Map Promise>(); + const pi = { + on(name: string, handler: (event: unknown, ctx: ExtensionContext) => Promise) { + handlers.set(name, handler); + }, + registerCommand() {}, + registerTool() {}, + } as unknown as ExtensionAPI; + createGentleAiExtension({ nativeReviewCli: null })(pi); + const sessionStart = handlers.get("session_start"); + assert.equal(typeof sessionStart, "function"); + const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-dev-cwd-")); + const notifications: Array<{ message: string; severity: string }> = []; + await sessionStart!({}, contextFor(cwd, notifications)); + const expected = `Gentle AI dev binary override active (unpinned, field-test only): ${devBinary} 9.9.9-dev+surface sha256:${sha256.slice(0, 16)}`; + const announcement = notifications.find((entry) => entry.message.includes(expected)); + assert.ok(announcement, JSON.stringify(notifications)); + assert.equal(announcement!.severity, "warning"); + }); + } finally { + if (previousAgentHome === undefined) delete process.env.GENTLE_PI_AGENT_HOME; + else process.env.GENTLE_PI_AGENT_HOME = previousAgentHome; + } +}); diff --git a/tests/gentle-ai-dev-binary.test.ts b/tests/gentle-ai-dev-binary.test.ts new file mode 100644 index 000000000..95b18c144 --- /dev/null +++ b/tests/gentle-ai-dev-binary.test.ts @@ -0,0 +1,336 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { + GENTLE_AI_DEV_BINARY_ENV, + GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE, + GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, + GentleAiDevBinaryOverrideError, + PackageLocalGentleAiBinaryMissingError, + gentleAiDevBinaryRegistrationPath, + registerGentleAiDevBinary, + resolveGentleAiBinary, + resolveGentleAiDevBinaryOverride, + setGentleAiDevBinaryEnvironmentForTesting, + unregisterGentleAiDevBinary, + type GentleAiDevBinaryEnvironment, +} from "../lib/gentle-ai-binary.ts"; +import { + NATIVE_REVIEW_ERROR_CODE, + NativeReviewCliError, + NativeReviewCliV214, + NativeReviewCliV216, + clearNativeReviewCapabilitiesCacheForTesting, + type ExecFileAdapter, +} from "../lib/native-review-cli.ts"; + +// Dev-binary override, maintainer field-test lane. The pinned supply-chain +// resolver stays byte-identical while neither the env var nor the persistent +// registration file is present; every override failure is a typed refusal that +// names its origin, never a silent fallback to the pin (silently running the +// pinned release while the maintainer believes he is testing main is the worst +// outcome this contract exists to prevent). + +const PLATFORM = "linux"; + +function sha256(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +async function scratch(prefix: string): Promise { + return mkdtemp(join(tmpdir(), prefix)); +} + +function writeDevBinary(directory: string, contents = "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+field'\n"): string { + const path = join(directory, "gentle-ai"); + writeFileSync(path, contents); + chmodSync(path, 0o755); + return path; +} + +function environment(home: string, env: Record = {}): GentleAiDevBinaryEnvironment { + return { env, home }; +} + +function writeRegistration(home: string, contents: string): string { + const path = gentleAiDevBinaryRegistrationPath(environment(home)); + mkdirSync(join(home, ".pi", "gentle-ai"), { recursive: true }); + writeFileSync(path, contents); + return path; +} + +function registrationDocument(path: string): string { + return `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path })}\n`; +} + +const isOverrideError = (origin: string | RegExp) => (error: unknown): boolean => + error instanceof GentleAiDevBinaryOverrideError + && error.code === GENTLE_AI_DEV_BINARY_OVERRIDE_INVALID_CODE + && (typeof origin === "string" ? error.message.includes(origin) : origin.test(error.message)); + +test("the env override resolves the dev binary with a fresh sha256", async () => { + const home = await scratch("gentle-pi-dev-home-"); + const bin = await scratch("gentle-pi-dev-bin-"); + const devBinary = writeDevBinary(bin); + const env = environment(home, { [GENTLE_AI_DEV_BINARY_ENV]: devBinary }); + const override = resolveGentleAiDevBinaryOverride(env, PLATFORM); + assert.equal(override?.source, "env"); + assert.equal(override?.path, devBinary); + assert.equal(override?.sha256, sha256(devBinary)); + const packageRoot = await scratch("gentle-pi-dev-pkg-"); + assert.equal(resolveGentleAiBinary(packageRoot, PLATFORM, readFileSync, env), devBinary); +}); + +test("with no env var and no registration file, pinned resolution is byte-identical", async () => { + const home = await scratch("gentle-pi-dev-home-"); + const packageRoot = await scratch("gentle-pi-dev-pkg-"); + const env = environment(home); + assert.equal(resolveGentleAiDevBinaryOverride(env, PLATFORM), undefined); + // An empty env value means unset, exactly like the pinned path today. + assert.equal(resolveGentleAiDevBinaryOverride(environment(home, { [GENTLE_AI_DEV_BINARY_ENV]: "" }), PLATFORM), undefined); + assert.throws(() => resolveGentleAiBinary(packageRoot, PLATFORM, readFileSync, env), PackageLocalGentleAiBinaryMissingError); +}); + +test("a relative path, symlink, or non-executable env override is a typed refusal naming the env var", async () => { + const home = await scratch("gentle-pi-dev-home-"); + const bin = await scratch("gentle-pi-dev-bin-"); + const devBinary = writeDevBinary(bin); + const packageRoot = await scratch("gentle-pi-dev-pkg-"); + const symlinked = join(bin, "gentle-ai-link"); + symlinkSync(devBinary, symlinked); + const nonExecutable = join(bin, "gentle-ai-noexec"); + writeFileSync(nonExecutable, "#!/bin/sh\n"); + chmodSync(nonExecutable, 0o644); + for (const value of ["relative/gentle-ai", symlinked, nonExecutable, join(bin, "missing-gentle-ai")]) { + const env = environment(home, { [GENTLE_AI_DEV_BINARY_ENV]: value }); + assert.throws(() => resolveGentleAiDevBinaryOverride(env, PLATFORM), isOverrideError(GENTLE_AI_DEV_BINARY_ENV), value); + // Never a silent fallback to the pin. + assert.throws(() => resolveGentleAiBinary(packageRoot, PLATFORM, readFileSync, env), isOverrideError(GENTLE_AI_DEV_BINARY_ENV), value); + } +}); + +test("the registration file resolves the dev binary and the env var wins over it", async () => { + const home = await scratch("gentle-pi-dev-home-"); + const bin = await scratch("gentle-pi-dev-bin-"); + const registered = writeDevBinary(bin); + writeRegistration(home, registrationDocument(registered)); + const fileOnly = resolveGentleAiDevBinaryOverride(environment(home), PLATFORM); + assert.equal(fileOnly?.source, "registration"); + assert.equal(fileOnly?.path, registered); + const envDirectory = await scratch("gentle-pi-dev-bin-"); + const envBinary = writeDevBinary(envDirectory, "#!/bin/sh\necho 'gentle-ai 9.9.10-dev'\n"); + const both = resolveGentleAiDevBinaryOverride(environment(home, { [GENTLE_AI_DEV_BINARY_ENV]: envBinary }), PLATFORM); + assert.equal(both?.source, "env"); + assert.equal(both?.path, envBinary); +}); + +test("GENTLE_PI_CONFIG_HOME relocates the registration file", async () => { + const home = await scratch("gentle-pi-dev-home-"); + const configHome = await scratch("gentle-pi-dev-config-"); + const env = environment(home, { GENTLE_PI_CONFIG_HOME: configHome }); + assert.equal(gentleAiDevBinaryRegistrationPath(env), join(configHome, "dev-binary.json")); + assert.equal(gentleAiDevBinaryRegistrationPath(environment(home)), join(home, ".pi", "gentle-ai", "dev-binary.json")); +}); + +test("a malformed or unresolvable registration is a typed refusal naming the file, never the pin", async () => { + const bin = await scratch("gentle-pi-dev-bin-"); + const packageRoot = await scratch("gentle-pi-dev-pkg-"); + const devBinary = writeDevBinary(bin); + const cases = [ + "not json at all", + `${JSON.stringify({ schema: "gentle-pi.dev-binary/v0", path: devBinary })}\n`, + `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path: devBinary, sha256: "0".repeat(64) })}\n`, + `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA })}\n`, + `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path: "relative/gentle-ai" })}\n`, + `${JSON.stringify({ schema: GENTLE_AI_DEV_BINARY_REGISTRATION_SCHEMA, path: join(bin, "missing-gentle-ai") })}\n`, + `${JSON.stringify([devBinary])}\n`, + ]; + for (const contents of cases) { + const home = await scratch("gentle-pi-dev-home-"); + const registrationPath = writeRegistration(home, contents); + const env = environment(home); + assert.throws(() => resolveGentleAiDevBinaryOverride(env, PLATFORM), isOverrideError(registrationPath), contents); + assert.throws(() => resolveGentleAiBinary(packageRoot, PLATFORM, readFileSync, env), isOverrideError(registrationPath), contents); + } +}); + +test("replacing the registered binary is picked up on the next resolution with the new sha256", async () => { + const home = await scratch("gentle-pi-dev-home-"); + const bin = await scratch("gentle-pi-dev-bin-"); + const devBinary = writeDevBinary(bin, "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+build1'\n"); + writeRegistration(home, registrationDocument(devBinary)); + const env = environment(home); + const first = resolveGentleAiDevBinaryOverride(env, PLATFORM); + writeFileSync(devBinary, "#!/bin/sh\necho 'gentle-ai 9.9.9-dev+build2'\n"); + chmodSync(devBinary, 0o755); + const second = resolveGentleAiDevBinaryOverride(env, PLATFORM); + assert.equal(first?.sha256, createHash("sha256").update("#!/bin/sh\necho 'gentle-ai 9.9.9-dev+build1'\n").digest("hex")); + assert.equal(second?.sha256, createHash("sha256").update("#!/bin/sh\necho 'gentle-ai 9.9.9-dev+build2'\n").digest("hex")); + assert.notEqual(first?.sha256, second?.sha256); +}); + +test("register and unregister write and delete the strict registration document", async () => { + const home = await scratch("gentle-pi-dev-home-"); + const bin = await scratch("gentle-pi-dev-bin-"); + const devBinary = writeDevBinary(bin); + const env = environment(home); + const registered = registerGentleAiDevBinary(devBinary, env, PLATFORM); + assert.equal(registered.registrationPath, gentleAiDevBinaryRegistrationPath(env)); + assert.equal(readFileSync(registered.registrationPath, "utf8"), registrationDocument(devBinary)); + assert.equal(resolveGentleAiDevBinaryOverride(env, PLATFORM)?.path, devBinary); + assert.throws(() => registerGentleAiDevBinary("relative/gentle-ai", env, PLATFORM), isOverrideError(/relative\/gentle-ai/)); + assert.equal(unregisterGentleAiDevBinary(env), true); + assert.equal(resolveGentleAiDevBinaryOverride(env, PLATFORM), undefined); + assert.equal(unregisterGentleAiDevBinary(env), false); +}); + +// --------------------------------------------------------------------------- +// Version gate: dev mode accepts the binary's reported version with the latest +// known contract row as the capability floor; pinned mode stays byte-identical. +// --------------------------------------------------------------------------- + +interface QueuedResult { stdout: string; stderr?: string; exitCode?: number } + +function queuedAdapter(results: QueuedResult[]): { adapter: ExecFileAdapter; calls: Array<{ file: string; arguments: readonly string[] }> } { + const calls: Array<{ file: string; arguments: readonly string[] }> = []; + return { + calls, + adapter: async (request) => { + calls.push({ file: request.file, arguments: request.arguments }); + const result = results.shift(); + if (!result) throw new Error("unexpected native invocation"); + return { stdout: result.stdout, stderr: result.stderr ?? "", exitCode: result.exitCode ?? 0, signal: null, timedOut: false, outputLimitExceeded: false }; + }, + }; +} + +const START = { stdout: JSON.stringify({ operation: "review/start", lineage_id: "lineage-1", state: "reviewing", risk_level: "medium", selected_lenses: ["review-reliability"], changed_files: 1, changed_lines: 2, correction_budget: 1, action: "created", lenses_required: true, projection: "workspace" }) }; +const DEV_VERSION_OUTPUTS = ["gentle-ai 2.4.0-rc.8+fix.verify-attestation-recovery\n", "gentle-ai 9.9.9\n"]; + +async function withDevEnvironment(callback: () => Promise): Promise { + const home = await scratch("gentle-pi-dev-home-"); + const bin = await scratch("gentle-pi-dev-bin-"); + const devBinary = writeDevBinary(bin); + setGentleAiDevBinaryEnvironmentForTesting({ env: { [GENTLE_AI_DEV_BINARY_ENV]: devBinary }, home }); + try { + return await callback(); + } finally { + setGentleAiDevBinaryEnvironmentForTesting(undefined); + } +} + +async function withPinnedEnvironment(callback: () => Promise): Promise { + const home = await scratch("gentle-pi-dev-home-"); + setGentleAiDevBinaryEnvironmentForTesting({ env: {}, home }); + try { + return await callback(); + } finally { + setGentleAiDevBinaryEnvironmentForTesting(undefined); + } +} + +test("pinned mode still refuses an unknown reported version", async () => { + await withPinnedEnvironment(async () => { + for (const stdout of DEV_VERSION_OUTPUTS) { + const queue = queuedAdapter([{ stdout }]); + await assert.rejects( + () => new NativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai").start({ cwd: "/repo" }), + (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, + stdout, + ); + } + }); +}); + +test("dev mode accepts the reported version and floors capabilities at the latest known contract row", async () => { + await withDevEnvironment(async () => { + for (const stdout of DEV_VERSION_OUTPUTS) { + const queue = queuedAdapter([{ stdout }, START]); + const result = await new NativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai").start({ cwd: "/repo" }); + assert.equal(result.lineageId, "lineage-1", stdout); + } + }); +}); + +test("dev mode never rescues a pinned-mode unparseable version banner", async () => { + await withDevEnvironment(async () => { + const queue = queuedAdapter([{ stdout: "definitely not a version banner\n" }]); + await assert.rejects( + () => new NativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai").start({ cwd: "/repo" }), + (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, + ); + }); +}); + +// Captured 2026-08-15 from /home/gentleman/.cargo/bin/gentle-ai reporting +// "gentle-ai 2.4.0-rc.8+fix.verify-attestation-recovery" (a gentle-ai main +// line dev build) via `gentle-ai review capabilities --contract +// gentle-ai.review-integration/v2` in a scratch git repository. +const CAPABILITIES_V22 = readFileSync(join(import.meta.dirname, "fixtures", "devbinary", "capabilities-v2.2.captured.json"), "utf8"); +const CAPTURED_EXECUTABLE_DIGEST = "ffc91d8fa79c869aba9aa3d1ec80edebb5b1744e5a06fef75d4c8b73c0e46bc1"; +const STATUS_V5 = readFileSync(join(import.meta.dirname, "fixtures", "devbinary", "status-v5.captured.json"), "utf8"); +const FORECAST_STDERR = "Forecast horizon: partial\nstep 1: collect; reason_code=empty_candidate_base_ref_required; description=empty candidate base ref required\nRe-query STATUS after completing this partial head.\n"; + +function v216(adapter: ExecFileAdapter, digest: string): NativeReviewCliV216 { + return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 16 * 1024 * 1024, async () => undefined, () => digest); +} + +test("pinned mode refuses a dev-version capabilities envelope on package version equality", async () => { + clearNativeReviewCapabilitiesCacheForTesting(); + await withPinnedEnvironment(async () => { + const queue = queuedAdapter([{ stdout: CAPABILITIES_V22 }]); + await assert.rejects( + () => v216(queue.adapter, CAPTURED_EXECUTABLE_DIGEST).capabilities({ cwd: "/repo" }), + (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, + ); + }); +}); + +test("dev mode negotiates the captured v2.2 capabilities envelope without the pin equality", async () => { + clearNativeReviewCapabilitiesCacheForTesting(); + await withDevEnvironment(async () => { + const queue = queuedAdapter([{ stdout: CAPABILITIES_V22 }]); + const capabilities = await v216(queue.adapter, CAPTURED_EXECUTABLE_DIGEST).capabilities({ cwd: "/repo" }); + assert.equal(capabilities.packageVersion, "2.4.0-rc.8+fix.verify-attestation-recovery"); + }); +}); + +test("dev mode tolerates the provider's forecast narration on negotiated STATUS stderr and still refuses other stderr", async () => { + await withDevEnvironment(async () => { + clearNativeReviewCapabilitiesCacheForTesting(); + const queue = queuedAdapter([{ stdout: CAPABILITIES_V22 }, { stdout: STATUS_V5, stderr: FORECAST_STDERR }]); + const status = await v216(queue.adapter, CAPTURED_EXECUTABLE_DIGEST).targetStatus({ cwd: "/repo", projection: "workspace" }); + assert.equal(status.action, "start"); + assert.equal(status.forecast?.horizon, "partial"); + clearNativeReviewCapabilitiesCacheForTesting(); + const noisy = queuedAdapter([{ stdout: CAPABILITIES_V22 }, { stdout: STATUS_V5, stderr: "panic: something else\n" }]); + await assert.rejects( + () => v216(noisy.adapter, CAPTURED_EXECUTABLE_DIGEST).targetStatus({ cwd: "/repo", projection: "workspace" }), + (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, + ); + clearNativeReviewCapabilitiesCacheForTesting(); + }); +}); + +test("pinned mode still refuses forecast narration stderr on negotiated STATUS", async () => { + await withPinnedEnvironment(async () => { + clearNativeReviewCapabilitiesCacheForTesting(); + // A pinned-version capabilities envelope cannot be captured from the dev + // binary; assert one layer down instead: with the dev override absent the + // forecast narration is not in any tolerated stderr set, so the negotiated + // STATUS invocation itself reports UNEXPECTED_STDERR. Capability + // negotiation is bypassed by pre-seeding the cache through a dev-mode run + // being impossible here, so this test drives the legacy client, whose + // stderr discipline is the same code path. + const queue = queuedAdapter([{ stdout: "gentle-ai 2.1.5\n" }, { stdout: STATUS_V5, stderr: FORECAST_STDERR }]); + await assert.rejects( + () => new NativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai").reviewStatus({ cwd: "/repo" }), + (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, + ); + clearNativeReviewCapabilitiesCacheForTesting(); + }); +}); diff --git a/tests/review-integration-v2-forward.test.ts b/tests/review-integration-v2-forward.test.ts new file mode 100644 index 000000000..4548beb66 --- /dev/null +++ b/tests/review-integration-v2-forward.test.ts @@ -0,0 +1,276 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; +import { + decodeReviewCapabilitiesV2, + decodeReviewStatusV3, +} from "../lib/review-integration-v2.ts"; + +// Forward decoders for the gentle-ai main line (dev-binary field-test lane). +// +// Fixture provenance: +// - capabilities-v2.2.captured.json and status-v5.captured.json were captured +// 2026-08-15 from the real binary at /home/gentleman/.cargo/bin/gentle-ai +// reporting "gentle-ai 2.4.0-rc.8+fix.verify-attestation-recovery" (built +// from the gentle-ai main line), in a scratch git repository: +// gentle-ai review capabilities --contract gentle-ai.review-integration/v2 +// GENTLE_PI_REVIEW_RELAY_CONTRACT=gentle-pi.review-relay/v1 \ +// gentle-ai review status --contract gentle-ai.review-integration/v2 \ +// --cwd --projection workspace --next-transition +// - capabilities-v2.1.derived.json is derived from the captured v2.2 payload +// plus gentle-ai origin/main contracts/review-integration/v2/schemas/ +// capabilities-v2.1.schema.json (schema const v2.1, protocol minor 1, +// consent/v3 + status/v3 schema advertisements, 17 optional features): no +// installed binary emits v2.1 to capture from. +// - The v5-only next_transition samples (correction_request, provider_task, +// submission descriptor forms) are constructed from gentle-ai origin/main +// contracts/review-integration/v2/schemas/status-v5.schema.json and +// contracts/review-integration/v1/schemas/correction-plan-request.schema.json. + +const fixtureRoot = join(import.meta.dirname, "fixtures", "devbinary"); +const fixture = >(name: string): T => JSON.parse(readFileSync(join(fixtureRoot, name), "utf8")) as T; +const v2FixtureRoot = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures"); +const v2Fixture = >(name: string): T => JSON.parse(readFileSync(join(v2FixtureRoot, name), "utf8")) as T; +const capturedDigest = "ffc91d8fa79c869aba9aa3d1ec80edebb5b1744e5a06fef75d4c8b73c0e46bc1"; +const pinnedFixtureDigest = "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705"; + +type JsonObject = Record; +const clone = (value: T): T => structuredClone(value); +const sha = (fill: string): string => `sha256:${fill.repeat(64)}`; + +test("the captured capabilities/v2.2 payload decodes and reports its own package version", () => { + const capabilities = decodeReviewCapabilitiesV2(fixture("capabilities-v2.2.captured.json"), capturedDigest); + assert.equal(capabilities.packageVersion, "2.4.0-rc.8+fix.verify-attestation-recovery"); + assert.equal(capabilities.schemas.has("gentle-ai.review-integration.status/v5"), true); + assert.equal(capabilities.schemas.has("gentle-ai.review-integration.consent/v3"), true); + assert.equal(capabilities.optionalFeatures.has("provider_bound_native_git_context"), true); +}); + +test("the derived capabilities/v2.1 payload decodes with protocol minor 1", () => { + const capabilities = decodeReviewCapabilitiesV2(fixture("capabilities-v2.1.derived.json"), capturedDigest); + assert.equal(capabilities.packageVersion, "2.4.0-rc.8+fix.verify-attestation-recovery"); + assert.equal(capabilities.schemas.has("gentle-ai.review-integration.status/v3"), true); + assert.equal(capabilities.schemas.has("gentle-ai.review-integration.consent/v3"), true); +}); + +test("the pinned capabilities/v2 fixture still decodes unchanged", () => { + const capabilities = decodeReviewCapabilitiesV2(v2Fixture("capabilities.fixture.json"), pinnedFixtureDigest); + assert.equal(capabilities.schemas.has("gentle-ai.review-integration.status/v3"), true); + assert.equal(capabilities.schemas.has("gentle-ai.review-integration.consent/v2"), true); +}); + +test("capabilities schema identity and protocol minor must agree", () => { + const base = fixture("capabilities-v2.2.captured.json"); + const wrongMinor = clone(base); + (wrongMinor.protocol as JsonObject).minor = 1; + assert.throws(() => decodeReviewCapabilitiesV2(wrongMinor, capturedDigest), /protocol/); + const wrongSchema = clone(base); + wrongSchema.schema = "gentle-ai.review-integration.capabilities/v2.3"; + assert.throws(() => decodeReviewCapabilitiesV2(wrongSchema, capturedDigest), /schema/); + // The old identity never inherits the new advertisements: a v2 envelope + // advertising the v2.2 schema surface is missing its own required floor. + const crossed = clone(base); + crossed.schema = "gentle-ai.review-integration.capabilities/v2"; + (crossed.protocol as JsonObject).minor = 0; + assert.throws(() => decodeReviewCapabilitiesV2(crossed, capturedDigest), /schemas/); +}); + +test("the captured status/v5 payload decodes with its forecast and base-ref collect input", () => { + const status = decodeReviewStatusV3(fixture("status-v5.captured.json")); + assert.equal(status.applicability, "unrelated"); + assert.equal(status.action, "start"); + assert.equal(status.forecast?.horizon, "partial"); + assert.deepEqual(status.forecast?.steps, [{ step: 1, kind: "collect", reasonCode: "empty_candidate_base_ref_required", description: "empty candidate base ref required" }]); + assert.equal(status.nextTransition?.kind, "collect"); + assert.equal(status.nextTransition?.collect?.inputs[0]?.captureOperation, "external.select_base_ref"); +}); + +test("the pinned status/v3 fixture still decodes unchanged", () => { + const status = decodeReviewStatusV3(v2Fixture("status.fixture.json")); + assert.equal(status.contract, "gentle-ai.review-integration/v2"); +}); + +test("a status/v3 payload never carries v5 fields", () => { + const v5 = fixture("status-v5.captured.json"); + const downgraded = clone(v5); + downgraded.schema = "gentle-ai.review-integration.status/v3"; + // forecast is a v5 field; the v3 identity must keep rejecting it. + assert.throws(() => decodeReviewStatusV3(downgraded), /forecast/); + const withoutForecast = clone(downgraded); + delete withoutForecast.forecast; + const status = decodeReviewStatusV3(withoutForecast); + assert.equal(status.forecast, undefined); +}); + +test("a v5 forecast requires the next transition and a coherent horizon", () => { + const base = fixture("status-v5.captured.json"); + const orphanForecast = clone(base); + delete orphanForecast.next_transition; + assert.throws(() => decodeReviewStatusV3(orphanForecast), /forecast/); + const wrongHorizon = clone(base); + (wrongHorizon.forecast as JsonObject).horizon = "terminal"; + assert.throws(() => decodeReviewStatusV3(wrongHorizon), /horizon/); + const badStep = clone(base); + ((badStep.forecast as JsonObject).steps as JsonObject[])[0]!.step = 2; + assert.throws(() => decodeReviewStatusV3(badStep), /step/); + const extraKey = clone(base); + (extraKey.forecast as JsonObject).unadvertised = true; + assert.throws(() => decodeReviewStatusV3(extraKey), /not allowed/); +}); + +// --- v5-only next_transition surfaces, constructed per the vendored schemas --- + +function v5StatusWith(nextTransition: JsonObject, forecast?: JsonObject): JsonObject { + const base = fixture("status-v5.captured.json"); + base.next_transition = nextTransition; + if (forecast === undefined) delete base.forecast; + else base.forecast = forecast; + return base; +} + +const correctionPlanRequest: JsonObject = { + schema: "gentle-ai.review-correction-plan-request/v1", + request_hash: sha("a"), + lineage_id: "review-1d5aadacc600e167", + expected_revision: sha("b"), + target_identity: sha("c"), + correction_budget: 25, + fix_finding_ids: ["risk-1"], + findings: [{ + id: "risk-1", + lens: "risk", + location: "app.ts:10", + severity: "BLOCKER", + claim: "candidate-introduced credential exposure", + proof_refs: ["app.ts:10-14"], + evidence: "the candidate logs the raw token", + evidence_class: "deterministic", + causal_disposition: "introduced", + }], +}; + +test("a v5 correction_plan_required transition carries its correction request and plan submission", () => { + const transition: JsonObject = { + kind: "collect", + reason_code: "correction_plan_required", + correction_request: correctionPlanRequest, + collect: { + inputs: [{ + name: "correction_plan", + schema: "gentle-ai.review-correction-plan/v1", + capture_operation: "external.plan_correction", + arguments: [{ name: "lineage", value: "review-1d5aadacc600e167" }], + submission: { + operation_token: "finalize", + argument_tokens: [ + "--contract=gentle-ai.review-integration/v2", + "--lineage=review-1d5aadacc600e167", + `--expected-revision=${sha("b")}`, + `--target=${sha("c")}`, + `--request-hash=${sha("a")}`, + `--repository-context=rctx1_${"0".repeat(64)}`, + "--correction-lines={{value}}", + ], + value: { slot: "correction_lines", domain: "positive_correction_lines", minimum: 1, maximum: 25, substitution_location: 6 }, + }, + }], + }, + }; + const status = decodeReviewStatusV3(v5StatusWith(transition)); + assert.equal(status.nextTransition?.reasonCode, "correction_plan_required"); + assert.equal(status.nextTransition?.correctionRequest?.correctionBudget, 25); + assert.equal(status.nextTransition?.collect?.inputs[0]?.submissionDescriptor?.operationToken, "finalize"); + // The same reason code without its correction request is incomplete. + const missing = clone(transition); + delete missing.correction_request; + assert.throws(() => decodeReviewStatusV3(v5StatusWith(missing)), /correction_request/); + // And correction_request never rides an unrelated reason code. + const unrelated = clone(transition); + unrelated.reason_code = "verification_evidence_required"; + assert.throws(() => decodeReviewStatusV3(v5StatusWith(unrelated)), /correction_request/); +}); + +test("a v5 capture-evidence transition decodes its evidence submission descriptor", () => { + const transition: JsonObject = { + kind: "collect", + reason_code: "verification_evidence_required", + collect: { + inputs: [{ + name: "verification_evidence", + schema: "https://gentle-ai.dev/schema/review/verification-evidence/v1", + capture_operation: "review.capture-evidence", + arguments: [{ name: "lineage", value: "review-1d5aadacc600e167" }], + submission: { + operation_token: "capture-evidence", + argument_tokens: [ + "--lineage=review-1d5aadacc600e167", + `--expected-revision=${sha("b")}`, + `--target=${sha("c")}`, + `--repository-context=rctx1_${"0".repeat(64)}`, + "--outcome={{outcome}}", + "--input={{input}}", + ], + values: [ + { slot: "outcome", domain: "verification_outcome", allowed_values: ["passed", "verification_failed", "procedural_tooling_failed"], substitution_location: 4 }, + { slot: "input", domain: "artifact_path_or_stdin", schema: "https://gentle-ai.dev/schema/review/verification-evidence/v1", substitution_location: 5 }, + ], + }, + }], + }, + }; + const status = decodeReviewStatusV3(v5StatusWith(transition)); + const input = status.nextTransition?.collect?.inputs[0]; + assert.equal(input?.submissionDescriptor?.operationToken, "capture-evidence"); + assert.equal(input?.submission, undefined); + // Missing its required submission descriptor is incomplete. + const missing = clone(transition); + delete ((missing.collect as JsonObject).inputs as JsonObject[])[0]!.submission; + assert.throws(() => decodeReviewStatusV3(v5StatusWith(missing)), /submission/); +}); + +test("a v5 provider role task input decodes and is confined to external.run_provider_role", () => { + const providerTask = { agent: "review-refuter", role: "refuter", prompt: "GENTLE_AI_REVIEW_BINDING {}" }; + const transition: JsonObject = { + kind: "collect", + reason_code: "refuter_batch_required", + collect: { + inputs: [{ + name: "refuter_batch", + schema: "https://gentle-ai.dev/schema/review/refuter/v1", + capture_operation: "external.run_provider_role", + arguments: [{ name: "lineage", value: "review-1d5aadacc600e167" }], + provider_task: providerTask, + }], + }, + }; + const status = decodeReviewStatusV3(v5StatusWith(transition)); + assert.deepEqual(status.nextTransition?.collect?.inputs[0]?.providerTask, { agent: "review-refuter", role: "refuter", prompt: "GENTLE_AI_REVIEW_BINDING {}" }); + // The task is required on its own vector and forbidden anywhere else. + const missing = clone(transition); + delete ((missing.collect as JsonObject).inputs as JsonObject[])[0]!.provider_task; + assert.throws(() => decodeReviewStatusV3(v5StatusWith(missing)), /provider_task/); + const misplaced = fixture("status-v5.captured.json"); + (((misplaced.next_transition as JsonObject).collect as JsonObject).inputs as JsonObject[])[0]!.provider_task = providerTask; + assert.throws(() => decodeReviewStatusV3(misplaced), /provider_task/); +}); + +test("the v3 next transition keeps rejecting every v5-only surface", () => { + const v5 = fixture("status-v5.captured.json"); + // Re-identify the captured envelope as v3 (drop the v5-only forecast) and + // then try to smuggle each v5 surface through the old identity. + const asV3 = (mutate: (transition: JsonObject) => void): JsonObject => { + const body = clone(v5); + body.schema = "gentle-ai.review-integration.status/v3"; + delete body.forecast; + mutate(body.next_transition as JsonObject); + return body; + }; + assert.throws(() => decodeReviewStatusV3(asV3((transition) => { transition.correction_request = correctionPlanRequest; })), /not allowed|correction_request/); + assert.throws(() => decodeReviewStatusV3(asV3((transition) => { + (((transition.collect as JsonObject).inputs as JsonObject[])[0]!).provider_task = { agent: "review-refuter", role: "refuter", prompt: "x" }; + })), /not allowed|provider_task/); + assert.throws(() => decodeReviewStatusV3(asV3((transition) => { + (((transition.collect as JsonObject).inputs as JsonObject[])[0]!).submission = { operation_token: "finalize", argument_tokens: ["--correction-lines={{value}}"], value: { slot: "correction_lines", domain: "positive_correction_lines", minimum: 1, maximum: 25, substitution_location: 6 } }; + })), /submission|values/); +});