import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts"; import { dataArgumentRanges, recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts"; import { defaultTempScratchFs, isTempScratchRemoval, type TempScratchFs } from "../lib/yolo-temp-scratch.ts"; import { SHELL_COMMAND_TOOLS } from "../lib/background-jobs.ts"; import { evaluateDeliveredTestTool } from "../lib/delivered-test-lint.ts"; import { commitSliceToolResult } from "../lib/commit-slice-signal.ts"; import { appendContextBackstopNotice, createContextBackstopSignal, createThrottledContextMeasure } from "../lib/context-backstop.ts"; import { blockChildDestructiveCommand } from "./child-safety.ts"; import { allowedEditSurfaces as hasTaskScopedAllowedEditSurfaces, bindSessionRepositoryPreparation, captureBoundSessionRepositoryAuthority, isHomeOrBroadUserDirectory, prepareBoundSessionRepository, rejectUndocumentedWriterLaunch, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sourcePathWithinProject } from "../lib/bounded-writer-admission.ts"; import { rejectUnriskedVerifierLaunch } from "../lib/verifier-admission.ts"; import { consumeReviewMutation, pendingReviewMutation, pendingReviewMutationProfiles, recordReviewMutation, type ReceiptSession } from "../lib/review-reminder-receipt.ts"; import { createReviewSidebarPublisher } from "../lib/review-sidebar-state.ts"; import { StatusTimingDiagnostics } from "../lib/status-timing-diagnostics.ts"; import { isOddPhase, oddPhaseRegistry, ODD_PHASES } from "../lib/odd-phase.ts"; import { shellEnabled } from "../lib/shell-bar.ts"; import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts"; import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts"; import { execFileSync } from "node:child_process"; import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { isProxy } from "node:util/types"; import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs"; import { access, mkdir, readFile, readdir, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import { discoverAgents, loadAgentsConfig, unmatchedProfileKeys } from "../lib/agents-config.ts"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionContext, SessionShutdownEvent, Theme, ThemeColor, ToolCallEventResult, ToolResultEvent, ToolResultEventResult, } from "@earendil-works/pi-coding-agent"; import { Text, isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui"; import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts"; import { BACKGROUND_SUBAGENTS_FILE, BACKGROUND_SUBAGENTS_SCHEMA, loadBackgroundSubagentsPolicy, parseBackgroundSubagentsPolicyFile, resolveBackgroundSubagentsPolicy, type BackgroundSubagentsPolicy, type BackgroundSubagentsResolution, } from "../lib/background-subagents-policy.ts"; import { installPackageAssets, getPackageAssetOwner, hasPackageAssetOwnerInstallation, type PackageAssetOwner, isPackageManagedSddAsset, updatePackageManagedSddAgentOwnership } from "../lib/agent-assets.ts"; import { THINKING_LEVELS, normalizeModelConfig, isThinkingLevel, normalizeModelId, normalizeRoutingEntry, readSavedModelConfig as readModelRoutingAuthority, readSavedModelConfigAsync as readModelRoutingAuthorityAsync, type AgentModelConfig, type AgentRoutingEntry, type ModelConfigFileResult, type ThinkingLevel, } from "../lib/model-routing-authority.ts"; import { bootstrapProfilesFile, buildProfileListItems, createProfile, deleteProfile, duplicateProfile, formatOrchestratorSelection, formatRoutingRow, isProfileOrchestratorKey, parseProfileExportTextWithDrops, PROFILE_ORCHESTRATOR_KEY, profileExportPath, profileRoutingRows, unmatchedRoutingNotes, profilesFilePath, readProfileOrchestrator, readProfilesFileResult, renameProfile, routingColumnWidths, serializeProfileExport, setActiveProfile, updateProfile, writeProfilesFileSync, type AgentProfilesFile, type ProfileListItem, type ProfileRoutingRow, type ProfilesFileReadResult, type ProfilesParseDrops, } from "../lib/agent-profiles.ts"; import { clearProfilePinSync, evaluateProfilePin, readProfilePinStatus, REPO_PROFILE_DECLARATION_GITIGNORE_RULES, resolveProfilePin, writeProfilePinSync, type ProfilePinEvaluation, type ProfilePinSource, type ProfilePinStatus, } from "../lib/agent-profile-pin.ts"; import { clearSessionProfileBinding, readSessionProfileBinding } from "../lib/session-profile-binding.ts"; import { createSessionProfileIntegration, hasPendingSessionProfileSelection, readCurrentSessionProfileOutcome, runCurrentSessionProfileSelection, waitCurrentSessionProfileSelection } from "../lib/session-profile-integration.ts"; import { publishSessionProfileOutcome, readSessionProfileAuthority } from "../lib/session-profile-authority.ts"; import { isSessionProfileFamilyEntry } from "../lib/session-profile-persistence.ts"; import { resolveSessionProfile, sessionProfileModelProfiles } from "../lib/session-profile-freeze.ts"; import { applyOrchestratorSettings, readOrchestratorSettings, restoreOrchestratorSettings, type OrchestratorSettingsReadResult, parseOrchestratorModelRef, } from "../lib/profiles-orchestrator.ts"; import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts"; import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, reviewHostMediatedRoleSlots, reviewHostRelaySlots, reviewHostRelayUnachievableDetail, reviewHostRelayUnachievableReason, reviewProviderRoleVectorSlots, resolveReviewHostRelaySubmission, runReviewHostRelayReviewerGroup, runReviewHostRelaySlot, submitReviewHostRelayPreparedResult, type ReviewHostRelayPreparedResult, type ReviewHostRelayRequest, type ReviewHostRelayRunner, type ReviewHostRelaySlot, type ReviewProviderRoleVectorSlot, } from "../lib/review-host-relay.ts"; import type { InProcessReviewerRegistry } from "../lib/inprocess-reviewer.ts"; import { JOURNAL_STATUS, REVIEW_OPERATION, REVIEW_TRANSITION, ReviewTransactionStore, canonicalHash, createReviewState, type ReviewBudgetV1, type ReviewReducerInput, type StartOperationResultV1, type ReviewTransition, } from "../lib/review-transaction.ts"; import { REVIEW_MODE, REVIEW_PROJECTION, captureReviewSnapshot, type ReviewMode, type ReviewProjectionV1, } from "../lib/review-snapshot.ts"; import { GentleAiElapsedTimingLedger } from "../lib/gentle-ai-elapsed-store.ts"; import { renderGentleAiLifecycleCall, renderGentleAiResult, type GentleAiRenderContext } from "../lib/gentle-ai-renderer.ts"; import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts"; import { BASE_REF_ACCEPTED_FORMS, CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, isProviderCandidateBaseTree, type CandidateView } from "../lib/review-candidate-view.ts"; import { GentleAiDevBinaryOverrideError, GENTLE_AI_INSTALL_RECOVERY_COMMAND, GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS, registerGentleAiDevBinary, resolveGentleAiBinary, resolveGentleAiDevBinaryOverride, unregisterGentleAiDevBinary, type GentleAiDevBinaryOverride, } from "../lib/gentle-ai-binary.ts"; import { spawnTelemetryTrigger, type TelemetryTriggerSpawn, } from "../lib/telemetry-trigger.ts"; import { createNativeReviewCli, createNodeExecFileAdapter, isCanonicalProcessString, isNativeReviewAbandonReason, NATIVE_REVIEW_ABANDON_REASONS, isNativeReviewUnachievableVerbRefused, nativeReviewAbandonAuthorization, nativeReviewLegacyAliasRepairAuthorization, nativeReviewLegacyQuarantineAuthorization, nativeReviewReconcileAuthorization, nativeReviewRecoverAuthorization, NATIVE_REVIEW_AUTHORITY_STATUS, type NativeReviewAuthorityEntry, NativeReviewCliError, nativeUntrackedSelection, NativeReviewConsentBindingError, NativeReviewConsentRequiredError, NativeReviewIntegrationError, NATIVE_REVIEW_ERROR_CODE, NATIVE_REVIEW_OPERATION, NATIVE_REVIEW_LEGACY_QUARANTINE, NATIVE_REVIEW_LEGACY_ALIAS_REPAIR, NATIVE_REVIEW_MODE_OPERATION, NATIVE_REVIEW_MODE_SOURCE, NATIVE_REVIEW_RECONCILE_ANOMALIES, sanitizeForeignNativeReviewDiagnostics, type NativeReviewCli, type NativeIntendedUntrackedSelectionSubmission, type NativeReviewAcknowledgeApprovedOutcome, type NativeReviewAcknowledgeApprovedRequest, type NativeTargetStatusRequest, type NativeReviewModeOperation, type NativeReviewModeSource, type NativeReviewModeStatus, type NativeReviewProcessDiagnostics, type NativeReviewUnachievableLensCaptureArtifact, type NativeStartResult, type NativeReviewAssessRequest, type ExecFileAdapter, type ExecFileResult, } from "../lib/native-review-cli.ts"; import { verificationPlan, isSmallWriterProfile, resolveWriterProfile, decodeAgentRiskEscalation, verificationRisk, HIGH_RISK_ITEMS, RDD_LINE, WRITER_PROFILE, VERIFICATION_TIER, NATIVE_REVIEW_OUTCOME, type RddLine, type VerificationTier, type ReviewAssessmentV1, type ReviewAssessmentNextTransition, type ReviewAssessmentReason, type ReviewDueReason, type NativeReviewOutcome, type WriterProfile, type AgentRiskEscalation, } from "../lib/review-risk-assessment.ts"; import { assertReviewApprovedAcknowledgementExecuteV1, decodeReviewLastEventClosureV1, type ReviewCollectInputV3, type ReviewConsentEnvelope, type ReviewLastEventClosureBinding, type ReviewLastEventClosureV1, type ReviewStatusV3, } from "../lib/review-integration-v2.ts"; import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts"; import { registerYoloSessionPolicy, updateYoloPrompt } from "../lib/yolo-session-policy.ts"; import { AutoPermissionRuntime, updateAutoPrompt, type AutoRuntimeClassifier } from "../lib/auto-permission-runtime.ts"; import { AutoPermissionClassifier } from "../lib/auto-permission-classifier.ts"; import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts"; import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts"; import { captureReviewSessionIdentity, grantReviewSessionPermission, hasReviewSessionPermission, resolveCanonicalGitRepositoryIdentity, revokeReviewSessionPermission, reviewSessionPermissionEpoch, revokeReviewSessionPermissionsForSession, sameReviewSessionIdentity, type ReviewSessionIdentity, } from "../lib/review-session-standing-permission.ts"; import { herdrSetupHint } from "../lib/herdr-activity.ts"; import { isInteractiveMode } from "../lib/rpc-host.ts"; const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations"; const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const ASSETS_DIR = join(PACKAGE_ROOT, "assets"); function gentlePiAgentHome(): string { return resolveGentlePiAgentHome(); } function packageAssetAudit(owner: PackageAssetOwner): { stale: number; overrides: number } { let stale = 0; let overrides = 0; for (const [assetSubdir, installedSubdir, ownershipPrefix] of [ ["agents", "agents", "agents"], ["chains", "chains", "chains"], ["support", join("gentle-ai", "support"), "gentle-ai/support"], ] as const) { const assetDir = join(ASSETS_DIR, assetSubdir); if (!existsSync(assetDir)) continue; for (const entry of readdirSync(assetDir, { withFileTypes: true })) { if (!entry.isFile() || getPackageAssetOwner(`${ownershipPrefix}/${entry.name}`) !== owner) continue; const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name); try { if (!existsSync(installedPath)) { stale += 1; continue; } if ( !isPackageManagedSddAsset( installedPath, `${ownershipPrefix}/${entry.name}`, ) ) { overrides += 1; continue; } const packaged = readFileSync(join(assetDir, entry.name), "utf8"); const installed = readFileSync(installedPath, "utf8"); const comparablePackaged = assetSubdir === "agents" ? updateFrontmatterRouting(packaged, undefined) : packaged; const comparableInstalled = assetSubdir === "agents" ? updateFrontmatterRouting(installed, undefined) : installed; if (comparablePackaged !== comparableInstalled) { stale += 1; } } catch { stale += 1; } } } return { stale, overrides }; } function packageAssetDiagnosticLines(cwd: string): string[] { return (["delegation", "review"] as const).flatMap((owner) => { const label = owner; const { stale, overrides } = packageAssetAudit(owner); const local = localAgentOverrideCount(cwd, owner); const lines = [`${stale > 0 ? "warn" : "pass"}: Global ${label} assets stale: ${stale} file(s)`]; if (stale > 0) { lines[0] += ` — run /gentle:install-${owner} --force to refresh managed assets`; } if (overrides > 0) { lines.push(`info: Global ${label} user overrides: ${overrides} file(s); preserved, not package drift`); } if (local > 0) { lines.push(`warn: Active ${label} agent overrides: ${local} file(s) — active non-builtin ${label} agents shadow package assets; keep only intentional overrides`); } return lines; }); } function localAgentOverrideCount(cwd: string, owner: PackageAssetOwner): number { const packageSddAgentsDir = join(ASSETS_DIR, "agents"); const packageSddAgentNames = new Set( listAgentsFromDir(packageSddAgentsDir, "builtin") .filter((agent) => getPackageAssetOwner( `agents/${relative(packageSddAgentsDir, agent.filePath).split(sep).join("/")}`, ) === owner, ) .map((agent) => agent.name), ); let count = 0; for (const { dir, source, packageManaged } of discoverableNonBuiltinAgentRoots(cwd)) { if (packageManaged || !existsSync(dir)) continue; for (const agent of listAgentsFromDir(dir, source)) { if (packageSddAgentNames.has(agent.name)) count += 1; } } return count; } // --------------------------------------------------------------------------- // Background subagents policy — project > global > env > default off // // The pure resolver (parseBackgroundSubagentsPolicyFile, // resolveBackgroundSubagentsPolicy, loadBackgroundSubagentsPolicy, and their // types/constants) lives in lib/background-subagents-policy.ts so the // runtime side (extensions/gentle-agents.ts) can read the effective policy // without importing the pi extension surface. Everything below this point // (capability probing, report rendering, the global-file writer) stays here // because it is specific to this extension's UI-facing surface. // --------------------------------------------------------------------------- type BackgroundSubagentsCapability = "ready" | "absent"; interface BackgroundSubagentsRendering { policy: BackgroundSubagentsPolicy; capability: BackgroundSubagentsCapability; singleShot?: boolean; } const DEFAULT_BACKGROUND_SUBAGENTS_RENDERING: BackgroundSubagentsRendering = { policy: "off", capability: "absent", }; /** Write the global policy file, creating the config home when needed. */ function writeGlobalBackgroundSubagentsPolicy( policy: BackgroundSubagentsPolicy, configHome: string = gentleAiConfigHome(), ): string { const path = join(configHome, BACKGROUND_SUBAGENTS_FILE); mkdirSync(configHome, { recursive: true }); writeFileSync( path, `${JSON.stringify({ schema: BACKGROUND_SUBAGENTS_SCHEMA, policy }, null, 2)}\n`, ); return path; } function describeBackgroundSubagentsSource( resolution: BackgroundSubagentsResolution, ): string { switch (resolution.source) { case "project_file": return `project file ${resolution.projectFile}`; case "global_file": return `global file ${resolution.globalFile}`; case "environment": return "GENTLE_PI_BACKGROUND_SUBAGENTS"; default: return "built-in default"; } } /** * Report the effective policy, the source that decided it, and the resolved * capability, plus whatever the user needs to know about the sources that did * NOT decide. `wrote` names a policy this invocation just wrote to the global * file; a write that a higher-priority file outranks must never be reported as * if it had taken effect. */ function renderBackgroundSubagentsReport( resolution: BackgroundSubagentsResolution, capability: BackgroundSubagentsCapability, wrote?: BackgroundSubagentsPolicy, ): { message: string; type: "info" | "warning" } { const lines = [ `background subagents: ${resolution.policy} (decided by ${describeBackgroundSubagentsSource(resolution)}; capability: ${capability})`, ]; if (wrote !== undefined) { lines.push(`Wrote ${wrote} to the global file ${resolution.globalFile}.`); } if (resolution.malformed) { const path = resolution.source === "project_file" ? resolution.projectFile : resolution.globalFile; lines.push( `${path} is present but malformed, so the policy fails closed to off and no lower-priority source is consulted.`, ); } const outranksTheWrite = wrote !== undefined && resolution.source === "project_file"; if (outranksTheWrite) { lines.push( `That global write does not take effect here: the project file ${resolution.projectFile} outranks it. Edit or remove that project file to let the global setting decide.`, ); } else if ( wrote === undefined && resolution.source === "project_file" && resolution.globalFileExists ) { lines.push( `The global file ${resolution.globalFile} exists but is outranked by that project file.`, ); } if (resolution.envValue !== undefined && resolution.source !== "environment") { lines.push( resolution.envValue === "on" || resolution.envValue === "off" ? `GENTLE_PI_BACKGROUND_SUBAGENTS=${resolution.envValue} is set, but both files outrank it and it outranks the built-in default; it decides only when neither file exists.` : `GENTLE_PI_BACKGROUND_SUBAGENTS="${resolution.envValue}" is not a recognized value ("on" or "off"), so it is ignored.`, ); } lines.push( "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.", ); return { message: lines.join("\n"), type: resolution.malformed || outranksTheWrite ? "warning" : "info", }; } const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const; const SUBAGENT_RUN_TOOL = "subagent_run"; const JUDGMENT_DAY_FIX_AGENT_NAME = "jd-fix-agent"; const JUDGMENT_DAY_ACTIVATION_HEADING = "## Judgment Day activation"; const JUDGMENT_DAY_ACTIVATION_SENTENCE = "User explicitly requested Judgment Day."; const JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING = "## Exact authorized severe IDs"; const JUDGMENT_DAY_CORRECTION_BATCH_HEADING = "## Judgment Day correction batch"; const JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING = "## Exact frozen finding rows"; function hasJudgmentDayFixAgentReference(input: Record): boolean { return input.agent === JUDGMENT_DAY_FIX_AGENT_NAME || (Array.isArray(input.agent) && input.agent.includes(JUDGMENT_DAY_FIX_AGENT_NAME)) || input.agents === JUDGMENT_DAY_FIX_AGENT_NAME || (Array.isArray(input.agents) && input.agents.includes(JUDGMENT_DAY_FIX_AGENT_NAME)); } function canonicalJudgmentDaySectionBodies(value: unknown, heading: string): string[][] { if (typeof value !== "string") return []; const headingPattern = new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "gm"); return [...value.matchAll(headingPattern)].map((match) => { const body = value.slice((match.index ?? 0) + match[0].length); const nextHeading = body.search(/^ {0,3}#{1,6} /m); return body .slice(0, nextHeading === -1 ? undefined : nextHeading) .split(/\r?\n/) .filter((line) => line.length > 0); }); } const JUDGMENT_DAY_SEVERE_ID_ENTRY = /^- `(JD-[A-Z][A-Z0-9]*-\d+)`$/; const JUDGMENT_DAY_CORRECTION_ROUND_ENTRY = /^Round: [12] of 2\.$/; const JUDGMENT_DAY_FROZEN_LEDGER_SHA256_ENTRY = /^Frozen ledger SHA-256: `([0-9a-f]{64})`$/; const JUDGMENT_DAY_FROZEN_ROW_FIELDS = [ "id", "lens", "location", "severity", "status_at_freeze", "evidence_class", "evidence_claim", ] as const; const JUDGMENT_DAY_FIX_SECTION_HEADINGS = [ JUDGMENT_DAY_ACTIVATION_HEADING, JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING, JUDGMENT_DAY_CORRECTION_BATCH_HEADING, JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING, "## Allowed edit surfaces", ] as const; function canonicalJudgmentDaySevereIds(entries: readonly string[]): string[] | undefined { const ids = entries.map((entry) => entry.match(JUDGMENT_DAY_SEVERE_ID_ENTRY)?.[1]); return ids.length > 0 && ids.every((id): id is string => id !== undefined) && new Set(ids).size === ids.length ? ids : undefined; } function canonicalJudgmentDayCorrectionBatchHash(entries: readonly string[]): string | undefined { if (entries.length !== 2 || !JUDGMENT_DAY_CORRECTION_ROUND_ENTRY.test(entries[0]!)) return undefined; return entries[1]!.match(JUDGMENT_DAY_FROZEN_LEDGER_SHA256_ENTRY)?.[1]; } function isNonEmptyJudgmentDayFrozenField(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } function parseCanonicalJudgmentDayFrozenRows( entries: readonly string[], authorizedIds: readonly string[], ): Record[] | undefined { if (entries.length !== authorizedIds.length) return undefined; const rows: Record[] = []; const rowIds: string[] = []; for (const entry of entries) { let row: unknown; try { row = JSON.parse(entry) as unknown; } catch { return undefined; } if (!isRecord(row)) return undefined; const keys = Object.keys(row); if (keys.length !== JUDGMENT_DAY_FROZEN_ROW_FIELDS.length || !JUDGMENT_DAY_FROZEN_ROW_FIELDS.every((field) => field in row) || !isNonEmptyJudgmentDayFrozenField(row.id) || !JUDGMENT_DAY_SEVERE_ID_ENTRY.test(`- \`${row.id}\``) || row.lens !== "judgment-day" || !isNonEmptyJudgmentDayFrozenField(row.location) || (row.severity !== "BLOCKER" && row.severity !== "CRITICAL") || row.status_at_freeze !== "open" || !isNonEmptyJudgmentDayFrozenField(row.evidence_class) || !isNonEmptyJudgmentDayFrozenField(row.evidence_claim) ) return undefined; rows.push(row); rowIds.push(row.id); } return new Set(rowIds).size === rowIds.length && rowIds.every((id, index) => id === authorizedIds[index]) ? rows : undefined; } function hasCanonicalJudgmentDayFixSectionOrder(...values: unknown[]): boolean { const dispatch = values.filter((value): value is string => typeof value === "string").join("\n"); let previousPosition = -1; for (const heading of JUDGMENT_DAY_FIX_SECTION_HEADINGS) { const matches = [...dispatch.matchAll(new RegExp(`^${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "gm"))]; if (matches.length !== 1 || (matches[0]!.index ?? -1) <= previousPosition) return false; previousPosition = matches[0]!.index ?? -1; } return true; } function hasCanonicalJudgmentDayFixActivation(...values: unknown[]): boolean { const hasMalformedHeading = values.some((value) => typeof value === "string" && value.split(/\r?\n/).some((line) => { const candidate = line.match(/^ {0,3}#{1,6} (Judgment Day activation|Exact authorized severe IDs|Judgment Day correction batch|Exact frozen finding rows)[ \t]*$/i); return candidate !== null && line !== JUDGMENT_DAY_ACTIVATION_HEADING && line !== JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING && line !== JUDGMENT_DAY_CORRECTION_BATCH_HEADING && line !== JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING; }), ); const activationBodies = values.flatMap((value) => canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_ACTIVATION_HEADING), ); const severeIdBodies = values.flatMap((value) => canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_AUTHORIZED_SEVERE_IDS_HEADING), ); const correctionBatchBodies = values.flatMap((value) => canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_CORRECTION_BATCH_HEADING), ); const frozenFindingRowBodies = values.flatMap((value) => canonicalJudgmentDaySectionBodies(value, JUDGMENT_DAY_FROZEN_FINDING_ROWS_HEADING), ); const authorizedIds = severeIdBodies.length === 1 ? canonicalJudgmentDaySevereIds(severeIdBodies[0]!) : undefined; const correctionBatchHash = correctionBatchBodies.length === 1 ? canonicalJudgmentDayCorrectionBatchHash(correctionBatchBodies[0]!) : undefined; const frozenFindingRows = authorizedIds !== undefined && frozenFindingRowBodies.length === 1 ? parseCanonicalJudgmentDayFrozenRows(frozenFindingRowBodies[0]!, authorizedIds) : undefined; return !hasMalformedHeading && hasCanonicalJudgmentDayFixSectionOrder(...values) && activationBodies.length === 1 && activationBodies[0]!.length === 1 && activationBodies[0]![0] === JUDGMENT_DAY_ACTIVATION_SENTENCE && authorizedIds !== undefined && correctionBatchHash !== undefined && frozenFindingRows !== undefined && correctionBatchHash === canonicalHash(frozenFindingRows); } const JUDGMENT_DAY_FIX_DISPATCH_REJECTION = "Judgment Day fix dispatch requires exactly one `agent: \"jd-fix-agent\"`, one exact `## Judgment Day activation` section containing only `User explicitly requested Judgment Day.`, one non-empty unique canonical `## Exact authorized severe IDs` section, one exact `## Judgment Day correction batch` section with `Round: 1 of 2.` or `Round: 2 of 2.` and the matching canonical lowercase SHA-256 of one exact `## Exact frozen finding rows` section whose BLOCKER/CRITICAL open Judgment Day rows equal the authorized IDs in the same order, and the existing exact `## Allowed edit surfaces` guard. The parent must provide the canonical bounded dispatch; do not infer activation, authorization, or frozen findings."; function rejectInvalidJudgmentDayFixDispatch(input: unknown): { block: true; reason: string } | undefined { if (!isRecord(input) || !hasJudgmentDayFixAgentReference(input)) return undefined; if ( input.agent === JUDGMENT_DAY_FIX_AGENT_NAME && !("agents" in input) && hasCanonicalJudgmentDayFixActivation(input.task, input.context) && hasTaskScopedAllowedEditSurfaces(input.task, input.context) ) { return undefined; } return { block: true, reason: JUDGMENT_DAY_FIX_DISPATCH_REJECTION }; } /** * Roots where an installed subagents package may live. These are the same * roots builtinAgentDirs() walks, minus its `/agents` suffix. * * builtinAgentDirs() looks for markdown agent definitions, which the package * legitimately may not ship. Capability is a different question, so it must * not reuse that path: pi-subagents-j0k3r v1.5.2 ships index.ts, src/, skills/ * and scripts/ and no agents/ directory at all, so an agents-dir probe reports * "absent" on every real install and leaves the background policy inert. */ function subagentsPackageRoots(cwd: string): string[] { return SUBAGENTS_PACKAGE_NAMES.flatMap((packageName) => [ join(PACKAGE_ROOT, "..", packageName), join(cwd, ".pi", "npm", "node_modules", packageName), join(homedir(), ".local", "lib", "node_modules", packageName), ]); } /** A package root counts as installed only when it carries its own manifest. */ function hasInstalledSubagentsPackage(cwd: string): boolean { return subagentsPackageRoots(cwd).some((root) => existsSync(join(root, "package.json")), ); } function hasSubagentRunTool(activeTools: readonly string[]): boolean { return activeTools.some( (name) => name === SUBAGENT_RUN_TOOL || name.endsWith(`.${SUBAGENT_RUN_TOOL}`), ); } /** * Read the live pi tool registry, or undefined when it carries no signal. * * An absent handle, a non-array result, a throwing registry, and an empty list * are all "no signal" rather than "no subagents": reporting absent from an * uninformative registry would reproduce the very defect this probe fixes. */ function readActiveToolNames(pi: unknown): readonly string[] | undefined { try { const getActiveTools = (pi as { getActiveTools?: () => unknown }) ?.getActiveTools; if (typeof getActiveTools !== "function") return undefined; const tools = getActiveTools.call(pi); if (!Array.isArray(tools)) return undefined; const names = tools .map((tool) => typeof tool === "string" ? tool : isRecord(tool) && typeof tool.name === "string" ? tool.name : "", ) .filter((name) => name.length > 0); return names.length > 0 ? names : undefined; } catch { return undefined; } } /** * `subagent_run` availability probe. * * The live tool registry answers the question directly and wins whenever it * carries any signal. Without it -- prompt rendering outside a session, or a * runtime with no getActiveTools -- capability falls back to the presence of * an installed subagents package. */ function resolveBackgroundSubagentsCapability( cwd: string, activeTools?: readonly string[], ): BackgroundSubagentsCapability { try { if (activeTools !== undefined && activeTools.length > 0) { return hasSubagentRunTool(activeTools) ? "ready" : "absent"; } return hasInstalledSubagentsPackage(cwd) ? "ready" : "absent"; } catch { return "absent"; } } function renderBackgroundSubagentsStatusLine( background: BackgroundSubagentsRendering, ): string { if (background.singleShot) return `Background subagent policy: off (single-shot mode)`; return `Background subagent policy: ${background.policy} (capability: ${background.capability})`; } // gentle-shell#1731 T27: `pi -p` and `pi --mode json` run one prompt and then // dispose the runtime, so background results can never arrive. Mirrors // isSingleShotMode in extensions/gentle-agents.ts, which rejects the launch. function isSingleShotHostMode(mode: string | undefined): boolean { return mode === "print" || mode === "json"; } /** * A `status` object is only trusted when `effective` is exactly `on`/`off` * and `source` is one of the exported `NATIVE_REVIEW_MODE_SOURCE` values. * `resolveRddModeStatus` only ever produces a value shaped like this, but * `renderRddStatusLine` validates at the render boundary anyway -- a * malformed or partial object (a bad decode upstream, a future field * change, a hand-built test fixture) must fail closed to the "unknown" * line, never render an unrecognized value verbatim. */ function isValidRddModeStatus( status: NativeReviewModeStatus | undefined, ): status is NativeReviewModeStatus { if (status === undefined || status === null || typeof status !== "object") return false; if (status.effective !== "on" && status.effective !== "off") return false; const validSources: readonly string[] = Object.values(NATIVE_REVIEW_MODE_SOURCE); return typeof status.source === "string" && validSources.includes(status.source); } /** * Renders the receipt-driven-development status line rendered next to * `Background subagent policy` (gentle-pi#661). Renders the fail-closed * "unknown" line whenever `status` is not a validated on/off status with a * recognized source -- `undefined` (the native reader could not answer: * binary absent, timed out, aborted, or a native CLI failure) or any * malformed/partial object. This is a pure render, never a native call, so * it never throws. */ function renderRddStatusLine( status: NativeReviewModeStatus | undefined, ): string { return isValidRddModeStatus(status) ? `Receipt-driven development: ${status.effective} (decided by ${status.source})` : "Receipt-driven development: unknown (native status unavailable)"; } // The primary-session prompt awaits this on every non-named agent // start, so an unbounded native read would stall session start behind a // hung `gentle-ai` child (gentle-pi#661 native-review escalation). The // production call site (before_agent_start) passes // `AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS)`; resolveRddModeStatus also // races the call against that same signal itself (not just the CLI's own // signal handling) so an abort is honored even against a stub/mock // reviewMode that ignores its `signal` argument, as tests do. const RDD_STATUS_TIMEOUT_MS = 3000; // Repeated session/agent-start builds within this window reuse the last // resolved status instead of respawning the native binary. Deliberately // memoizes a failed/undefined resolution too (a sustained outage should not // retry every agent start), trading a slower recovery signal for far fewer // spawns; the one-shot notify below still surfaces a sustained outage. const RDD_STATUS_MEMO_TTL_MS = 30_000; const rddStatusMemo = new Map(); /** @internal test seam: clears the per-cwd RDD status memo. */ function clearRddStatusMemoForTesting(): void { rddStatusMemo.clear(); rddStatusUnavailableWarned = false; } // gentle-pi#668 (corrected): last-known outcome for ONE candidate, keyed by // repository realpath AND targetIdentity -- never repository alone, or one // candidate's outcome would leak into every other candidate's `assess` call. // Only declined/unavailable are ever written (from ANSWER_CONSENT); `closed` // is never written here -- it is derived per assess call only from that // call's own native `candidate.consumed` evidence (gentle-pi#1175). A missing // entry reads back `undefined`, treated as `unknown` (fail closed, like `off`). const nativeReviewOutcomeByCandidate = new Map(); function nativeReviewOutcomeMemoKey(cwd: string, targetIdentity: string): string { try { return `${realpathSync(cwd)}\u0000${targetIdentity}`; } catch { return `${cwd}\u0000${targetIdentity}`; } } function recordNativeReviewOutcome(cwd: string, targetIdentity: string, outcome: "declined" | "unavailable"): void { nativeReviewOutcomeByCandidate.set(nativeReviewOutcomeMemoKey(cwd, targetIdentity), outcome); } /** Returns the recorded outcome for exactly this candidate, or `undefined` when none is recorded. */ function readNativeReviewOutcome(cwd: string, targetIdentity: string): "declined" | "unavailable" | undefined { return nativeReviewOutcomeByCandidate.get(nativeReviewOutcomeMemoKey(cwd, targetIdentity)); } /** @internal test seam: clears the per-candidate native review outcome memo. */ function clearNativeReviewOutcomeMemoForTesting(): void { nativeReviewOutcomeByCandidate.clear(); } // Best-effort current-candidate target identity for `assess` (gentle-pi#668): // reuses `targetStatus`, a native call this tool already makes elsewhere. async function readCurrentTargetIdentityBestEffort( nativeReviewCli: Pick | null | undefined, cwd: string, signal?: AbortSignal, ): Promise { if (nativeReviewCli?.targetStatus === undefined) return undefined; try { const status = await nativeReviewCli.targetStatus({ cwd, ...(signal === undefined ? {} : { signal }) }); return status.applicability === "current_target" ? status.targetIdentity : undefined; } catch { return undefined; } } function rddAbortRejection(signal: AbortSignal): Promise { return new Promise((_resolve, reject) => { if (signal.aborted) { reject(signal.reason ?? new Error("aborted")); return; } signal.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), { once: true }); }); } async function readRddModeStatusOnce( nativeReviewCli: Pick | null | undefined, cwd: string, signal?: AbortSignal, ): Promise { const read = await readRddModeStatusWithCause(nativeReviewCli, cwd, signal); return "status" in read ? read.status : undefined; } // gentle-shell#2250: the same read, keeping why it failed so the one-shot // unavailable notice can tell a timeout from a native refusal (for example a // repository on a network share that Git or gentle-ai rejects by ownership). async function readRddModeStatusWithCause( nativeReviewCli: Pick | null | undefined, cwd: string, signal?: AbortSignal, ): Promise<{ readonly status: NativeReviewModeStatus } | { readonly cause: string }> { if (!nativeReviewCli?.reviewMode) return { cause: "the native gentle-ai review CLI is not available" }; try { const call = nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, signal }); const result = signal === undefined ? await call : await Promise.race([call, rddAbortRejection(signal)]); return { status: result.status }; } catch (error) { return { cause: describeRddStatusFailure(error, signal) }; } } const RDD_STATUS_CAUSE_LIMIT = 300; function describeRddStatusFailure(error: unknown, signal: AbortSignal | undefined): string { const command = "native `gentle-ai review mode status`"; if (signal?.aborted || (error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.TIMEOUT)) { return `${command} did not answer within ${RDD_STATUS_TIMEOUT_MS} ms`; } if (error instanceof NativeReviewCliError) { const exit = error.diagnostics.exit_code === undefined ? "" : `, exit ${error.diagnostics.exit_code}`; const detail = (error.diagnostics.stderr ?? "").replace(/\s+/g, " ").trim() || error.message; return `${command} failed (${error.code}${exit}): ${boundRddStatusCause(detail)}`; } return boundRddStatusCause(error instanceof Error ? error.message : String(error)); } function boundRddStatusCause(text: string): string { const trimmed = text.replace(/[.\s]+$/, ""); return trimmed.length <= RDD_STATUS_CAUSE_LIMIT ? trimmed : `${trimmed.slice(0, RDD_STATUS_CAUSE_LIMIT - 1)}…`; } // gentle-pi#662: read-only combined native risk assessment plus the computed // verification plan (`lib/review-risk-assessment.ts`), for the `gentle_review` // tool's `assess` operation. Never throws: an unavailable/failed native // assess call (older binary without the verb, timeout, malformed response) // resolves to the `unassessable` native tier, which informs only: like a // native high, it adds no independent verifier without an agent escalation. interface ReviewAssessmentPlanDetails { schema: "gentle-pi.review-assessment-plan/v1"; risk: VerificationTier; // gentle-shell#1494: the native tier before any agent escalation. `risk` is // the plan tier: high only through an escalation, else passive or medium. nativeRisk: VerificationTier; // `applied` is true whenever an escalation is present: without it the plan // would be passive or medium. agentEscalation?: { item: number; label: string; reason: string; applied: boolean }; reasons: readonly ReviewAssessmentReason[]; changedPaths: number; changedLines: number; // gentle-pi#1175: `consumed`, `reviewDue`, `reviewDueReason`, and // `nextTransition` are projected only when native reported them; an older // binary omits them and they are never fabricated here. candidate: { kind: string; baseRef: string | undefined; consumed?: boolean } | null; reviewDue?: boolean; reviewDueReason?: ReviewDueReason; // Opaque native continuation, carried verbatim. nextTransition?: ReviewAssessmentNextTransition; rddLine: RddLine; nativeReviewOutcome: NativeReviewOutcome; // gentle-pi#668/#1175: where nativeReviewOutcome came from -- explicit // (a caller-supplied declined/unavailable/unknown), derived (native // consumed evidence or the per-candidate memo for this exact candidate), // or unknown. outcome_source: NativeReviewOutcomeSource; writerProfile: WriterProfile; // gentle-pi#1175: runtime (pending mutation receipts), caller (input, // only when no runtime receipt is pending), or fallback (small). writerProfileSource: WriterProfileSource; plan: { writerSelfVerification: boolean; structuralReadbackOnly: boolean; independentVerifier: boolean; reason: string; }; } type NativeReviewOutcomeSource = "explicit" | "derived" | "unknown"; type WriterProfileSource = "runtime" | "caller" | "fallback"; /** * gentle-pi#1175: the writer profile for ASSESS. Pending runtime mutation * receipts for this root are the evidence: any writer without a recorded * model id, or any small writer (`isSmallWriterProfile`), makes the profile * small. Caller-declared writerModelId/writerEffort are consulted only when no * runtime receipt is pending, and can never override runtime evidence. An * unreadable receipt ledger fails closed to small. */ function resolveAssessWriterProfile( session: ReceiptSession | undefined, root: string, input: ReviewAssessInput, ): { writerProfile: WriterProfile; writerProfileSource: WriterProfileSource } { let pending: ReturnType = []; try { if (session !== undefined) pending = pendingReviewMutationProfiles(session, root); } catch { return { writerProfile: WRITER_PROFILE.SMALL, writerProfileSource: "fallback" }; } if (pending.length > 0) { const small = pending.some((profile) => profile.writerModelId === undefined || isSmallWriterProfile({ model: { id: profile.writerModelId }, thinking: profile.writerEffort })); return { writerProfile: small ? WRITER_PROFILE.SMALL : WRITER_PROFILE.LARGE, writerProfileSource: "runtime" }; } if (input.writerModelId === undefined && input.writerEffort === undefined) { return { writerProfile: WRITER_PROFILE.SMALL, writerProfileSource: "fallback" }; } return { writerProfile: resolveWriterProfile({ ...(input.writerModelId === undefined ? {} : { model: { id: input.writerModelId } }), thinking: input.writerEffort, }), writerProfileSource: "caller", }; } /** * gentle-pi#1175: the parent session's own current model and thinking level, * recorded on its direct mutation receipt. Omits whatever the runtime cannot * report, so an unknown writer stays small for ASSESS. */ function directWriterProfile( pi: Pick, ctx: Pick, ): { writerModelId?: string; writerEffort?: string } { const model = ctx.model; const modelId = typeof model?.provider === "string" && model.provider && typeof model.id === "string" && model.id ? `${model.provider}/${model.id}` : undefined; let effort: string | undefined; try { const level: unknown = pi.getThinkingLevel(); if (typeof level === "string" && level) effort = level; } catch { /* No runtime effort evidence. */ } return { ...(modelId === undefined ? {} : { writerModelId: modelId }), ...(effort === undefined ? {} : { writerEffort: effort }), }; } /** * gentle-pi#1175: closure comes only from exact native evidence. `closed` is * derived solely from THIS assessment's `candidate.consumed === true`, which * native writes only inside the approved-acknowledgement burn for that exact * target identity; a caller-declared `closed` is only a claim and, without * that corroboration, fails closed to `unknown`. Explicit declined, * unavailable, or unknown are honored (they only raise the bar), and a * declined/unavailable recorded for this exact candidate beats closure. */ function resolveAssessNativeReviewOutcome( input: ReviewAssessInput, assessment: ReviewAssessmentV1 | undefined, memoOutcome: "declined" | "unavailable" | undefined, ): { nativeReviewOutcome: NativeReviewOutcome; outcomeSource: NativeReviewOutcomeSource } { if (input.nativeReviewOutcome !== undefined && input.nativeReviewOutcome !== NATIVE_REVIEW_OUTCOME.CLOSED) { return { nativeReviewOutcome: input.nativeReviewOutcome, outcomeSource: "explicit" }; } if (memoOutcome !== undefined) return { nativeReviewOutcome: memoOutcome, outcomeSource: "derived" }; if (assessment?.candidate.consumed === true) return { nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.CLOSED, outcomeSource: "derived" }; return { nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.UNKNOWN, outcomeSource: "unknown" }; } async function resolveReviewAssessmentPlan( nativeReviewCli: Pick | null | undefined, cwd: string, input: ReviewAssessInput, signal?: AbortSignal, receiptSession?: ReceiptSession, ): Promise { if (input.baseRef !== undefined && input.committedOnly !== true) throw new Error("Review assess baseRef requires committedOnly: true"); if (input.baseRef === undefined && input.committedOnly !== undefined) throw new Error("Review assess committedOnly requires an explicit baseRef"); const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal); const rddLine: RddLine = isValidRddModeStatus(status) ? status.effective : RDD_LINE.UNKNOWN; const { writerProfile, writerProfileSource } = resolveAssessWriterProfile(receiptSession, cwd, input); let assessment: ReviewAssessmentV1 | undefined; let unassessableDetail: string | undefined; let unassessableCode = "native-assess-unavailable"; if (nativeReviewCli?.assess === undefined) { unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command."; } else { try { const request: NativeReviewAssessRequest = { cwd, ...nativeUntrackedSelection(input), ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }), ...(signal === undefined ? {} : { signal }), }; assessment = await nativeReviewCli.assess(request); } catch (error) { const nativeError = asNativeReviewCliError(error); unassessableCode = nativeError?.code ?? unassessableCode; // Only the sanitized process surface may supply native evidence. // Arbitrary thrown messages can contain argv or environment values. unassessableDetail = nativeError?.diagnostics.stderr ? `native review assess failed: ${nativeError.diagnostics.stderr}` : "native review assess failed; no sanitized stderr diagnostic is available."; } } const nativeRisk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE; // gentle-shell#1494: the plan is high only through an agent escalation // naming a Task Size high-risk item; a native high or unassessable tier // informs as medium. The native tier stays visible as `nativeRisk`. const risk = verificationRisk(nativeRisk, input.escalate); // gentle-pi#668: the memo is read only for THIS candidate's own target // identity, never repository-only. It is still needed when the caller // claims `closed`, because a recorded decline beats closure (gentle-pi#1175). const needsMemo = input.nativeReviewOutcome === undefined || input.nativeReviewOutcome === NATIVE_REVIEW_OUTCOME.CLOSED; const targetIdentity = needsMemo ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined; const memoOutcome = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity); const { nativeReviewOutcome, outcomeSource } = resolveAssessNativeReviewOutcome(input, assessment, memoOutcome); const plan = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome }); return { schema: "gentle-pi.review-assessment-plan/v1", risk, nativeRisk, ...(input.escalate === undefined ? {} : { agentEscalation: { item: input.escalate.item, label: HIGH_RISK_ITEMS[input.escalate.item], reason: input.escalate.reason, applied: risk !== verificationRisk(nativeRisk, undefined) } }), // No path is known for an unassessable candidate: omit it rather than // emitting an empty string the native reason shape forbids. reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: unassessableCode, detail: unassessableDetail }]), changedPaths: assessment?.changedPaths ?? 0, changedLines: assessment?.changedLines ?? 0, candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef, ...(assessment.candidate.consumed === undefined ? {} : { consumed: assessment.candidate.consumed }), }, ...(assessment?.reviewDue === undefined ? {} : { reviewDue: assessment.reviewDue }), ...(assessment?.reviewDueReason === undefined ? {} : { reviewDueReason: assessment.reviewDueReason }), ...(assessment?.nextTransition === undefined ? {} : { nextTransition: assessment.nextTransition }), rddLine, nativeReviewOutcome, outcome_source: outcomeSource, writerProfile, writerProfileSource, plan, }; } let rddStatusUnavailableWarned = false; // S2 (startup-home-repo-and-changes): a repository rooted at HOME, an ancestor // of it or a broad user folder makes every Git call scan the user's whole // tree. Warn once per process and repository, never per session restart. const HOME_REPOSITORY_WARNING = "Gentle Shell is running inside a Git repository rooted at your home folder (or a broad user folder). Every Git operation scans that whole tree, which makes startup and reviews very slow. Open Gentle Shell inside a project folder, or remove the stray .git if you did not create it on purpose."; const homeRepositoryWarnings = new Set(); // An unresolvable HOME or a resolver failure is unknown, not a match: the // bootstrap guard fails closed there, but startup must not warn or skip work. function homeRootedRepository(cwd: string): string | undefined { try { const home = realpathSync.native(homedir()); const root = resolveSessionWorktree(cwd, cwd)?.root; return root !== undefined && isHomeOrBroadUserDirectory(root, { home }) ? root : undefined; } catch { return undefined; } } /** * Best-effort native RDD mode status read for prompt rendering, memoized per * cwd for `RDD_STATUS_MEMO_TTL_MS`. Reuses the `reviewMode` STATUS reader * (`gentle-ai review mode status --json`, decoded to * `NativeReviewModeStatus`) that the `/gentle:review-mode` command also * calls. Never throws and never hangs past `signal`'s deadline when one is * given: an absent binary, a timed-out/aborted process, or a native CLI * failure all resolve to `undefined` unless an earlier read for the same cwd * succeeded, in which case that last known status is kept (gentle-shell#1029: * the rendered line sits in the cached prompt prefix). `ctx` is optional and * used only for a one-shot (per process) UI notice when the read is * swallowed, so a sustained native outage is observable beyond the prompt. */ async function resolveRddModeStatus( nativeReviewCli: Pick | null | undefined, cwd: string, signal?: AbortSignal, now: () => number = Date.now, ctx?: Pick, ): Promise { const nowMs = now(); const cached = rddStatusMemo.get(cwd); if (cached !== undefined && cached.expiresAt > nowMs) return cached.status; // gentle-shell#1029: the rendered line is part of the cached system-prompt // prefix. A re-read that fails or times out keeps the last successfully // read status instead of flipping the prompt to "unknown" and back, which // would invalidate the provider's prefix cache for the whole conversation. // Only a status the native reader actually reports replaces a known one. const outcome = await readRddModeStatusWithCause(nativeReviewCli, cwd, signal); const read = "status" in outcome ? outcome.status : undefined; const status = read ?? cached?.status; rddStatusMemo.set(cwd, { status, expiresAt: nowMs + RDD_STATUS_MEMO_TTL_MS }); if ("cause" in outcome && !rddStatusUnavailableWarned) { rddStatusUnavailableWarned = true; if (ctx?.hasUI) { ctx.ui.notify( `Gentle AI: receipt-driven-development status is unavailable: ${outcome.cause}. The parent prompt keeps the last known status, or renders "unknown" when none was read; this notice will not repeat this session.`, "warning", ); } } return status; } /** True only for a validated `off` line; `unknown` and `on` are never off. */ function isRddStatusLineOff(line: string | undefined): boolean { return line !== undefined && line.startsWith("Receipt-driven development: off "); } /** * gentle-shell#2195: the rendered line sits in the cached system-prompt * prefix, so a session keeps the first line it rendered and re-renders only * when the off state changes, because that also adds or drops the review * execution contract (gentle-shell#1494). An unknown-to-on flip or a * deciding-source change keeps the prefix; review tools still read the live * mode whenever they run, so the pinned text never grants or blocks anything. */ function pinRddStatusLine(pinned: string | undefined, next: string): string { return pinned !== undefined && isRddStatusLineOff(pinned) === isRddStatusLineOff(next) ? pinned : next; } /** * gentle-pi S11 T1: while the pinned line reads off, no review can start and * the orchestrator only calls ASSESS, so the primary request swaps the review * family's long guidance for this lean loadout: one short gentle_review * snippet, guideline, and description, with the capture, capture-group, and * scope declarations hidden (they stay registered and active). It follows the * pinned line, so it changes only when that line's off state flips, together * with the review execution contract; on and unknown keep the registered text. */ const RDD_OFF_REVIEW_SNIPPET = "Assess an authorized change and return its verification plan (read-only)"; const RDD_OFF_REVIEW_GUIDELINES: readonly string[] = [ 'Receipt-driven development is off, so use gentle_review only for {"operation":"assess"} (read-only): after an authorized implementation, delegated or inline, call it over the diff and follow the returned plan; pass input {"escalate":{"item":1-6,"reason":""}} only when the change touches a Task Size high-risk item, or {"baseRef":"","committedOnly":true} to assess a committed range.', "When ASSESS returns reviewDue or a native continuation, relay it unchanged but do not execute it while RDD is off; assessment metadata does not authorize STATUS, another review lifecycle operation, or enabling RDD. Continue the returned verification plan; tests, builds, and functional checks still run when applicable, and no assessment authorizes delivery.", ]; const RDD_OFF_REVIEW_DESCRIPTION = "Assess the current changes, or a committed range, and return the verification plan. Read-only; review outcomes never authorize delivery: commit, push, pull-request, and release commands follow ordinary repository policy."; const RDD_OFF_HIDDEN_REVIEW_TOOLS: readonly string[] = ["gentle_review_scope", "gentle_review_capture_group", "gentle_review_capture"]; interface ReviewToolPromptOptions { toolSnippets?: Record; toolGuidelines?: Record; } /** Replaces gentle_review's snippet and guidelines in this run's prompt options only. */ function applyRddOffReviewPrompt(options: ReviewToolPromptOptions | null | undefined): void { if (options?.toolSnippets?.gentle_review !== undefined) options.toolSnippets.gentle_review = RDD_OFF_REVIEW_SNIPPET; if (options?.toolGuidelines?.gentle_review !== undefined) options.toolGuidelines.gentle_review = [...RDD_OFF_REVIEW_GUIDELINES]; } /** Resolves and renders the RDD status line for a production call site in one call. */ async function resolveRddStatusLine( nativeReviewCli: Pick | null | undefined, cwd: string, signal?: AbortSignal, now: () => number = Date.now, ctx?: Pick, ): Promise { return renderRddStatusLine(await resolveRddModeStatus(nativeReviewCli, cwd, signal, now, ctx)); } // Rendered prompts are memoized per background policy/capability/RDD-status // key for the process lifetime; the assets bytes themselves are read once // per key. `rddStatusLine` defaults to the "unknown" fallback line (the // longest of the three renderable forms), not "", so the default no-argument // render IS the worst case the canonical 8 KiB budget in // tests/orchestrator-budget.test.ts measures; assets/orchestrator.md is // sized with that worst case already included. Production still resolves // and passes the real line (on/off/unknown) via resolveRddStatusLine. const orchestratorPromptCache = new Map(); function getOrchestratorPrompt( cwd: string = process.cwd(), activeTools?: readonly string[], rddStatusLine: string = renderRddStatusLine(undefined), hostMode?: string, ): string { const background: BackgroundSubagentsRendering = { policy: loadBackgroundSubagentsPolicy(cwd), capability: resolveBackgroundSubagentsCapability(cwd, activeTools), singleShot: isSingleShotHostMode(hostMode), }; const cacheKey = `${background.policy}:${background.capability}:${background.singleShot}:${rddStatusLine}`; let prompt = orchestratorPromptCache.get(cacheKey); if (prompt === undefined) { prompt = renderOrchestratorPrompt(ASSETS_DIR, background, rddStatusLine); orchestratorPromptCache.set(cacheKey, prompt); } return prompt; } function renderOrchestratorPrompt( assetsDir: string, background: BackgroundSubagentsRendering = DEFAULT_BACKGROUND_SUBAGENTS_RENDERING, rddStatusLine: string = renderRddStatusLine(undefined), ): string { const backgroundPolicyBlock = `${renderBackgroundSubagentsStatusLine(background)}\n${rddStatusLine}`; return readFileSync(join(assetsDir, "orchestrator.md"), "utf8") .replaceAll("{{GENTLE_PI_ASSETS_ROOT}}", assetsDir) .replaceAll( "{{GENTLE_PI_BACKGROUND_POLICY}}", backgroundPolicyBlock, ) .trim(); } // gentle-pi#560 / gentle-ai#4056, #4057: Gentle AI stopped writing a // runtime-specific review execution contract into Pi's generated // APPEND_SYSTEM composition on 2026-08-01. This package now injects the // mirrored provider contract bundle's own `orchestration/pi.md` text // instead, read once from the package-local mirror // (contracts/review-provider-contract-mirror/) and cached as the fully // rendered fragment for the process lifetime. It is deliberately NOT folded // into getOrchestratorPrompt/orchestratorPromptCache: that core prompt is // pinned at an 8192-byte budget (tests/orchestrator-budget.test.ts). const PROVIDER_CONTRACT_MIRROR_ROOT = join(PACKAGE_ROOT, "contracts", "review-provider-contract-mirror"); const PROVIDER_CONTRACT_LOCK_FILE = "provider-contract.lock.json"; const PI_MIRROR_RUNTIME = "pi"; let reviewContractPromptFragmentCache: string | null | undefined; let reviewContractPromptMissingWarned = false; // Reads one mirrored bundle file after verifying its bytes against the lock's digest (gentle-ai R1/R3). function readVerifiedMirrorFile(mirrorRoot: string, bundlePath: string): { contractSemver: string; text: string } | null { try { const lockPath = join(mirrorRoot, PROVIDER_CONTRACT_LOCK_FILE); const lock = JSON.parse(readFileSync(lockPath, "utf8")) as { contract_semver?: unknown; entries?: Record; }; if (typeof lock.contract_semver !== "string" || lock.contract_semver === "") return null; const expectedSha256 = lock.entries?.[bundlePath]; if (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256)) return null; const filePath = join(mirrorRoot, `v${lock.contract_semver}`, "bundle", ...bundlePath.split("/")); const rawBytes = readFileSync(filePath); const actualSha256 = createHash("sha256").update(rawBytes).digest("hex"); if (!timingSafeEqual(Buffer.from(expectedSha256, "hex"), Buffer.from(actualSha256, "hex"))) return null; return { contractSemver: lock.contract_semver, text: rawBytes.toString("utf8") }; } catch { return null; } } // Verifies the mirrored orchestration/pi.md bytes against the lock's digest before injection (gentle-ai R1/R3). function readMirroredReviewContractFragment(mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT): string | null { const file = readVerifiedMirrorFile(mirrorRoot, `orchestration/${PI_MIRROR_RUNTIME}.md`); if (file === null) return null; const text = file.text.trim(); if (text.length === 0) return null; return `## Gentle AI review execution contract (mirrored provider bundle ${file.contractSemver})\n\n${text}`; } // gentle-ai R3: the harness ODD steps 1-7, the phase-reporting line, and the // resume line come from the mirrored bundle's routing/pi.md (contract >= 1.4.0), // the ODD routing block Gentle AI renders for Pi, instead of a hand-copied // literal. The bundle's blank lines between them are not carried into the // prompt. Like assets/orchestrator.md, this is package-shipped prompt source: // a missing, tampered, or reshaped file fails closed instead of rendering a // harness without its workflow. const ODD_PROTOCOL_HEADING = "### ODD protocol (MANDATORY, in this order, on every request)"; const ODD_PROTOCOL_STEP_COUNT = 7; let oddProtocolStepsCache: string | undefined; function extractOddProtocolLines(routing: string): string { const lines = routing.split("\n"); const start = lines.indexOf(ODD_PROTOCOL_HEADING); const section: string[] = []; for (const line of start === -1 ? [] : lines.slice(start + 1)) { if (line.startsWith("#")) break; if (line !== "") section.push(line); } const shapeHolds = section.length === ODD_PROTOCOL_STEP_COUNT + 2 && section.slice(0, ODD_PROTOCOL_STEP_COUNT).every((line, index) => line.startsWith(`${index + 1}. **`)) && section[ODD_PROTOCOL_STEP_COUNT]!.startsWith("Phase reporting: ") && section[ODD_PROTOCOL_STEP_COUNT + 1]!.startsWith("Resume an interrupted feature "); if (!shapeHolds) { throw new Error(`The mirrored provider bundle's routing/${PI_MIRROR_RUNTIME}.md ODD protocol section has an unexpected shape: expected ${JSON.stringify(ODD_PROTOCOL_HEADING)} followed by steps 1-${ODD_PROTOCOL_STEP_COUNT}, phase reporting, and resume.`); } return section.join("\n"); } function readMirroredOddProtocolSteps(mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT): string { const file = readVerifiedMirrorFile(mirrorRoot, `routing/${PI_MIRROR_RUNTIME}.md`); if (file === null) { throw new Error(`Gentle Shell cannot render its ODD steps: the mirrored provider bundle's routing/${PI_MIRROR_RUNTIME}.md is missing, unreadable, or fails digest verification. Reinstall gentle-pi.`); } return extractOddProtocolLines(file.text); } function oddProtocolSteps(): string { oddProtocolStepsCache ??= readMirroredOddProtocolSteps(); return oddProtocolStepsCache; } function loadReviewContractPromptFragment( ctx: Pick, mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT, ): string | null { if (reviewContractPromptFragmentCache === undefined) { reviewContractPromptFragmentCache = readMirroredReviewContractFragment(mirrorRoot); } if (reviewContractPromptFragmentCache === null && !reviewContractPromptMissingWarned) { reviewContractPromptMissingWarned = true; if (ctx.hasUI) { ctx.ui.notify( "Gentle AI review execution contract is unavailable: the mirrored provider bundle is missing, unreadable, or fails digest verification. Review preflight instructions will not be injected this session.", "warning", ); } } return reviewContractPromptFragmentCache; } async function pathExists(path: string): Promise { try { await access(path); return true; } catch { return false; } } type PersonaMode = "gentleman" | "neutral"; const PERSONA_OPTIONS = ["gentleman", "neutral"] as const; const GENTLEMAN_PERSONA_PROMPT = `Persona: - Be direct, technical, and concise. - Always respond in the same language the user writes in. Judge it by the user's latest message, never by earlier context or a compaction summary, and never default to Spanish: a message in English gets a reply in English. - When the user writes Spanish, answer in natural Rioplatense Spanish with voseo. - Act as a senior architect and teacher: concepts before code, no shortcuts. - Treat AI as a tool directed by the human; never present yourself as a default chatbot. - Push back when the user asks for code without enough context or understanding. - Correct errors directly, explain why, and show the better path.`; const NEUTRAL_PERSONA_PROMPT = `Persona: - Be direct, technical, concise, warm, and professional. - Always respond in the same language the user writes in. Judge it by the user's latest message, never by earlier context or a compaction summary, and never default to Spanish: a message in English gets a reply in English. - Do not use slang or regional expressions. - When the user writes Spanish, use neutral/professional Spanish. Do NOT use voseo or any regional conjugations, regular or irregular: use tú or usted forms, never vos forms (tienes, not tenés; ajustas, not ajustás; vives, not vivís; mira, not mirá; haz, not hacé). - Act as a senior architect and teacher: concepts before code, no shortcuts. - Treat AI as a tool directed by the human; never present yourself as a default chatbot. - Push back when the user asks for code without enough context or understanding. - Correct errors directly, explain why, and show the better path.`; function buildGentlePrompt( persona: PersonaMode, cwd: string = process.cwd(), activeTools?: readonly string[], rddStatusLine: string = renderRddStatusLine(undefined), hostMode?: string, ): string { const personaPrompt = persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT; const languageBoundary = persona === "neutral" ? "Language: neutral/professional Spanish when the user writes Spanish. Do NOT use voseo or Rioplatense regional expressions." : "Language: natural Rioplatense Spanish with voseo when the user writes Spanish."; return `## el Gentleman Identity and Harness Current persona mode: ${persona} You are el Gentleman: a Pi-specific coding-agent harness for controlled development work. Identity contract: - When the user asks who or what you are, answer as el Gentleman, not as a generic assistant, and never introduce yourself as only "your assistant" or "the default assistant". Convey this meaning, translated into the user's language: "I am el Gentleman: a Pi-specific coding-agent harness for controlled development, with a senior architect persona. I run Organic Driven Development, coordinate subagents, track substantial work, run commands, and edit files. I am not a generic chatbot." - Follow the currently selected persona mode. - Mention ODD as the development workflow and subagents as a core capability. - Mention memory only when memory packages or callable memory tools are actually active; never invent persistent memory. - Do not claim portability outside the Pi runtime. ${personaPrompt} ${languageBoundary} ## Authorized work continuity An authorized outcome includes its necessary in-scope implementation, documentation and verification steps. Continue those steps without asking the human to approve each transition. Never end a turn merely announcing an immediately executable authorized next step; execute it instead. A progress update is not a request for permission. Stop only for an unresolved consequential scope/product decision, required explicit consent, a denied action, unavailable required proof, or the human's explicit stop. Do not infer permission for publishing, destructive actions, unrelated scope or native consent from this continuity rule. Report a concrete blocker and the exact decision needed rather than requesting a generic "continue". For background work, launch the bounded action and end the turn when no independent in-scope work remains. Retain its identity and resume the already-authorized continuation automatically on its completion event; do not ask the human to say "continue", poll, or claim completion while checks remain pending. Default workflow: Organic Driven Development (MANDATORY) Organic Driven Development (ODD) is the predefined workflow of this orchestrator. Every request enters it, without the user asking for a workflow, a plan, or task tracking. Never describe this workflow only when asked about it: run it. Run these steps in this order on every request: ${oddProtocolSteps()} Harness principles: - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents. - Clarify scope, constraints, acceptance criteria, and non-goals before implementation. - Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration. - For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Write one RED test per requested rule. For every existing command or option the change touches, add one test proving its previous behavior still holds; add no other cases. An existing behavior counts as touched when it shares the code you changed (options, parsers, helpers, validation). Only when your diff changes shared code (options, parsers, helpers) that an existing command the request did not name also uses, run one differential check before finishing, as a single script: for each such command, run it with the new or changed input (for example a new option passed to a sibling subcommand) against the previous code (a copy of the base commit in a \`mktemp -d\` scratch directory, never committed as a test; committed tests never read git history or a fixed commit) and against your change, and compare stdout, stderr, and exit code; any difference nobody asked for is a regression to fix and pin with a test. Test presence alone does not establish applicability; no chat or TUI toggle activates it. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, explain why and run proportionate ordinary functional or structural verification. Never invent lifecycle evidence or skip checks. Follow orchestrator-delegation.md for ODD forwarding and evidence. - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change. - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable. ${getOrchestratorPrompt(cwd, activeTools, rddStatusLine, hostMode)}${headlessDeliveryNote(hostMode)}`; } // Bench gate-p53-r1-BS40 x6 run 3: in a headless RPC session the ask-on-risk // delivery question ran before any code was written, ask_user_question was // unavailable, and the orchestrator printed the menu and stopped. Hosts where // no human can answer (the same rule ask_user_question applies) resolve that // question to the recommended chain. Interactive sessions render nothing here. function headlessDeliveryNote(hostMode: string | undefined): string { if (hostMode === undefined || isInteractiveMode(hostMode)) return ""; return "\n\nNo human can answer questions in this session. For the ask-on-risk delivery question, do not ask: use `feature-branch-chain`, record that choice in the feature document's `## Log`, and continue. Consent, maintenance, and opaque-token prompts keep their own rules."; } // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp // between `git` and the subcommand. Short flags may be followed by a separate value token. // gentle-shell#2172: `\s+` rather than a literal space, so a tab-separated // `git\tpush` is guarded like any other spelling. const GIT_GLOBAL_FLAGS_SRC = String.raw`(?:\s+--?\S+(?:\s+[^-\s]\S*)?)*\s+`; const GIT_PUSH_RE = new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(push)\b`); const DENIED_BASH_PATTERNS: RegExp[] = [ // Block rm -rf targeting /, ~ or ~/subdir, $HOME or $HOME/subdir, .. or . /\brm\s+-rf\s+(?:\/(?:\s|$)|~(?:\/|\s|$)|[$]HOME(?:\/|\s|$)|\.\.?(?:\s|$))/, /\bgit\s+reset\s+--hard\b/, /\bgit\s+clean\b(?=[^\n]*(?:-[^\n]*f|--force))(?=[^\n]*(?:-[^\n]*d|--directories))/, // Force-push deny: tolerates git global flags (e.g. -C /repo) before the subcommand new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\r\n;&|]*\s--force(?:-with-lease)?\b)`), new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\r\n;&|]*\s-[^\s-]*f)`), /\bchmod\s+-R\s+777\b/, /\bchown\s+-R\b/, ]; // --------------------------------------------------------------------------- // Autonomous guard — runtime guardrails config // --------------------------------------------------------------------------- const GUARD_ACTION = { ALLOW: "allow", CONFIRM: "confirm", BLOCK: "block", } as const; type GuardAction = (typeof GUARD_ACTION)[keyof typeof GUARD_ACTION]; type GuardClassification = GuardAction | "not-guarded"; interface GuardMatch { key: GuardedCommandKey; action: GuardAction; triggerIndex: number; } interface GuardEvaluation { action: GuardClassification; dataLoss?: boolean; key?: GuardedCommandKey; triggerIndex: number; matches: GuardMatch[]; } const GUARDED_COMMAND_KEY = { GIT_PUSH: "gitPush", GIT_REBASE: "gitRebase", GIT_BRANCH_DELETE_FORCE: "gitBranchDeleteForce", NPM_PUBLISH: "npmPublish", PI_REMOVE: "piRemove", } as const; type GuardedCommandKey = (typeof GUARDED_COMMAND_KEY)[keyof typeof GUARDED_COMMAND_KEY]; type GuardedCommandsConfig = Partial>; interface RuntimeGuardrailsConfig { autonomousMode: boolean; guardedCommands: GuardedCommandsConfig; } interface LoadGuardrailsOptions { /** Override the config home directory (used in tests to avoid touching ~/.pi). */ gentlePiConfigHome?: string; } const GUARDED_KEY_PATTERNS: Record = { gitPush: GIT_PUSH_RE, gitRebase: new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}(rebase)\b`), gitBranchDeleteForce: /\bgit\s+(branch)\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\r\n;&|]*--force\b|--force\b[^\r\n;&|]*--delete\b)/, npmPublish: /\bnpm\s+(publish)\b/, piRemove: /\bpi\s+(remove)\b/, }; const AUTONOMOUS_DEFAULT_ACTIONS: Record = { gitPush: "allow", gitRebase: "confirm", gitBranchDeleteForce: "confirm", npmPublish: "block", piRemove: "confirm", }; const GUARDED_COMMAND_LABELS: Record = { gitPush: "git push", gitRebase: "git rebase", gitBranchDeleteForce: "forced git branch deletion", npmPublish: "npm publish", piRemove: "pi remove", }; const SAFE_GUARDRAILS_CONFIG: RuntimeGuardrailsConfig = { autonomousMode: false, guardedCommands: {}, }; /** * Classify a shell command under the runtime guard policy. * * Ordering (non-negotiable): * 1. Hard-deny patterns → "block" (always, cannot be overridden by config) * 2. If autonomousMode is false → mirror the legacy CONFIRM_BASH_PATTERNS result * 3. If autonomousMode is true → use configured GuardAction for the matched key * (applying AUTONOMOUS_DEFAULT_ACTIONS for any key not set in guardedCommands) * 4. No match → "not-guarded" */ function collectGuardedMatches( command: string, config: RuntimeGuardrailsConfig, ): GuardMatch[] { const matches: GuardMatch[] = []; const dataRanges = dataArgumentRanges(command); for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [ GuardedCommandKey, RegExp, ][]) { const globalPattern = new RegExp(pattern.source, `${pattern.flags}g`); for (const match of command.matchAll(globalPattern)) { // gentle-shell#2174: text inside a data command's argument is not an operation. if (inDataArgument(dataRanges, match.index)) continue; const action = config.autonomousMode ? (config.guardedCommands[key] ?? AUTONOMOUS_DEFAULT_ACTIONS[key]) : "confirm"; matches.push({ key, action, triggerIndex: match.index + match[0].lastIndexOf(match[1]), }); } } return matches.sort((left, right) => left.triggerIndex - right.triggerIndex); } function inDataArgument(ranges: ReadonlyArray, index: number): boolean { return ranges.some(([start, end]) => index >= start && index < end); } function evaluateGuardedCommand( command: string, config: RuntimeGuardrailsConfig, ): GuardEvaluation { const matches = collectGuardedMatches(command, config); const destructive = recognizeDestructiveCommands(command); const hardDeny = destructive.find((match) => match.hardDeny); if (hardDeny) return { action: "block", triggerIndex: hardDeny.triggerIndex, matches }; // Hard denies override every configured action across the complete command. const dataRanges = dataArgumentRanges(command); for (const pattern of DENIED_BASH_PATTERNS) { const denied = [...command.matchAll(new RegExp(pattern.source, `${pattern.flags.replace("g", "")}g`))] .find((candidate) => !inDataArgument(dataRanges, candidate.index)); if (!denied) continue; const matchedAction = matches.find((match) => match.triggerIndex >= denied.index && match.triggerIndex < denied.index + denied[0].length, ); return { action: "block", key: matchedAction?.key, triggerIndex: matchedAction?.triggerIndex ?? denied.index, matches, }; } // Explicit configured blocks outrank recognized data-loss confirmation. const configuredBlock = matches.find((match) => match.action === "block"); if (configuredBlock) return { ...configuredBlock, matches }; const dataLoss = destructive.find((match) => match.kind !== "git"); if (dataLoss) return { action: "confirm", dataLoss: true, triggerIndex: dataLoss.triggerIndex, matches }; // Confirmation, then allow win across remaining matches. const selected = matches.find((match) => match.action === "block") ?? matches.find((match) => match.action === "confirm") ?? matches.find((match) => match.action === "allow"); if (selected) return { ...selected, matches }; return { action: "not-guarded", triggerIndex: 0, matches }; } function classifyGuardedCommand( command: string, config: RuntimeGuardrailsConfig, ): GuardClassification { return evaluateGuardedCommand(command, config).action; } function guardedCommandPreview(command: string, triggerIndex: number): string { const start = Math.max(0, triggerIndex - 60); const prefix = start > 0 ? "…" : ""; return `${prefix}${truncateToWidth(command.slice(start).replace(/\s+/g, " ").trim(), 180 - prefix.length, "…")}`; } /** Confirmation headline for all guarded actions; generic when no key matched. */ function guardedCommandTitle( key?: GuardedCommandKey, matches: readonly GuardMatch[] = [], ): string { if (matches.length > 1) { return `Allow guarded actions: ${matches.map((match) => GUARDED_COMMAND_LABELS[match.key]).join("; ")}?`; } return key === undefined ? "Allow guarded command?" : `Allow guarded ${GUARDED_COMMAND_LABELS[key]}?`; } function parseGuardrailsConfigFile( raw: string, ): RuntimeGuardrailsConfig | undefined { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return undefined; } if (!isRecord(parsed)) return undefined; const autonomousMode = parsed.autonomousMode === true; const rawCommands = isRecord(parsed.guardedCommands) ? parsed.guardedCommands : {}; const guardedCommands: GuardedCommandsConfig = {}; const validActions = new Set(["allow", "confirm", "block"]); for (const [key, value] of Object.entries(rawCommands)) { if ( typeof value === "string" && validActions.has(value) && Object.values(GUARDED_COMMAND_KEY).includes(key as GuardedCommandKey) ) { guardedCommands[key as GuardedCommandKey] = value as GuardAction; } } return { autonomousMode, guardedCommands }; } /** * Load the runtime guardrails config. * * Resolution order (project overrides global): * 1. Check GENTLE_PI_AUTONOMOUS_MODE env var — if "1", forces autonomousMode=true * and uses default guarded command actions. * 2. Read global config from ${gentlePiConfigHome}/runtime-guardrails.json * 3. Read project config from ${cwd}/.pi/gentle-ai/runtime-guardrails.json * (project values are merged on top of global) * 4. Any parse/read error anywhere → fail safe (return SAFE_GUARDRAILS_CONFIG) */ function loadRuntimeGuardrailsConfig( cwd: string, options: LoadGuardrailsOptions = {}, ): RuntimeGuardrailsConfig { try { // Env var override: forces autonomous mode with default actions if (process.env.GENTLE_PI_AUTONOMOUS_MODE === "1") { return { autonomousMode: true, guardedCommands: {} }; } const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome(); const globalConfigPath = join(configHome, "runtime-guardrails.json"); const projectConfigPath = join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json"); let merged: RuntimeGuardrailsConfig = { autonomousMode: false, guardedCommands: {} }; if (existsSync(globalConfigPath)) { const globalParsed = parseGuardrailsConfigFile( readFileSync(globalConfigPath, "utf8"), ); if (!globalParsed) return SAFE_GUARDRAILS_CONFIG; merged = globalParsed; } if (existsSync(projectConfigPath)) { const projectParsed = parseGuardrailsConfigFile( readFileSync(projectConfigPath, "utf8"), ); if (!projectParsed) return SAFE_GUARDRAILS_CONFIG; // Project values fully override global values merged = { autonomousMode: projectParsed.autonomousMode, guardedCommands: { ...merged.guardedCommands, ...projectParsed.guardedCommands, }, }; } return merged; } catch { return SAFE_GUARDRAILS_CONFIG; } } const PATH_GUARDED_TOOL_NAMES = new Set(["read", "write", "edit"]); const PATH_INPUT_KEYS = new Set([ "path", "paths", "file", "files", "filePath", "filePaths", ]); const SENSITIVE_PATH_PATTERNS: RegExp[] = [ /(^|\/)\.ssh(?:\/|$)/, /(^|\/)\.credentials(?:\/|$)/, /(^|\/)library\/keychains(?:\/|$)/, /(^|\/)\.aws\/credentials$/, /(^|\/)\.config\/gh\/hosts\.ya?ml$/, /(^|\/)secrets(?:\/|$)/, /(^|\/)\.env(?:$|[./_-])/, /\.(?:pem|key|p12|pfx)$/, ]; const JUDGMENT_DAY_AGENT_NAMES = [ "jd-judge-a", "jd-judge-b", "jd-fix-agent", ] as const; const CORE_MODEL_AGENT_NAMES = JUDGMENT_DAY_AGENT_NAMES; const CORE_MODEL_AGENT_NAME_SET = new Set(CORE_MODEL_AGENT_NAMES); type AgentSource = "project" | "user" | "builtin"; interface AgentEntry { name: string; source: AgentSource; filePath?: string; } const KEEP_CURRENT = "Keep current"; const INHERIT_MODEL = "Inherit active/default model"; const CUSTOM_MODEL = "Custom model id"; const INHERIT_THINKING = "Inherit effort"; const MODEL_CONTROL_OPTIONS = [ KEEP_CURRENT, INHERIT_MODEL, CUSTOM_MODEL, ] as const; function readStringPath(value: unknown, path: string[]): string | undefined { let current = value; for (const key of path) { if (!isRecord(current)) return undefined; current = current[key]; } return typeof current === "string" ? current : undefined; } function readAgentStartNames(event: unknown): string[] { return [ readStringPath(event, ["agentName"]), readStringPath(event, ["agent"]), readStringPath(event, ["name"]), readStringPath(event, ["agent", "name"]), readStringPath(event, ["subagent", "name"]), ] .filter((value): value is string => value !== undefined) .map((value) => value.trim()) .filter((value) => value.length > 0); } function isNamedAgentStartEvent(event: unknown): boolean { return readAgentStartNames(event).length > 0; } function normalizePolicyPath(value: string): string { return value.trim().replace(/^~(?=\/|$)/, homedir()).replace(/\\/g, "/").toLowerCase(); } function isSensitivePath(value: string): boolean { const normalized = normalizePolicyPath(value); return SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(normalized)); } function collectPathInputs(value: unknown, key?: string): string[] { if (typeof value === "string") return key && PATH_INPUT_KEYS.has(key) ? [value] : []; if (Array.isArray(value)) return value.flatMap((item) => collectPathInputs(item, key)); if (!isRecord(value)) return []; return Object.entries(value).flatMap(([entryKey, entryValue]) => collectPathInputs(entryValue, entryKey), ); } function hasWritableEngramTool(pi: ExtensionAPI): boolean { try { const getActiveTools = (pi as unknown as { getActiveTools?: () => unknown[] }) .getActiveTools; if (typeof getActiveTools !== "function") return false; const tools = getActiveTools.call(pi); return tools.some((tool) => { const name = typeof tool === "string" ? tool : isRecord(tool) && typeof tool.name === "string" ? tool.name : ""; return name === "mem_save" || name.endsWith(".mem_save"); }); } catch { return false; } } function evaluateSensitivePathTool( toolName: string, input: unknown, ): ToolCallEventResult | undefined { if (!PATH_GUARDED_TOOL_NAMES.has(toolName)) return undefined; const sensitivePath = collectPathInputs(input).find(isSensitivePath); if (!sensitivePath) return undefined; return { block: true, reason: `Gentle AI safety policy blocked access to sensitive path: ${sanitizeTerminalText(sensitivePath)}. Ask the user for an explicit safer plan.`, }; } const ASK_USER_CHOICE_BLOCKED_EVENT = "gentle-pi:ask-user-choice:blocked"; const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked"; const HERDR_BLOCKER_LABEL = { CHOICE: "Choice awaiting input", GUARDED_CONFIRMATION: "Guarded command confirmation", QUESTIONNAIRE: "Questionnaire awaiting input", } as const; type HerdrBlockerLabel = (typeof HERDR_BLOCKER_LABEL)[keyof typeof HERDR_BLOCKER_LABEL]; type HerdrConfirmationLifecycle = { begin(): void; settle(): void; }; function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): HerdrConfirmationLifecycle { let pending = 0; let choiceActive = false; let nativeQuestionnaireActive = false; let legacyQuestionnaireActive = false; let emittedLabel: HerdrBlockerLabel | undefined; const emitEffectiveBlocker = (): void => { const nextLabel = choiceActive ? HERDR_BLOCKER_LABEL.CHOICE : nativeQuestionnaireActive || legacyQuestionnaireActive ? HERDR_BLOCKER_LABEL.QUESTIONNAIRE : pending > 0 ? HERDR_BLOCKER_LABEL.GUARDED_CONFIRMATION : undefined; // Herdr bridges count active/inactive edges. Keep the first label until // every source releases; relabel activations would leak a blocked count. if ((nextLabel === undefined) === (emittedLabel === undefined)) return; emittedLabel = nextLabel; if (nextLabel === undefined) events.emit("herdr:blocked", { active: false }); else events.emit("herdr:blocked", { active: true, label: nextLabel }); }; events?.on?.(ASK_USER_CHOICE_BLOCKED_EVENT, (event) => { if (!isRecord(event) || typeof event.active !== "boolean" || event.active === choiceActive) return; choiceActive = event.active; emitEffectiveBlocker(); }); events?.on?.(ASK_USER_QUESTION_BLOCKED_EVENT, (event) => { if (!isRecord(event) || typeof event.active !== "boolean" || event.active === nativeQuestionnaireActive) return; nativeQuestionnaireActive = event.active; emitEffectiveBlocker(); }); events?.on?.("rpiv:ask-user:blocked", (event) => { if (!isRecord(event) || typeof event.active !== "boolean" || event.active === legacyQuestionnaireActive) return; legacyQuestionnaireActive = event.active; emitEffectiveBlocker(); }); return { begin() { pending += 1; emitEffectiveBlocker(); }, settle() { if (pending === 0) return; pending -= 1; emitEffectiveBlocker(); }, }; } // gentle-shell#2172: YOLO waives reversible delivery, not only one bare push. // A guarded segment is judged from its trigger word (`push`/`rebase`) to the // next command separator; a plain token or allow-listed option list is the // whole eligible grammar, so quotes, wrappers and unknown options fall back // to the ordinary confirmation. Everything that deletes or rewrites a remote // ref, publishes, or runs arbitrary commands is deliberately absent: // `--delete`/`-d`, `:dst` and `+src` refspecs, `--mirror`, `--prune`, // `--all`, every force spelling, `--receive-pack`/`--exec`, `--repo`, `-o`. const YOLO_WAIVABLE_KEYS: ReadonlySet = new Set(["gitPush", "gitRebase"]); const YOLO_SEGMENT_END_RE = /&&|\|\||;|\||\n|\r/; // Text between the segment start and the `push`/`rebase` word: optional // variable assignments, `env`/`command` prefixes and the `git` word with at // most a `-C `. Other global options (`-c key=value`, `--exec-path`, // `--git-dir`) can run or redirect code, so they keep the confirmation. const YOLO_GIT_PREFIX_RE = /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=[^\s'"`$]*\s+|env\s+(?:-i\s+)?|command\s+)*git(?:\s+-C\s+[^\s'"`$;&|]+)?\s+$/; // Output redirections that write nothing: to stdout/stderr or /dev/null. const YOLO_REDIRECT_SRC = String.raw`[12]?>&[12]|(?:[12]?|&)>>?\s*/dev/null(?![^\s;&|])`; const YOLO_PUSH_SEGMENT_RE = new RegExp(String.raw`^push(?:\s+(?:${YOLO_REDIRECT_SRC}|-u|--set-upstream|-q|--quiet|-v|--verbose|--tags|--follow-tags|--no-verify|--porcelain|[A-Za-z0-9_][A-Za-z0-9_./-]*(?::[A-Za-z0-9_][A-Za-z0-9_./-]*)?))*\s*$`); const YOLO_REBASE_SEGMENT_RE = new RegExp(String.raw`^rebase(?:\s+(?:${YOLO_REDIRECT_SRC}|--onto\s+[A-Za-z0-9_][A-Za-z0-9_./~^-]*|--continue|--abort|--skip|--autostash|--no-autostash|[A-Za-z0-9_][A-Za-z0-9_./~^-]*))*\s*$`); // A pipe out of a waived segment may only feed read-only filters with plain or // literally quoted arguments and end at a sequential separator. Git output // carries remote-controlled text, so `| sh`, `| tee`, `| xargs` and a trailing // background `&` keep the confirmation. const YOLO_FILTER_PIPELINE_RE = new RegExp(String.raw`^(?:\|(?!\|)\s*(?:tail|head|cat|grep|wc)(?:\s+(?:${YOLO_REDIRECT_SRC}|[A-Za-z0-9_.,:=+/%@-]+|'[^'\n]*'|"[^"$\x60\\\n]*"))*\s*)*(?=$|&&|\|\||;|\n|\r)`); function yoloGuardedSegment(command: string, triggerIndex: number): string { const rest = command.slice(triggerIndex); const end = rest.search(YOLO_SEGMENT_END_RE); return end < 0 ? rest : rest.slice(0, end); } function yoloSegmentPrefix(command: string, triggerIndex: number): string { const head = command.slice(0, triggerIndex); const separators = [...head.matchAll(new RegExp(YOLO_SEGMENT_END_RE.source, "g"))]; const last = separators.at(-1); return last === undefined ? head : head.slice(last.index + last[0].length); } /** * Every guarded match and every recognized Git side effect must be a reversible * push or non-interactive rebase; the only data loss allowed is recursive * removal of the session's own temp scratch (lib/yolo-temp-scratch.ts). */ function isReversibleYoloCommand(command: string, evaluation: GuardEvaluation, scratchFs?: TempScratchFs): boolean { if (evaluation.action !== "confirm") return false; const effects = recognizeDestructiveCommands(command); const scratch = effects.map((effect) => effect.removalTargets !== undefined && isTempScratchRemoval(effect, scratchFs ??= defaultTempScratchFs())); if (evaluation.matches.length === 0 && !scratch.includes(true)) return false; for (const match of evaluation.matches) { if (!YOLO_WAIVABLE_KEYS.has(match.key)) return false; if (!YOLO_GIT_PREFIX_RE.test(yoloSegmentPrefix(command, match.triggerIndex))) return false; const segment = yoloGuardedSegment(command, match.triggerIndex); if (!(match.key === "gitPush" ? YOLO_PUSH_SEGMENT_RE : YOLO_REBASE_SEGMENT_RE).test(segment)) return false; if (!YOLO_FILTER_PIPELINE_RE.test(command.slice(match.triggerIndex + segment.length))) return false; } // The data-loss recognizer leaves non-hard Git effects (reset, stash drop, // restore, checkout --) to the primary policy; under YOLO only a rebase that // a guarded match already vetted may remain. const rebaseTriggers = new Set(evaluation.matches.filter((match) => match.key === "gitRebase").map((match) => match.triggerIndex)); for (const [index, effect] of effects.entries()) { if (scratch[index]) continue; if (effect.hardDeny || effect.kind !== "git") return false; const rebase = /^git(?:\s+(?:-C|-c|--git-dir|--work-tree|--namespace)\s+\S+|\s+-\S+)*\s+(rebase)\b/.exec(command.slice(effect.triggerIndex)); if (rebase === null || !rebaseTriggers.has(effect.triggerIndex + rebase.index + rebase[0].lastIndexOf("rebase"))) return false; } return true; } /** Preserve explicitly configured confirmations for the waivable keys present, even when legacy env/autonomy resolution ignores them. */ function yoloConfiguredRestriction(cwd: string, keys: readonly GuardedCommandKey[], options: LoadGuardrailsOptions = {}): GuardAction | undefined { let restriction: GuardAction | undefined; for (const path of [join(options.gentlePiConfigHome ?? gentleAiConfigHome(), "runtime-guardrails.json"), join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { try { if (!existsSync(path)) continue; const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed) || (parsed.guardedCommands !== undefined && !isRecord(parsed.guardedCommands))) { restriction ??= "confirm"; continue; } for (const key of keys) { const action = isRecord(parsed.guardedCommands) ? parsed.guardedCommands[key] : undefined; if (action === "block") return "block"; if (action !== undefined && action !== "allow") restriction = "confirm"; } } catch { restriction ??= "confirm"; } } return restriction; } /** Super Yolo ignores every configurable guardrail: only code-level denies survive. */ const SUPER_YOLO_GUARDRAILS: RuntimeGuardrailsConfig = { autonomousMode: true, guardedCommands: { gitPush: "allow", gitRebase: "allow", gitBranchDeleteForce: "allow", npmPublish: "allow", piRemove: "allow" }, }; /** * Super Yolo command decision: run everything without confirmation except the * code-level catastrophic denies (root/home recursive removal, --no-preserve-root, * hard-denied patterns). Configured guardrails are deliberately not consulted. */ function evaluateSuperYoloCommand(command: string): GuardAction { return evaluateGuardedCommand(command, SUPER_YOLO_GUARDRAILS).action === "block" ? "block" : "allow"; } async function confirmCommand( command: string, ctx: ExtensionContext, events: ExtensionAPI["events"], herdrLifecycle: HerdrConfirmationLifecycle, yoloActive = false, autoGate?: () => Promise, superActive = false, ): Promise { if (superActive) { // Sensitive-path and delivered-test guards already ran before this call; // here only the code-level catastrophic denies remain. if (evaluateSuperYoloCommand(command) === "block") { return { block: true, reason: "Gentle AI safety policy blocked a catastrophic destructive shell command. Ask the user for an explicit safer plan.", }; } return autoGate?.(); } const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd); const evaluation = evaluateGuardedCommand(command, guardrailsConfig); // Configuration provenance is distinct from legacy autonomy resolution, which // can ignore confirmations when an environment override is present. Activation // cannot waive a restriction from either user or project configuration layer. const waivableKeys = evaluation.matches.map((match) => match.key).filter((key) => YOLO_WAIVABLE_KEYS.has(key)); const configuredRestriction = yoloActive && waivableKeys.length > 0 ? yoloConfiguredRestriction(ctx.cwd, waivableKeys) : undefined; const classification = evaluation.action === "block" || configuredRestriction === "block" ? "block" : configuredRestriction ?? evaluation.action; if (classification === "block") { return { block: true, reason: "Gentle AI safety policy blocked a destructive shell command. Ask the user for an explicit safer plan.", }; } if (classification === "not-guarded") return autoGate?.(); // classification is "allow" or "confirm" from this point on if (classification === "allow") return autoGate?.(); // Auto may replace only a default confirmation, never an explicit user/project // ask/block rule or the independent data-loss confirmation. if (autoGate && !evaluation.dataLoss) { let explicit = false; for (const path of [join(gentleAiConfigHome(), "runtime-guardrails.json"), join(ctx.cwd, ".pi", "gentle-ai", "runtime-guardrails.json")]) { try { if (!existsSync(path)) continue; const settings: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(settings) || (settings.guardedCommands !== undefined && !isRecord(settings.guardedCommands))) { explicit = true; continue; } if (isRecord(settings.guardedCommands) && evaluation.matches.some(match => { const action = (settings.guardedCommands as Record)[match.key]; return action !== undefined && action !== "allow"; })) explicit = true; } catch { explicit = true; } } if (!explicit) return autoGate(); } // Full T1 evaluation has already run. YOLO never changes persistent autonomy. if (yoloActive && configuredRestriction === undefined && isReversibleYoloCommand(command, evaluation)) return undefined; // classification === "confirm" if (!ctx.hasUI) { return { block: true, reason: "Gentle AI safety policy requires interactive confirmation before this command.", }; } const title = evaluation.dataLoss ? "Allow recognized data-loss command?" : guardedCommandTitle(evaluation.key, evaluation.matches); const preview = guardedCommandPreview(command, evaluation.triggerIndex); const requestId = randomUUID(); const emitPermissionRequest = ( state: "waiting" | "approved" | "denied", ): void => { events.emit("pi-permission-system:permission-request", { requestId, state, source: "tool_call", message: "Gentle AI safety policy requires confirmation for this tool call.", toolName: "bash", }); }; let approved = false; let confirmationFailed = false; let confirmationError: unknown; emitPermissionRequest("waiting"); herdrLifecycle.begin(); try { approved = (await ctx.ui.confirm(title, preview)) === true; } catch (error) { confirmationFailed = true; confirmationError = error; } finally { try { emitPermissionRequest(confirmationFailed || !approved ? "denied" : "approved"); } finally { herdrLifecycle.settle(); } } if (confirmationFailed) throw confirmationError; if (approved) return undefined; return { block: true, reason: "Gentle AI safety policy blocked the command because it was not confirmed.", }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function gentleAiConfigHome(): string { return gentlePiConfigHome(); } function modelConfigPath(_cwd: string): string { return join(gentleAiConfigHome(), "models.json"); } function modelExportPath(_cwd: string): string { return join(gentleAiConfigHome(), "models.export.json"); } const MODEL_EXPORT_KIND = "gentle-pi.agent_model_routing"; const MODEL_EXPORT_VERSION = 1; function legacyProjectModelConfigPath(cwd: string): string { return join(cwd, ".pi", "gentle-ai", "models.json"); } function projectPersonaConfigPath(cwd: string): string { return join(cwd, ".pi", "gentle-ai", "persona.json"); } function personaConfigPath(_cwd: string): string { return join(gentleAiConfigHome(), "persona.json"); } function readPersonaFile(path: string): PersonaMode | undefined { if (!existsSync(path)) return undefined; try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed)) return undefined; return parsed.mode === "neutral" ? "neutral" : "gentleman"; } catch { return undefined; } } function readPersonaMode(cwd: string): PersonaMode { return ( readPersonaFile(projectPersonaConfigPath(cwd)) ?? readPersonaFile(personaConfigPath(cwd)) ?? "gentleman" ); } function writePersonaMode(cwd: string, mode: PersonaMode): string[] { const paths = [personaConfigPath(cwd)]; const projectPath = projectPersonaConfigPath(cwd); if (existsSync(projectPath)) paths.push(projectPath); for (const path of paths) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify({ mode }, null, 2)}\n`); } return paths; } function readSavedModelConfig(cwd: string): ModelConfigFileResult { const projectPath = legacyProjectModelConfigPath(cwd); const result = readModelRoutingAuthority(modelConfigPath(cwd), projectPath); return result.status === "invalid" && result.path === projectPath ? { status: "valid", config: {} } : result; } async function readSavedModelConfigAsync( cwd: string, ): Promise { const projectPath = legacyProjectModelConfigPath(cwd); const result = await readModelRoutingAuthorityAsync(modelConfigPath(cwd), projectPath); return result.status === "invalid" && result.path === projectPath ? { status: "valid", config: {} } : result; } export function readModelConfig(cwd: string): AgentModelConfig { const result = readSavedModelConfig(cwd); return result.status === "valid" ? result.config : {}; } export async function readModelConfigAsync( cwd: string, ): Promise { const result = await readSavedModelConfigAsync(cwd); return result.status === "valid" ? result.config : {}; } function writeModelConfig(cwd: string, config: AgentModelConfig): void { const path = modelConfigPath(cwd); mkdirSync(dirname(path), { recursive: true }); const cleaned = normalizeModelConfig(config) ?? {}; writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`); } async function writeModelConfigAsync(cwd: string, config: AgentModelConfig): Promise { const path = modelConfigPath(cwd); await mkdir(dirname(path), { recursive: true }); const cleaned = normalizeModelConfig(config) ?? {}; await writeFile(path, `${JSON.stringify(cleaned, null, 2)}\n`); } function parseModelExport(value: unknown): AgentModelConfig | undefined { if (!isRecord(value)) return undefined; if (value.kind !== MODEL_EXPORT_KIND || value.version !== MODEL_EXPORT_VERSION) return undefined; return normalizeModelConfig(value.agents); } async function exportSavedModelConfig(ctx: ExtensionContext): Promise { const saved = await readModelRoutingAuthorityAsync( modelConfigPath(ctx.cwd), legacyProjectModelConfigPath(ctx.cwd), ); if (saved.status === "invalid") throw new Error(`Invalid model config: ${saved.path}`); const agents = saved.status === "valid" ? saved.config : {}; const path = modelExportPath(ctx.cwd); await mkdir(dirname(path), { recursive: true }); await writeFile( path, `${JSON.stringify({ kind: MODEL_EXPORT_KIND, version: MODEL_EXPORT_VERSION, agents }, null, 2)}\n`, ); return Object.keys(agents).length; } async function readModelExport(ctx: ExtensionContext): Promise { try { return parseModelExport(JSON.parse(await readFile(modelExportPath(ctx.cwd), "utf8"))); } catch { return undefined; } } function cloneModelConfig(config: AgentModelConfig): AgentModelConfig { return Object.fromEntries( Object.entries(config).map(([name, entry]) => [name, { ...entry }]), ); } function updateFrontmatterRouting( content: string, entry: AgentRoutingEntry | undefined, ): string { if (!content.startsWith("---\n")) return content; const endIndex = content.indexOf("\n---", 4); if (endIndex === -1) return content; const frontmatter = content.slice(4, endIndex); const body = content.slice(endIndex); const lines = frontmatter .split("\n") .filter( (line) => !line.startsWith("model:") && !line.startsWith("thinking:"), ); const toInsert: string[] = []; if (entry?.model) toInsert.push(`model: ${entry.model}`); if (entry?.thinking) toInsert.push(`thinking: ${entry.thinking}`); if (toInsert.length > 0) { const descriptionIndex = lines.findIndex((line) => line.startsWith("description:"), ); const insertIndex = descriptionIndex >= 0 ? descriptionIndex + 1 : Math.min(1, lines.length); lines.splice(insertIndex, 0, ...toInsert); } return `---\n${lines.join("\n")}${body}`; } /** * The routing an agent file currently carries, read the same way * `updateFrontmatterRouting` writes it: top-level `model:` and `thinking:` * frontmatter lines. Anything else is "no routing", not an error. */ function readFrontmatterRouting(content: string): AgentRoutingEntry | undefined { if (!content.startsWith("---\n")) return undefined; const endIndex = content.indexOf("\n---", 4); if (endIndex === -1) return undefined; const raw: Record = {}; for (const line of content.slice(4, endIndex).split("\n")) { if (line.startsWith("model:")) raw.model = line.slice("model:".length).trim(); else if (line.startsWith("thinking:")) raw.thinking = line.slice("thinking:".length).trim(); } if (raw.model === undefined && raw.thinking === undefined) return undefined; const entry = normalizeRoutingEntry(raw); return entry && !isClearRoutingEntry(entry) ? entry : undefined; } function routingEntryFromModelProfile(value: unknown): AgentRoutingEntry | undefined { if (!isRecord(value)) return undefined; const entry = normalizeRoutingEntry({ model: value.model, thinking: value.effort }); return entry && !isClearRoutingEntry(entry) ? entry : undefined; } function mergeMaterializedRouting( profile: AgentRoutingEntry | undefined, frontmatter: AgentRoutingEntry | undefined, ): AgentRoutingEntry | undefined { if (!profile) return frontmatter; if (!frontmatter) return profile; return normalizeRoutingEntry({ model: profile.model ?? frontmatter.model, thinking: profile.thinking ?? frontmatter.thinking, }); } function readSubagentModelProfiles(path: string): Record { if (!existsSync(path)) return {}; try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); return isRecord(parsed) && isRecord(parsed.model_profiles) ? parsed.model_profiles : {}; } catch { return {}; } } async function readSubagentModelProfilesAsync(path: string): Promise> { if (!(await pathExists(path))) return {}; try { const parsed: unknown = JSON.parse(await readFile(path, "utf8")); return isRecord(parsed) && isRecord(parsed.model_profiles) ? parsed.model_profiles : {}; } catch { return {}; } } /** * The routing an agent is materialized with — what subagent launches actually * resolve — regardless of what `models.json` records: the runtime reads * `subagents.json` model profiles first and the agent frontmatter otherwise, * independently for each routing field. */ function readMaterializedRoutingEntry( cwd: string, agent: AgentEntry, profilesByPath: Map>, ): AgentRoutingEntry | undefined { const profilesPath = agentModelProfileConfigPath(cwd, agent.source); let profiles = profilesByPath.get(profilesPath); if (!profiles) { profiles = readSubagentModelProfiles(profilesPath); profilesByPath.set(profilesPath, profiles); } const fromProfile = routingEntryFromModelProfile(profiles[agent.name]); if (!agent.filePath || !existsSync(agent.filePath)) return fromProfile; try { return mergeMaterializedRouting(fromProfile, readFrontmatterRouting(readFileSync(agent.filePath, "utf8"))); } catch { return fromProfile; } } async function readMaterializedRoutingEntryAsync( cwd: string, agent: AgentEntry, profilesByPath: Map>, ): Promise { const profilesPath = agentModelProfileConfigPath(cwd, agent.source); let profiles = profilesByPath.get(profilesPath); if (!profiles) { profiles = await readSubagentModelProfilesAsync(profilesPath); profilesByPath.set(profilesPath, profiles); } const fromProfile = routingEntryFromModelProfile(profiles[agent.name]); if (!agent.filePath || !(await pathExists(agent.filePath))) return fromProfile; try { return mergeMaterializedRouting(fromProfile, readFrontmatterRouting(await readFile(agent.filePath, "utf8"))); } catch { return fromProfile; } } /** * The routing a launch would resolve: a winning per-repository pin replaces subagent * routing wholesale, so when one wins it is the effective routing. The launch * resolver decides, so the profile shown as effective is exactly the profile a launch * would use -- there is no second precedence rule here. */ function pinnedEffectiveModelConfig(cwd: string): AgentModelConfig | undefined { const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); return resolution === undefined ? undefined : cloneModelConfig(resolution.modelProfiles); } /** * The routing in effect: `models.json` where it speaks, and the materialized * stores the runtime resolves from for every discoverable agent it is silent * about. A sparse `models.json` therefore never hides routing that is still * live (#1012). A winning pin outranks both. Reading never writes. */ function readEffectiveModelConfig(cwd: string): AgentModelConfig { return pinnedEffectiveModelConfig(cwd) ?? readGlobalEffectiveModelConfig(cwd); } /** Resolve one complete reviewer routing snapshot without per-role fallback. */ function readReviewerModelConfig(cwd: string, sessionId: string | undefined): AgentModelConfig { return readSessionProfileBinding(sessionId)?.modelProfiles ?? pinnedEffectiveModelConfig(cwd) ?? readModelConfig(cwd); } /** The routing in effect once the pin is set aside: what a global save materializes. */ function readGlobalEffectiveModelConfig(cwd: string): AgentModelConfig { const effective = cloneModelConfig(readModelConfig(cwd)); const profilesByPath = new Map>(); for (const agent of listDiscoverableAgents(cwd)) { if (isProviderReviewRole(agent.name) || agent.name in effective) continue; const entry = readMaterializedRoutingEntry(cwd, agent, profilesByPath); if (entry) effective[agent.name] = entry; } return effective; } /** * The profile this session's launches resolve (gentle-shell#1064 slice 3c): the * Enter binding, then the profile frozen at startup, through the same rule the * launcher reads. It resolves from the session's directory, as the freeze and * the launcher do. `undefined` means the session resolves no profile, so * launches keep the global routing; a pin the session did not freeze never * reaches them and is not read here. Without session profile authority the * launches are blocked, so no profile is theirs. */ function sessionLaunchProfile(ctx: ExtensionContext, authorityAvailable: boolean): { name: string; modelProfiles: AgentModelConfig } | undefined { if (!authorityAvailable) return undefined; const resolution = resolveSessionProfile({ sessionId: ctx.sessionManager?.getSessionId?.(), cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd, configHome: gentleAiConfigHome(), }); const name = resolution.kind === "explicit" ? resolution.name : resolution.profile?.name; const routing = sessionProfileModelProfiles(resolution); return name === undefined || routing === undefined ? undefined : { name, modelProfiles: cloneModelConfig(routing) }; } /** * Whether this session's launches may run, read as the launcher reads it (and, * like it, clearing a binding it cannot corroborate). A manager without a * branch has no session profile record to distrust. */ function sessionProfileAuthorityAvailable(ctx: ExtensionContext): boolean { const manager = ctx.sessionManager; if (typeof manager?.getSessionId !== "function" || typeof manager.getBranch !== "function") return true; return readSessionProfileAuthority(manager).available; } /** The routing this session's launches use: its profile, or the global effective routing. */ async function readSessionLaunchModelConfigAsync(ctx: ExtensionContext, authorityAvailable: boolean): Promise { return sessionLaunchProfile(ctx, authorityAvailable)?.modelProfiles ?? await readGlobalEffectiveModelConfigFromAsync(ctx.cwd, await readModelConfigAsync(ctx.cwd)); } async function readEffectiveModelConfigAsync(cwd: string): Promise { const pinned = pinnedEffectiveModelConfig(cwd); if (pinned) return pinned; return readGlobalEffectiveModelConfigFromAsync(cwd, await readModelConfigAsync(cwd)); } /** * The saved global routing merged with the materialized stores of every * discoverable agent it is silent about — the same effective view * `readEffectiveModelConfigAsync` builds, but starting from an already-read * saved routing so callers that must distinguish an unreadable authority can * keep that distinction while still seeing materialized routes. */ async function readGlobalEffectiveModelConfigFromAsync( cwd: string, base: AgentModelConfig, ): Promise { const effective = cloneModelConfig(base); const profilesByPath = new Map>(); for (const agent of await listDiscoverableAgentsAsync(cwd)) { if (isProviderReviewRole(agent.name) || agent.name in effective) continue; const entry = await readMaterializedRoutingEntryAsync(cwd, agent, profilesByPath); if (entry) effective[agent.name] = entry; } return effective; } /** * A profile is a complete routing snapshot: applying it must leave every * discoverable agent it omits on inherit, not on whatever was materialized * before. Padding the omitted agents with clear entries makes * `applyModelConfig` remove their model profiles and frontmatter routing, the * same way applying a profile with inherit entries clears an agent set. */ async function withOmittedAgentsClearedAsync( cwd: string, config: AgentModelConfig, ): Promise { const completed = cloneModelConfig(config); for (const agent of await listDiscoverableAgentsAsync(cwd)) { if (isProviderReviewRole(agent.name) || agent.name in completed) continue; completed[agent.name] = {}; } return completed; } function parseAgentName(filePath: string): string | undefined { let content: string; try { content = readFileSync(filePath, "utf8"); } catch { return undefined; } const name = content.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim(); if (!name) return undefined; const packageName = content .match(/^package:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1] ?.trim(); return packageName ? `${packageName}.${name}` : name; } async function parseAgentNameAsync( filePath: string, ): Promise { let content: string; try { content = await readFile(filePath, "utf8"); } catch { return undefined; } const name = content.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim(); if (!name) return undefined; const packageName = content .match(/^package:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1] ?.trim(); return packageName ? `${packageName}.${name}` : name; } function listAgentFilesRecursive(dir: string): string[] { if (!existsSync(dir)) return []; const files: string[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "skills") continue; files.push(...listAgentFilesRecursive(path)); } else if ( entry.isFile() && entry.name.endsWith(".md") && !entry.name.endsWith(".chain.md") ) files.push(path); } return files; } async function listAgentFilesRecursiveAsync(dir: string): Promise { if (!(await pathExists(dir))) return []; const files: string[] = []; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { const path = join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "skills") continue; files.push(...(await listAgentFilesRecursiveAsync(path))); } else if ( entry.isFile() && entry.name.endsWith(".md") && !entry.name.endsWith(".chain.md") ) { files.push(path); } } return files; } function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] { return listAgentFilesRecursive(dir) .map((filePath): AgentEntry | undefined => { const name = parseAgentName(filePath); return name ? { name, source, filePath } : undefined; }) .filter((entry): entry is AgentEntry => entry !== undefined); } async function listAgentsFromDirAsync( dir: string, source: AgentSource, ): Promise { const filePaths = await listAgentFilesRecursiveAsync(dir); const entries: AgentEntry[] = []; for (const filePath of filePaths) { const name = await parseAgentNameAsync(filePath); if (name) entries.push({ name, source, filePath }); } return entries; } interface DiscoverableNonBuiltinAgentRoot { dir: string; source: AgentSource; /** The package installer owns this directory, so packageAssetAudit reports it. */ packageManaged: boolean; } function discoverableNonBuiltinAgentRoots(cwd: string): DiscoverableNonBuiltinAgentRoot[] { const globalAgentHome = gentlePiAgentHome(); const roots: DiscoverableNonBuiltinAgentRoot[] = [ { dir: join(globalAgentHome, "agents"), source: "user", packageManaged: true }, { dir: join(globalAgentHome, "subagents"), source: "user", packageManaged: false }, { dir: join(homedir(), ".agents"), source: "user", packageManaged: false }, { dir: join(cwd, ".agents"), source: "project", packageManaged: false }, { dir: join(cwd, ".pi", "agents"), source: "project", packageManaged: false }, { dir: join(cwd, ".pi", "subagents"), source: "project", packageManaged: false }, ]; const unique = new Map(); for (const root of roots) { let canonical: string; try { canonical = realpathSync(root.dir); } catch { canonical = resolve(root.dir); } const existing = unique.get(canonical); if (existing) { // Reinsert so a later alias keeps true later-root precedence even when // another physical root appears between the duplicate entries. A merged // package-managed root must keep its installer-owned path: ownership // updates validate that lexical path against the managed manifest root. const managedRoot = existing.packageManaged ? existing : root.packageManaged ? root : undefined; unique.delete(canonical); unique.set(canonical, { dir: managedRoot?.dir ?? root.dir, source: root.source, packageManaged: managedRoot !== undefined, }); } else unique.set(canonical, root); } return [...unique.values()]; } function builtinAgentDirs(cwd: string): string[] { return [ join(PACKAGE_ROOT, "..", "pi-subagents-j0k3r", "agents"), join(cwd, ".pi", "npm", "node_modules", "pi-subagents-j0k3r", "agents"), join(homedir(), ".local", "lib", "node_modules", "pi-subagents-j0k3r", "agents"), join(PACKAGE_ROOT, "..", "pi-subagents", "agents"), join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"), join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"), ]; } function listDiscoverableAgents(cwd: string): AgentEntry[] { const builtinDirs = builtinAgentDirs(cwd); const agents = [ ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")), ...discoverableNonBuiltinAgentRoots(cwd).flatMap(({ dir, source }) => listAgentsFromDir(dir, source), ), ]; const byName = new Map(); for (const agent of agents) byName.set(agent.name, agent); return orderDiscoverableAgents(Array.from(byName.values())); } async function listDiscoverableAgentsAsync(cwd: string): Promise { const builtinDirs = builtinAgentDirs(cwd); const agents: AgentEntry[] = []; for (const dir of builtinDirs) { agents.push(...(await listAgentsFromDirAsync(dir, "builtin"))); } for (const { dir, source } of discoverableNonBuiltinAgentRoots(cwd)) { agents.push(...(await listAgentsFromDirAsync(dir, source))); } const byName = new Map(); for (const agent of agents) byName.set(agent.name, agent); return orderDiscoverableAgents(Array.from(byName.values())); } function orderDiscoverableAgents(agents: AgentEntry[]): AgentEntry[] { const coreFirst = CORE_MODEL_AGENT_NAMES.map((name) => agents.find((agent) => agent.name === name), ).filter((agent): agent is AgentEntry => agent !== undefined); const rest = agents .filter((agent) => !CORE_MODEL_AGENT_NAME_SET.has(agent.name)) .sort((left, right) => left.name.localeCompare(right.name)); return [...coreFirst, ...rest]; } function isClearRoutingEntry(entry: AgentRoutingEntry): boolean { return entry.model === undefined && entry.thinking === undefined; } function agentModelProfileConfigPath(cwd: string, source: AgentSource): string { return source === "project" ? join(cwd, ".pi", "subagents.json") : join(gentlePiAgentHome(), "subagents.json"); } function modelProfileForRoutingEntry( entry: AgentRoutingEntry | undefined, ): Record | undefined { if (!entry || isClearRoutingEntry(entry)) return undefined; const profile: Record = {}; if (entry.model) profile.model = entry.model; if (entry.thinking) profile.effort = entry.thinking; return Object.keys(profile).length > 0 ? profile : undefined; } function updateSubagentModelProfileAtPath( path: string, name: string, entry: AgentRoutingEntry | undefined, options: { preserveExisting?: boolean } = {}, ): boolean { let config: Record = {}; if (existsSync(path)) { try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (isRecord(parsed)) config = { ...parsed }; } catch { config = {}; } } const modelProfiles = isRecord(config.model_profiles) ? { ...config.model_profiles } : {}; const profile = modelProfileForRoutingEntry(entry); // A write that would leave the profile as it is (including removing a // profile that was never there) is not an update and touches no file. if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false; if (profile) { if (options.preserveExisting && isRecord(modelProfiles[name])) return false; modelProfiles[name] = profile; } else delete modelProfiles[name]; if (Object.keys(modelProfiles).length > 0) config.model_profiles = modelProfiles; else delete config.model_profiles; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); return true; } async function updateSubagentModelProfileAtPathAsync( path: string, name: string, entry: AgentRoutingEntry | undefined, options: { preserveExisting?: boolean } = {}, ): Promise { let config: Record = {}; if (await pathExists(path)) { try { const parsed: unknown = JSON.parse(await readFile(path, "utf8")); if (isRecord(parsed)) config = { ...parsed }; } catch { config = {}; } } const modelProfiles = isRecord(config.model_profiles) ? { ...config.model_profiles } : {}; const profile = modelProfileForRoutingEntry(entry); // A write that would leave the profile as it is (including removing a // profile that was never there) is not an update and touches no file. if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false; if (profile) { if (options.preserveExisting && isRecord(modelProfiles[name])) return false; modelProfiles[name] = profile; } else delete modelProfiles[name]; if (Object.keys(modelProfiles).length > 0) config.model_profiles = modelProfiles; else delete config.model_profiles; await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`); return true; } function updateSubagentModelProfile( cwd: string, source: AgentSource, name: string, entry: AgentRoutingEntry | undefined, options: { preserveExisting?: boolean } = {}, ): boolean { return updateSubagentModelProfileAtPath( agentModelProfileConfigPath(cwd, source), name, entry, options, ); } function projectSettingsPath(cwd: string): string { return join(cwd, ".pi", "settings.json"); } /** * Pi's own global settings file, which is where the orchestrator model lives. * Profiles own the three `default*` keys there; nothing else in this extension * reads or writes that file. */ function orchestratorSettingsPath(): string { return join(gentlePiAgentHome(), "settings.json"); } function removeLegacyAgentOverridesFromSettings( settingsPath: string, settings: Record, ): void { const subagents = isRecord(settings.subagents) ? { ...settings.subagents } : undefined; if (!subagents) return; delete subagents.agentOverrides; if (Object.keys(subagents).length > 0) settings.subagents = subagents; else delete settings.subagents; mkdirSync(dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); } function isValidJsonObjectFileOrMissing(path: string): boolean { if (!existsSync(path)) return true; try { return isRecord(JSON.parse(readFileSync(path, "utf8"))); } catch { return false; } } const PROVIDER_REVIEW_ROLES = ["review-refuter", "review-validator"] as const; function isProviderReviewRole(name: string): boolean { return PROVIDER_REVIEW_ROLES.some((role) => role === name); } function modelAssignmentNames(cwd: string): string[] { return [...new Set([ ...PROVIDER_REVIEW_ROLES, ...listDiscoverableAgents(cwd).map((agent) => agent.name), ])]; } const PROVIDER_ROUTING_DEFAULT_LABELS = { model: "Pi persisted default model", effort: "Pi persisted default effort", } as const; type RoutingDefaultField = keyof typeof PROVIDER_ROUTING_DEFAULT_LABELS; function routingDefaultLabel(name: string, field: RoutingDefaultField): string { return isProviderReviewRole(name) ? PROVIDER_ROUTING_DEFAULT_LABELS[field] : "inherit"; } function migrateLegacyProjectModelOverrides(cwd: string): number { const settingsPath = projectSettingsPath(cwd); if (!existsSync(settingsPath)) return 0; let settings: Record; try { const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8")); if (!isRecord(parsed)) return 0; settings = { ...parsed }; } catch { return 0; } const subagents = isRecord(settings.subagents) ? settings.subagents : undefined; const agentOverrides = isRecord(subagents?.agentOverrides) ? subagents.agentOverrides : undefined; if (!agentOverrides) return 0; const agentsByName = new Map(listDiscoverableAgents(cwd).map((agent) => [agent.name, agent])); const migratableEntries = Object.entries(agentOverrides) .filter(([name]) => !isProviderReviewRole(name)) .map(([name, value]) => ({ name, entry: normalizeRoutingEntry(value) })) .filter((item): item is { name: string; entry: AgentRoutingEntry } => item.entry !== undefined && !isClearRoutingEntry(item.entry), ); const targetPaths = new Set( migratableEntries.map(({ name }) => agentModelProfileConfigPath(cwd, agentsByName.get(name)?.source ?? "project"), ), ); if (![...targetPaths].every(isValidJsonObjectFileOrMissing)) return 0; let migrated = 0; for (const { name, entry } of migratableEntries) { const source = agentsByName.get(name)?.source ?? "project"; if (updateSubagentModelProfile(cwd, source, name, entry, { preserveExisting: true })) migrated += 1; } removeLegacyAgentOverridesFromSettings(settingsPath, settings); return migrated; } async function updateSubagentModelProfileAsync( cwd: string, source: AgentSource, name: string, entry: AgentRoutingEntry | undefined, options: { preserveExisting?: boolean } = {}, ): Promise { return updateSubagentModelProfileAtPathAsync( agentModelProfileConfigPath(cwd, source), name, entry, options, ); } export function applyModelConfig( cwd: string, config: AgentModelConfig, ): { updated: number; skipped: number } { let updated = 0; let skipped = 0; const seenAgents = new Set(); for (const agent of listDiscoverableAgents(cwd)) { if (isProviderReviewRole(agent.name)) continue; seenAgents.add(agent.name); const entry = config[agent.name]; if (entry === undefined) { skipped += 1; continue; } if (agent.source === "builtin") { if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1; else skipped += 1; continue; } if (!agent.filePath || !existsSync(agent.filePath)) { skipped += 1; } else { const original = readFileSync(agent.filePath, "utf8"); const next = updateFrontmatterRouting(original, entry); if (next === original) { skipped += 1; } else { if (!updatePackageManagedSddAgentOwnership(agent.filePath, original, next)) { writeFileSync(agent.filePath, next); } updated += 1; } } if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1; else skipped += 1; } for (const [name, entry] of Object.entries(config)) { if (isProviderReviewRole(name)) continue; // The orchestrator is routing, not an agent: its model lives in Pi's global // settings.json and must never reach subagents.json. if (isProfileOrchestratorKey(name)) continue; if (!seenAgents.has(name) && isClearRoutingEntry(entry)) { if (updateSubagentModelProfile(cwd, "user", name, entry)) updated += 1; else skipped += 1; } } return { updated, skipped }; } export async function applyModelConfigAsync( cwd: string, config: AgentModelConfig, ): Promise<{ updated: number; skipped: number }> { let updated = 0; let skipped = 0; const seenAgents = new Set(); for (const agent of await listDiscoverableAgentsAsync(cwd)) { if (isProviderReviewRole(agent.name)) continue; seenAgents.add(agent.name); const entry = config[agent.name]; if (entry === undefined) { skipped += 1; continue; } if (agent.source === "builtin") { if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry)) updated += 1; else skipped += 1; continue; } if (!agent.filePath || !(await pathExists(agent.filePath))) { skipped += 1; } else { const original = await readFile(agent.filePath, "utf8"); const next = updateFrontmatterRouting(original, entry); if (next === original) { skipped += 1; } else { if (!updatePackageManagedSddAgentOwnership(agent.filePath, original, next)) { await writeFile(agent.filePath, next); } updated += 1; } } if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry)) updated += 1; else skipped += 1; } for (const [name, entry] of Object.entries(config)) { if (isProviderReviewRole(name)) continue; if (isProfileOrchestratorKey(name)) continue; if (!seenAgents.has(name) && isClearRoutingEntry(entry)) { if (await updateSubagentModelProfileAsync(cwd, "user", name, entry)) updated += 1; else skipped += 1; } } return { updated, skipped }; } export async function applySavedModelConfig( ctx: ExtensionContext, applyConfig: typeof applyModelConfigAsync = applyModelConfigAsync, ): Promise<{ updated: number; skipped: number; invalidPath?: string }> { const result = await readModelRoutingAuthorityAsync( modelConfigPath(ctx.cwd), legacyProjectModelConfigPath(ctx.cwd), ); if (result.status === "invalid") { return { updated: 0, skipped: 0, invalidPath: result.path }; } // Startup/reload has no deletion approval. Persisted inherit entries are // not instructions to erase routing, even if an agent is no longer present. // Explicit panel saves and confirmed profile replacement still apply clears. const config = result.status === "valid" ? result.config : {}; const activationConfig = Object.fromEntries( Object.entries(config).filter(([, entry]) => !isClearRoutingEntry(entry)), ); return applyConfig(ctx.cwd, activationConfig); } function describeModelConfig(cwd: string, config: AgentModelConfig): string[] { return modelAssignmentNames(cwd).map((name) => { const entry = config[name]; const model = entry?.model ?? routingDefaultLabel(name, "model"); const thinking = entry?.thinking ?? routingDefaultLabel(name, "effort"); return `${sanitizeTerminalText(name)}: model=${sanitizeTerminalText(model)}, effort=${sanitizeTerminalText(thinking)}`; }); } async function getPiModelOptions(ctx: ExtensionContext): Promise { const registry = ctx.modelRegistry; if (!registry) { return [...MODEL_CONTROL_OPTIONS]; } let raw: unknown; try { raw = await registry.getAvailable(); } catch { return [...MODEL_CONTROL_OPTIONS]; } if (!Array.isArray(raw)) { return [...MODEL_CONTROL_OPTIONS]; } const models = raw as { provider: string; id: string }[]; const modelIds = models .map((model) => normalizeModelId(`${model.provider}/${model.id}`)) .filter((model): model is string => model !== undefined) .sort((left, right) => left.localeCompare(right)); return [...MODEL_CONTROL_OPTIONS, ...modelIds]; } /** * The effort levels each catalog model supports, keyed by its provider/model id. * Pi clamps an unsupported level at launch, so the models screen offers only * these and shows where a saved level would land. Models outside the catalog * are absent: their levels are unknown. */ async function getPiThinkingSupport(ctx: ExtensionContext): Promise> { const support: Record = {}; let raw: unknown; try { raw = await ctx.modelRegistry?.getAvailable(); } catch { return support; } if (!Array.isArray(raw)) return support; for (const model of raw as Parameters[0][]) { const id = normalizeModelId(`${model?.provider}/${model?.id}`); // Without the reasoning flag the catalog says nothing about effort: the // model stays unknown (every level offered) rather than "off only". if (!id || typeof model?.reasoning !== "boolean") continue; try { support[id] = getSupportedThinkingLevels(model).filter((level): level is ThinkingLevel => (THINKING_LEVELS as readonly string[]).includes(level), ); } catch { // A model whose metadata cannot be read stays unknown. } } return support; } /** * Where Pi lands a requested level on a model, mirroring pi-ai's * clampThinkingLevel: the level itself when supported, else the nearest * supported level above it, else below it. */ function clampToSupportedLevel(level: ThinkingLevel, supported: readonly ThinkingLevel[]): ThinkingLevel { if (supported.includes(level)) return level; const start = THINKING_LEVELS.indexOf(level); for (let index = start + 1; index < THINKING_LEVELS.length; index += 1) { if (supported.includes(THINKING_LEVELS[index])) return THINKING_LEVELS[index]; } for (let index = start - 1; index >= 0; index -= 1) { if (supported.includes(THINKING_LEVELS[index])) return THINKING_LEVELS[index]; } return supported[0] ?? "off"; } interface OverlayComponent { render(width: number): string[]; handleInput(data: string): void; invalidate(): void; } interface RoutingHitRegion { x0: number; x1: number; y: number; action: RoutingTableMouseAction; } const LEAVE_ORCHESTRATOR_UNTOUCHED = "leave untouched"; const PANEL_TONE = { BORDER: "border", MUTED: "muted", DIM: "dim", TEXT: "text", TITLE: "title", ACCENT: "accent", STATUS: "status", EDITED: "edited", GOOD: "good", DANGER: "danger", } as const; type PanelTone = (typeof PANEL_TONE)[keyof typeof PANEL_TONE]; const PANEL_TONE_COLOR: Record = { border: "border", muted: "muted", dim: "dim", text: "text", title: "accent", accent: "accent", status: "thinkingHigh", edited: "warning", good: "success", danger: "error", }; /** The pseudo row behind the Model ▾ / Effort ▾ header: it sets every agent at once. */ const ROUTING_ALL_ROW = "__all"; const ROUTING_CUSTOM_MODEL = "Custom model id…"; /** The table's short form of a provider review role's inherited model and effort. */ const ROUTING_PI_DEFAULT = "Pi default"; /** Body lines around the agent rows: tabs, rail, description, blank, orchestrator, rule, header, blank, footer, hints, two feedback lines. */ const ROUTING_TABLE_FIXED_ROWS = 12; /** Compact button labels for terminals too narrow for the full ones. */ const ROUTING_TABLE_SHORT_BUTTONS: Partial> = { use: "Use here", default: "Default", }; /** * The key hints in display order, each with a keep priority (lower stays * longer): narrow terminals drop whole hints instead of cutting one in half. */ const ROUTING_TABLE_HINTS: Array<{ text: string; keep: number }> = [ { text: "←→ profile", keep: 5 }, { text: "↑↓ agent", keep: 6 }, { text: "enter model", keep: 1 }, { text: "e effort", keep: 2 }, { text: "s save", keep: 3 }, { text: "u use", keep: 7 }, { text: "d default", keep: 8 }, { text: "n new", keep: 10 }, { text: "m menu", keep: 9 }, { text: "esc close", keep: 4 }, ]; /** Model card chrome (title, search, rule, blank, hint), its borders, and a margin so the card never touches the frame. */ const ROUTING_MODEL_CARD_CHROME_ROWS = 11; type RoutingTableAction = | { type: "load"; name: string } | { type: "custom"; target: string; config: AgentModelConfig } | { type: "use-session"; name: string; config?: AgentModelConfig } | { type: "apply-global"; name: string; config: AgentModelConfig } | { type: "save"; name: string; config: AgentModelConfig } | { type: "create" } | { type: "create-live" } | { type: "duplicate"; name: string } | { type: "rename"; name: string } | { type: "delete"; name: string } | { type: "export-profile"; name: string } | { type: "import-profile" } | { type: "pin"; source: ProfilePinSource; name: string } | { type: "close" }; /** * `saveFirst` asks the loop to write the draft into the shown profile before * running the action (the "Save and switch" answer). `draft` carries unsaved * edits across actions that keep the same profile on screen (rename, pin, a * declined delete), so managing a profile never throws away what you typed. */ type RoutingTableResult = RoutingTableAction & { saveFirst?: AgentModelConfig; draft?: AgentModelConfig }; type RoutingTableButton = "use" | "default" | "save" | "switch-save" | "switch-discard" | "switch-stay"; /** * What the screen needs to show another profile without closing: the store * after an optional save, fresh scope state, and the footer line. `error` * means the save failed and the shown profile stays, edits intact. */ type RoutingProfileSwitch = | { file: AgentProfilesFile; sessionBoundName?: string; pinWinner?: string; notices: string[]; feedback: string } | { error: string }; /** * Switches the shown profile in place. Closing the overlay and reopening it on * the next tab let the terminal paint the screen beneath for a frame (and wait * on the async model catalog before the new frame), which is the tab flicker. */ type RoutingProfileSwitcher = (name: string, saveFirst?: AgentModelConfig) => RoutingProfileSwitch; type RoutingTableMouseAction = | { kind: "tab"; name: string } | { kind: "tab-menu" } | { kind: "new" } | { kind: "row"; row: string } | { kind: "effort"; row: string } | { kind: "model-option"; index: number } | { kind: "effort-option"; index: number } | { kind: "menu-item"; id: string } | { kind: "button"; button: RoutingTableButton }; interface RoutingMenuItem { id: string; label: string; tone?: PanelTone; } const ROUTING_TABLE_BUTTONS: Record = { use: "Use in this session", default: "Make default", save: "Save", "switch-save": "Save and switch", "switch-discard": "Discard", "switch-stay": "Stay", }; /** * `/gentle:profiles` — one screen, one table, profiles as * tabs. Each tab is a profile and carries where it is in effect (◉ this * session, ★ default for new sessions, ⌂ this repo); the table below is that * profile's routing, orchestrator first; three buttons decide where it applies. * Model and effort changes happen in small cards over the still-visible table. * Keyboard and pointer reach the same actions, and each key means one thing. */ class RoutingTablePanel implements OverlayComponent { private overlay: "model" | "effort" | "menu" | "new" | "switch" | undefined; private target: string = PROFILE_ORCHESTRATOR_KEY; private pending: RoutingTableResult | undefined; private cursor = 0; private query = ""; private modelCursor = 0; private effortCursor = 0; private menuCursor = 0; private hovered: string | undefined; private escArmed = false; private feedback: string | undefined; private completed = false; private hitRegions: RoutingHitRegion[] = []; private activeTabColumn = 0; private newColumn = 0; private draft: AgentModelConfig; private stored: AgentModelConfig; private readonly rows: string[]; private readonly agents: string[]; private readonly catalog: string[]; private readonly done: (result: RoutingTableResult) => void; private readonly theme: Theme | undefined; private readonly terminalRows: (() => number) | undefined; private profileName: string; private file: AgentProfilesFile; private sessionBoundName: string | undefined; private pinWinner: string | undefined; private readonly thinkingSupport: Record; /** Configuration problems the screen must not hide: pins that do not resolve, keys no agent matches. */ private notices: string[]; private readonly switchProfile: RoutingProfileSwitcher | undefined; constructor( draft: AgentModelConfig, modelOptions: string[], agents: string[], done: (result: RoutingTableResult) => void, theme?: Theme, profileName = "current", terminalRows?: () => number, options: { file?: AgentProfilesFile; sessionBoundName?: string; pinWinner?: string; feedback?: string; thinkingSupport?: Record; notices?: string[]; switchProfile?: RoutingProfileSwitcher; } = {}, ) { this.switchProfile = options.switchProfile; this.thinkingSupport = options.thinkingSupport ?? {}; this.notices = options.notices ?? []; this.file = options.file ?? bootstrapProfilesFile(cloneModelConfig(draft)); const names = Object.keys(this.file.profiles); this.profileName = hasOwnProfile(this.file.profiles, profileName) ? profileName : (names[0] ?? profileName); this.stored = cloneModelConfig(normalizeModelConfig(this.file.profiles[this.profileName]) ?? {}); this.draft = cloneModelConfig(draft); this.agents = [...agents]; this.rows = [PROFILE_ORCHESTRATOR_KEY, ROUTING_ALL_ROW, ...agents]; // The control entries of the old panel (keep current, custom id) are not // models; this screen offers inherit and custom as its own options. this.catalog = modelOptions.filter((option) => !(MODEL_CONTROL_OPTIONS as readonly string[]).includes(option)); this.done = done; this.theme = theme; this.terminalRows = terminalRows; this.sessionBoundName = options.sessionBoundName; this.pinWinner = options.pinWinner; this.feedback = options.feedback; } invalidate(): void { this.hovered = undefined; } // -- state ---------------------------------------------------------------- private profileNames(): string[] { return Object.keys(this.file.profiles).filter((name) => hasOwnProfile(this.file.profiles, name)); } private changeCount(): number { let count = 0; for (const key of [PROFILE_ORCHESTRATOR_KEY, ...this.agents]) { if (this.isEdited(key, "model")) count += 1; if (this.isEdited(key, "thinking")) count += 1; } return count; } private isEdited(key: string, field: "model" | "thinking"): boolean { return (this.draft[key]?.[field] ?? undefined) !== (this.stored[key]?.[field] ?? undefined); } private scopeGlyphs(name: string): Array<{ text: string; tone: PanelTone }> { const glyphs: Array<{ text: string; tone: PanelTone }> = []; if (this.sessionBoundName === name) glyphs.push({ text: "◉", tone: "good" }); if (this.file.active === name) glyphs.push({ text: "★", tone: "accent" }); if (this.pinWinner === name) glyphs.push({ text: "⌂", tone: "edited" }); return glyphs; } private scopeSentence(name: string): string { const parts: string[] = []; if (this.sessionBoundName === name) parts.push("Used in this session."); if (this.file.active === name) parts.push("Default for new sessions."); if (this.pinWinner === name) parts.push("Used in this repo."); return parts.length > 0 ? parts.join(" ") : "Not in use. Pick a button below to use it."; } private rowLabel(key: string): string { if (key === PROFILE_ORCHESTRATOR_KEY) return "the orchestrator"; if (key === ROUTING_ALL_ROW) return "every agent"; return key; } private modelValue(key: string): string { if (key === PROFILE_ORCHESTRATOR_KEY) return this.draft[key]?.model ?? LEAVE_ORCHESTRATOR_UNTOUCHED; // Provider review roles inherit Pi's persisted default; the table cell says // so in short, the cards spell it out. if (isProviderReviewRole(key)) return this.draft[key]?.model ?? ROUTING_PI_DEFAULT; return this.draft[key]?.model ?? routingDefaultLabel(key, "model"); } private effortValue(key: string): string { if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) return ""; const thinking = this.draft[key]?.thinking; if (thinking === undefined) return isProviderReviewRole(key) ? ROUTING_PI_DEFAULT : routingDefaultLabel(key, "effort"); const supported = this.supportedLevels(key); const landed = supported ? clampToSupportedLevel(thinking, supported) : thinking; return landed === thinking ? thinking : `${thinking} → ${landed}`; } /** * The levels the row's model supports, from the catalog. Every agent at once * offers what all their known models share. Undefined means unknown: the row * inherits its model, or the model is not in the catalog. */ private supportedLevels(key: string): readonly ThinkingLevel[] | undefined { if (key !== ROUTING_ALL_ROW) { const model = this.draft[key]?.model; return model === undefined ? undefined : this.thinkingSupport[model]; } const known = this.agents .map((agent) => this.draft[agent]?.model) .map((model) => (model === undefined ? undefined : this.thinkingSupport[model])) .filter((levels): levels is readonly ThinkingLevel[] => levels !== undefined); if (known.length === 0) return undefined; return THINKING_LEVELS.filter((level) => known.every((levels) => levels.includes(level))); } /** The effort card's choices: inherit, then the supported levels, or every level when unknown. */ private effortOptions(): Array { const supported = this.supportedLevels(this.target); return [INHERIT_THINKING, ...THINKING_LEVELS.filter((level) => !supported || supported.includes(level))]; } /** The value the picker marks as current: one agent's, or every agent's when they agree. */ private currentValue(key: string, field: "model" | "thinking"): string | undefined { if (key === ROUTING_ALL_ROW) { const values = this.agents.map((agent) => this.draft[agent]?.[field]); return values.length > 0 && values.every((value) => value === values[0]) ? values[0] : undefined; } return this.draft[key]?.[field]; } private inheritLabel(key: string): string { if (key === PROFILE_ORCHESTRATOR_KEY) return LEAVE_ORCHESTRATOR_UNTOUCHED; if (key !== ROUTING_ALL_ROW && isProviderReviewRole(key)) return routingDefaultLabel(key, "model"); return "inherit"; } private modelOptions(): string[] { const query = this.query.trim().toLowerCase(); const all = [this.inheritLabel(this.target), ...this.catalog]; const matches = query ? all.filter((option) => option.toLowerCase().includes(query)) : all; return [...matches, ROUTING_CUSTOM_MODEL]; } private menuItems(): RoutingMenuItem[] { if (this.overlay === "new") { return [ { id: "new-copy", label: `Copy of ${this.profileName}` }, { id: "new-live", label: "From what's running now" }, { id: "new-empty", label: "Empty", tone: "muted" }, ]; } return [ { id: "rename", label: "Rename" }, { id: "duplicate", label: "Duplicate" }, { id: "repo", label: this.pinWinner === this.profileName ? "Stop using in this repo" : "Use in this repo" }, { id: "delete", label: "Delete", tone: "danger" }, ]; } // -- actions ---------------------------------------------------------------- private finish(result: RoutingTableResult): void { if (this.completed) return; if (result.type === "load" && this.switchProfile) { this.showProfile(result.name, result.saveFirst); return; } this.completed = true; this.done(result); } /** Replaces the shown profile on this same screen, as a fresh open on that tab would. */ private showProfile(name: string, saveFirst?: AgentModelConfig): void { const next = this.switchProfile!(name, saveFirst); this.closeOverlay(); if ("error" in next) { this.say(next.error); return; } this.file = next.file; this.profileName = hasOwnProfile(this.file.profiles, name) ? name : (this.profileNames()[0] ?? name); this.stored = cloneModelConfig(normalizeModelConfig(this.file.profiles[this.profileName]) ?? {}); this.draft = cloneModelConfig(this.stored); this.sessionBoundName = next.sessionBoundName; this.pinWinner = next.pinWinner; this.notices = next.notices; this.target = PROFILE_ORCHESTRATOR_KEY; this.cursor = 0; this.hovered = undefined; this.feedback = next.feedback; this.escArmed = false; } private say(message: string): void { this.feedback = message; this.escArmed = false; } private closeOverlay(): void { this.overlay = undefined; this.pending = undefined; this.query = ""; } /** Leaving this profile with unsaved edits asks first; nothing is lost in silence. */ private leave(result: RoutingTableResult): void { if (this.changeCount() === 0) { this.finish(result); return; } this.pending = result; this.overlay = "switch"; } private switchTab(name: string): void { if (name === this.profileName) return; this.leave({ type: "load", name }); } private switchBy(delta: number): void { const names = this.profileNames(); const index = names.indexOf(this.profileName); const next = names[Math.max(0, Math.min(names.length - 1, index + delta))]; if (next) this.switchTab(next); } private openModel(key: string): void { this.target = key; this.overlay = "model"; this.query = ""; const options = this.modelOptions(); const current = this.currentValue(key, "model") ?? (key === ROUTING_ALL_ROW ? undefined : this.inheritLabel(key)); this.modelCursor = Math.max(0, current === undefined ? 0 : options.indexOf(current)); } private openEffort(key: string): void { if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) { this.say("Pick a model for the orchestrator first; its effort follows the model."); return; } this.target = key; this.overlay = "effort"; const current = this.currentValue(key, "thinking") as ThinkingLevel | undefined; const supported = this.supportedLevels(key); const landed = current !== undefined && supported ? clampToSupportedLevel(current, supported) : current; this.effortCursor = Math.max(0, landed === undefined ? 0 : this.effortOptions().indexOf(landed)); } private openMenu(kind: "menu" | "new"): void { this.overlay = kind; this.menuCursor = 0; } private pickModel(option: string | undefined): void { if (option === undefined) return; if (option === ROUTING_CUSTOM_MODEL) { this.finish({ type: "custom", target: this.target, config: cloneModelConfig(this.draft) }); return; } const model = option === this.inheritLabel(this.target) ? undefined : option; for (const key of this.targetKeys()) this.setModel(key, model); this.say(`${this.rowLabel(this.target)}: model ${model ?? this.inheritLabel(this.target)}. Save keeps it in ${this.profileName}.`); this.closeOverlay(); } private pickEffort(index: number): void { const option = this.effortOptions()[index]; if (option === undefined) return; const thinking = option === INHERIT_THINKING ? undefined : (option as ThinkingLevel); for (const key of this.targetKeys()) this.setThinking(key, thinking); this.say(`${this.rowLabel(this.target)}: effort ${thinking ?? "inherit"}. Save keeps it in ${this.profileName}.`); this.closeOverlay(); } private targetKeys(): string[] { return this.target === ROUTING_ALL_ROW ? this.agents : [this.target]; } private setModel(key: string, model: string | undefined): void { if (key === PROFILE_ORCHESTRATOR_KEY && model === undefined) { delete this.draft[key]; return; } const entry = { ...(this.draft[key] ?? {}) }; if (model === undefined) delete entry.model; else entry.model = model; this.draft[key] = entry.model || entry.thinking ? entry : {}; } private setThinking(key: string, thinking: ThinkingLevel | undefined): void { if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) return; const entry = { ...(this.draft[key] ?? {}) }; if (thinking === undefined) delete entry.thinking; else entry.thinking = thinking; this.draft[key] = entry.model || entry.thinking ? entry : {}; } private runMenuItem(id: string): void { const name = this.profileName; const draft = cloneModelConfig(this.draft); switch (id) { case "rename": this.finish({ type: "rename", name, draft }); return; case "duplicate": case "new-copy": this.closeOverlay(); this.leave({ type: "duplicate", name }); return; case "repo": this.finish({ type: "pin", source: "local", name, draft }); return; case "delete": this.finish({ type: "delete", name, draft }); return; case "new-live": this.closeOverlay(); this.leave({ type: "create-live" }); return; case "new-empty": this.closeOverlay(); this.leave({ type: "create" }); return; } } private pressButton(button: RoutingTableButton): void { const config = cloneModelConfig(this.draft); switch (button) { case "use": this.finish({ type: "use-session", name: this.profileName, config }); return; case "default": this.finish({ type: "apply-global", name: this.profileName, config }); return; case "save": if (this.changeCount() === 0) { this.say("Nothing to save."); return; } this.finish({ type: "save", name: this.profileName, config }); return; case "switch-save": if (this.pending) this.finish({ ...this.pending, saveFirst: config }); return; case "switch-discard": if (this.pending) this.finish(this.pending); return; case "switch-stay": this.closeOverlay(); return; } } // -- keyboard ---------------------------------------------------------------- handleInput(data: string): void { if (isKeyRelease(data)) return; if (matchesKey(data, "ctrl+c")) { this.finish({ type: "close" }); return; } switch (this.overlay) { case "model": this.handleModelKey(data); return; case "effort": this.handleEffortKey(data); return; case "menu": case "new": this.handleMenuKey(data); return; case "switch": this.handleSwitchKey(data); return; default: this.handleTableKey(data); } } private handleTableKey(data: string): void { if (matchesKey(data, "escape")) { if (this.changeCount() > 0 && !this.escArmed) { this.feedback = "Unsaved changes. Press esc again to close without saving."; this.escArmed = true; return; } this.finish({ type: "close" }); return; } this.escArmed = false; if (matchesKey(data, "left")) return this.switchBy(-1); if (matchesKey(data, "right")) return this.switchBy(1); if (matchesKey(data, "up") || matchesKey(data, "k")) { this.cursor = Math.max(0, this.cursor - 1); return; } if (matchesKey(data, "down") || matchesKey(data, "j")) { this.cursor = Math.min(this.rows.length - 1, this.cursor + 1); return; } if (matchesKey(data, "return")) return this.openModel(this.rows[this.cursor] ?? PROFILE_ORCHESTRATOR_KEY); if (matchesKey(data, "e")) return this.openEffort(this.rows[this.cursor] ?? PROFILE_ORCHESTRATOR_KEY); if (matchesKey(data, "s") || matchesKey(data, "ctrl+s")) return this.pressButton("save"); if (matchesKey(data, "u")) return this.pressButton("use"); if (matchesKey(data, "d")) return this.pressButton("default"); if (matchesKey(data, "n")) return this.openMenu("new"); if (matchesKey(data, "m")) return this.openMenu("menu"); } private handleModelKey(data: string): void { const options = this.modelOptions(); if (matchesKey(data, "escape")) return this.closeOverlay(); if (matchesKey(data, "return")) return this.pickModel(options[this.modelCursor]); if (matchesKey(data, "up")) { this.modelCursor = Math.max(0, this.modelCursor - 1); return; } if (matchesKey(data, "down")) { this.modelCursor = Math.min(options.length - 1, this.modelCursor + 1); return; } if (matchesKey(data, "backspace")) { this.query = this.query.slice(0, -1); this.modelCursor = 0; return; } // Every printable key types into the search, so letters never act here. if (data.length === 1 && data.charCodeAt(0) >= 32) { this.query += data; this.modelCursor = 0; } } private handleEffortKey(data: string): void { if (matchesKey(data, "escape")) return this.closeOverlay(); if (matchesKey(data, "return")) return this.pickEffort(this.effortCursor); if (matchesKey(data, "left") || matchesKey(data, "up")) { this.effortCursor = Math.max(0, this.effortCursor - 1); return; } if (matchesKey(data, "right") || matchesKey(data, "down")) { this.effortCursor = Math.min(this.effortOptions().length - 1, this.effortCursor + 1); } } private handleMenuKey(data: string): void { const items = this.menuItems(); if (matchesKey(data, "escape")) return this.closeOverlay(); if (matchesKey(data, "up") || matchesKey(data, "k")) { this.menuCursor = Math.max(0, this.menuCursor - 1); return; } if (matchesKey(data, "down") || matchesKey(data, "j")) { this.menuCursor = Math.min(items.length - 1, this.menuCursor + 1); return; } if (matchesKey(data, "return")) { const item = items[this.menuCursor]; if (item) this.runMenuItem(item.id); } } private handleSwitchKey(data: string): void { if (matchesKey(data, "escape")) return this.pressButton("switch-stay"); if (matchesKey(data, "s") || matchesKey(data, "return")) return this.pressButton("switch-save"); if (matchesKey(data, "d")) return this.pressButton("switch-discard"); } // -- pointer ---------------------------------------------------------------- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined { if (event.type === "wheel" && event.wheelDelta) { const delta = event.wheelDelta < 0 ? -1 : 1; if (this.overlay === "model") { this.modelCursor = Math.max(0, Math.min(this.modelOptions().length - 1, this.modelCursor + delta)); } else if (this.overlay === "effort") { this.effortCursor = Math.max(0, Math.min(this.effortOptions().length - 1, this.effortCursor + delta)); } else if (this.overlay === "menu" || this.overlay === "new") { this.menuCursor = Math.max(0, Math.min(this.menuItems().length - 1, this.menuCursor + delta)); } else if (this.overlay === undefined) { this.cursor = Math.max(0, Math.min(this.rows.length - 1, this.cursor + delta)); } return { handled: true, render: true }; } const exact = this.hitRegions.find( (region) => region.y === event.y && event.x >= region.x0 && event.x < region.x1, ); if (event.type === "move") { if (event.button !== "none") return undefined; // Whole-row targets light up anywhere on their line; buttons and tabs // only under the pointer. const hit = exact ?? this.hitRegions.find((region) => region.y === event.y && this.isRowAction(region.action)); const next = hit ? this.hoverKey(hit.action) : undefined; const changed = next !== this.hovered; this.hovered = next; return { handled: hit !== undefined, render: changed }; } // A click arrives as press then click: the press only claims the pointer, // so one click never runs two actions. if (event.type === "press") return exact ? { handled: true } : undefined; if (event.type !== "click" || event.button !== "left") return undefined; if (!exact) { if (this.overlay !== undefined && this.overlay !== "switch") { this.closeOverlay(); return { handled: true, render: true }; } return undefined; } this.dispatch(exact.action); return { handled: true, render: true }; } private isRowAction(action: RoutingTableMouseAction): boolean { return action.kind === "row" || action.kind === "model-option" || action.kind === "menu-item"; } private hoverKey(action: RoutingTableMouseAction): string { switch (action.kind) { case "row": case "effort": return `row:${action.row}`; case "tab": return `tab:${action.name}`; case "model-option": case "effort-option": return `${action.kind}:${action.index}`; case "menu-item": return `menu:${action.id}`; case "button": return `button:${action.button}`; default: return action.kind; } } private dispatch(action: RoutingTableMouseAction): void { switch (action.kind) { case "tab": this.switchTab(action.name); return; case "tab-menu": this.openMenu("menu"); return; case "new": this.openMenu("new"); return; case "row": this.cursor = Math.max(0, this.rows.indexOf(action.row)); this.openModel(action.row); return; case "effort": this.cursor = Math.max(0, this.rows.indexOf(action.row)); this.openEffort(action.row); return; case "model-option": this.pickModel(this.modelOptions()[action.index]); return; case "effort-option": this.pickEffort(action.index); return; case "menu-item": this.runMenuItem(action.id); return; case "button": this.pressButton(action.button); return; } } // -- render ---------------------------------------------------------------- render(width: number): string[] { this.hitRegions = []; const inner = Math.max(20, width - 4); if (this.overlay === undefined) return this.renderFrame(this.tableLines(inner), width); // The table stays on screen, dimmed, under the card; its regions are // dropped because the card is modal. const table = this.tableLines(inner).map((line) => this.renderText(stripAnsi(line), "dim")); this.hitRegions = []; const card = this.cardFor(inner); return this.renderFrame(this.placeCard(table, card, inner), width); } private renderFrame(body: string[], width: number): string[] { const inner = Math.max(20, width - 4); const rows = this.terminalRows ? Math.floor(this.terminalRows()) - 2 : body.length; const padded = body.length < rows ? [...body, ...Array(rows - body.length).fill("")] : body.slice(0, Math.max(rows, 1)); const rule = "─".repeat(inner + 2); const border = (text: string) => this.renderText(text, "border"); return [ border(`╭${rule}╮`), ...padded.map((line) => `${border("│")} ${this.fit(line, inner)} ${border("│")}`), border(`╰${rule}╯`), ]; } private tableLines(inner: number): string[] { const lines: string[] = []; // Regions are recorded in frame coordinates: the top border is row 0 and // content starts two columns in ("│ "). const region = (action: RoutingTableMouseAction, x0: number, x1: number) => this.hitRegions.push({ y: lines.length + 1, x0: 2 + x0, x1: 2 + x1, action }); // Tabs: every profile, its scopes, the menu on the active one, and New. const tabs = this.tabWindow(inner); let column = 0; let tabLine = ""; let activeStart = 0; let activeEnd = 0; const put = (text: string, tone: PanelTone | undefined, action?: RoutingTableMouseAction) => { if (action) region(action, column, column + visibleWidth(text)); const styled = tone ? this.renderText(text, tone) : sanitizeTerminalText(text); tabLine += action ? this.hoverWrap(this.hoverKey(action), styled, text) : styled; column += visibleWidth(text); }; put(" ", undefined); if (tabs.before) put("‹ ", "dim"); for (const name of tabs.names) { const active = name === this.profileName; if (active) activeStart = column; put(` ${truncateToWidth(sanitizeTerminalText(name), 18, "…")} `, active ? "title" : "muted", { kind: "tab", name }); for (const glyph of this.scopeGlyphs(name)) put(glyph.text, glyph.tone); if (active) { put(" ▾ ", "accent", { kind: "tab-menu" }); activeEnd = column; this.activeTabColumn = activeStart; } put(" ", undefined); } if (tabs.after) put("› ", "dim"); this.newColumn = column; put("+ New", "accent", { kind: "new" }); lines.push(tabLine); lines.push( this.renderText("─".repeat(activeStart), "border") + this.renderText("━".repeat(Math.max(0, activeEnd - activeStart)), "accent") + this.renderText("─".repeat(Math.max(0, inner - activeEnd)), "border"), ); const title = truncateToWidth(sanitizeTerminalText(this.profileName), Math.max(8, inner - 4), "…"); const room = inner - 1 - visibleWidth(title) - 3; const sentence = [this.scopeSentence(this.profileName), this.shortScope(this.profileName)] .find((candidate) => visibleWidth(candidate) <= room) ?? ""; lines.push(` ${this.renderText(title, "title")}${sentence ? ` ${this.renderText(sentence, "muted")}` : ""}`); const notices = this.noticeLines(inner); for (const notice of notices) lines.push(this.renderText(notice, "edited")); lines.push(""); // Labels are measured after sanitizing: control sequences in an agent name // would otherwise pad the column and then vanish, gluing name and model. const agentCol = Math.min(26, Math.max("Orchestrator".length, ...this.agents.map((agent) => visibleWidth(sanitizeTerminalText(agent)))) + 2); const padLabel = (text: string) => { const safe = truncateToWidth(sanitizeTerminalText(text), agentCol - 1, "…"); return safe + " ".repeat(Math.max(0, agentCol - visibleWidth(safe))); }; const modelCol = Math.max(20, Math.min(40, inner - agentCol - 12)); const effortStart = 2 + agentCol + modelCol; const row = (key: string, label: string, labelTone: PanelTone) => { const focused = this.rows[this.cursor] === key; const model = this.modelValue(key); const effort = this.effortValue(key); const modelTone: PanelTone = this.isEdited(key, "model") ? "edited" : model === LEAVE_ORCHESTRATOR_UNTOUCHED || model === "inherit" || model === ROUTING_PI_DEFAULT ? "dim" : "text"; const effortTone: PanelTone = this.isEdited(key, "thinking") ? "edited" : effort === "inherit" || effort === ROUTING_PI_DEFAULT ? "dim" : "status"; const plain = `${focused ? "▸ " : " "}${padLabel(label)}${truncateToWidth(model, modelCol - 1, "…").padEnd(modelCol)}${effort}`; const styled = this.renderText(focused ? "▸ " : " ", "accent") + this.renderText(padLabel(label), labelTone) + this.renderText(truncateToWidth(model, modelCol - 1, "…").padEnd(modelCol), modelTone) + this.renderText(effort, effortTone); if (effort) region({ kind: "effort", row: key }, effortStart, inner); region({ kind: "row", row: key }, 0, inner); lines.push(this.hoverWrap(`row:${key}`, styled, plain, inner)); }; row(PROFILE_ORCHESTRATOR_KEY, "Orchestrator", "title"); lines.push(` ${this.renderText("─".repeat(Math.max(0, effortStart + 8 - 1)), "border")}`); // The header doubles as the "every agent" row: Model ▾ and Effort ▾. const headerFocused = this.rows[this.cursor] === ROUTING_ALL_ROW; const headerBase = `${headerFocused ? "▸ " : " "}${"Agent".padEnd(agentCol)}${"Model ▾".padEnd(modelCol)}Effort ▾`; const headerNote = visibleWidth(`${headerBase} set every agent`) <= inner ? " set every agent" : ""; const headerPlain = `${headerBase}${headerNote}`; region({ kind: "effort", row: ROUTING_ALL_ROW }, effortStart, effortStart + "Effort ▾".length); region({ kind: "row", row: ROUTING_ALL_ROW }, 0, inner); lines.push( this.hoverWrap( `row:${ROUTING_ALL_ROW}`, this.renderText(headerFocused ? "▸ " : " ", "accent") + this.renderText("Agent".padEnd(agentCol), "muted") + this.renderText("Model ▾".padEnd(modelCol), "muted") + this.renderText("Effort ▾", "muted") + this.renderText(headerNote, "dim"), headerPlain, inner, ), ); const window = this.agentWindow(notices.length); if (window.start > 0) lines.push(this.renderText(` ↑ ${window.start} more`, "dim")); for (const agent of this.agents.slice(window.start, window.end)) row(agent, agent, "text"); if (window.end < this.agents.length) lines.push(this.renderText(` ↓ ${this.agents.length - window.end} more`, "dim")); lines.push(""); // Footer: three buttons, the unsaved count on the right. const changes = this.changeCount(); const status = changes > 0 ? `● ${changes} unsaved` : "Saved"; const buttonsWidth = (labels: (id: RoutingTableButton) => string) => 1 + (["use", "default", "save"] as const).reduce((sum, id) => sum + labels(id).length + 6, 0); const fullLabel = (id: RoutingTableButton) => ROUTING_TABLE_BUTTONS[id]; const shortLabel = (id: RoutingTableButton) => ROUTING_TABLE_SHORT_BUTTONS[id] ?? ROUTING_TABLE_BUTTONS[id]; const label = buttonsWidth(fullLabel) + status.length + 1 <= inner ? fullLabel : shortLabel; let footer = ""; let footerColumn = 0; const button = (id: RoutingTableButton, tone: PanelTone) => { const text = `[ ${label(id)} ]`; region({ kind: "button", button: id }, footerColumn, footerColumn + text.length); footer += this.hoverWrap(`button:${id}`, this.renderText(text, tone), text); footer += " "; footerColumn += text.length + 2; }; footer += " "; footerColumn += 1; button("use", "accent"); button("default", "accent"); button("save", changes > 0 ? "title" : "dim"); const gap = Math.max(1, inner - footerColumn - status.length - 1); lines.push(footer + " ".repeat(gap) + this.renderText(status, changes > 0 ? "edited" : "good")); lines.push(this.renderText(this.fitHints(inner), "dim")); const [first = "", second = ""] = this.wrapFeedback(inner); const feedbackTone: PanelTone = this.escArmed ? "edited" : "muted"; lines.push(first ? this.renderText(first, feedbackTone) : ""); lines.push(second ? this.renderText(second, feedbackTone) : ""); return lines; } /** The scope in a few characters, for terminals too narrow for the sentence. */ private shortScope(name: string): string { const parts = this.scopeGlyphs(name).map((glyph) => glyph.text === "◉" ? "◉ session" : glyph.text === "★" ? "★ default" : "⌂ repo", ); return parts.length > 0 ? parts.join(" ") : "not in use"; } /** As many key hints as fit, chosen by priority and shown in their usual order. */ private fitHints(inner: number): string { const budget = inner - 1; const chosen = new Set(); let used = 0; const byPriority = ROUTING_TABLE_HINTS.map((hint, index) => ({ ...hint, index })).sort((a, b) => a.keep - b.keep); for (const hint of byPriority) { const cost = visibleWidth(hint.text) + (chosen.size > 0 ? 2 : 0); if (used + cost > budget) continue; chosen.add(hint.index); used += cost; } return ` ${ROUTING_TABLE_HINTS.filter((_, index) => chosen.has(index)).map((hint) => hint.text).join(" ")}`; } /** * Each notice wrapped between words under a ⚠, at most four lines in all so * the table keeps its room; anything beyond is counted, never cut mid-word. */ private noticeLines(inner: number): string[] { const width = Math.max(10, inner - 3); const lines: string[] = []; let shown = 0; for (const notice of this.notices) { const wrapped: string[] = []; let current = ""; for (const word of sanitizeTerminalText(notice).split(/\s+/).filter(Boolean)) { const next = current ? `${current} ${word}` : word; if (visibleWidth(next) <= width || current === "") { current = next; continue; } wrapped.push(current); current = word; } if (current) wrapped.push(current); if (lines.length + wrapped.length > 4) break; wrapped.forEach((line, index) => lines.push(`${index === 0 ? " ⚠ " : " "}${line}`)); shown += 1; } const hidden = this.notices.length - shown; if (hidden > 0) lines.push(` ${hidden} more problem${hidden === 1 ? "" : "s"} not shown.`); return lines; } /** The feedback message on up to two lines, broken between words. */ private wrapFeedback(inner: number): string[] { if (!this.feedback) return []; const width = Math.max(10, inner - 1); const lines: string[] = []; let current = ""; for (const word of sanitizeTerminalText(this.feedback).split(/\s+/).filter(Boolean)) { const next = current ? `${current} ${word}` : word; if (visibleWidth(next) <= width) { current = next; continue; } if (current) lines.push(current); current = word; } if (current) lines.push(current); if (lines.length > 2) { lines[1] = truncateToWidth(lines.slice(1).join(" "), width, "…"); lines.length = 2; } return lines.map((line) => ` ${line}`); } /** Which tabs fit around the active one, and whether more hide on either side. */ private tabWindow(inner: number): { names: string[]; before: boolean; after: boolean } { const names = this.profileNames(); const widthOf = (name: string) => Math.min(18, visibleWidth(name)) + 4 + this.scopeGlyphs(name).length + (name === this.profileName ? 3 : 0); const budget = inner - 1 - "+ New".length - 4; const active = Math.max(0, names.indexOf(this.profileName)); let start = active; let end = active + 1; let used = widthOf(names[active] ?? ""); for (let grew = true; grew;) { grew = false; if (end < names.length && used + widthOf(names[end]) <= budget) { used += widthOf(names[end]); end += 1; grew = true; } if (start > 0 && used + widthOf(names[start - 1]) <= budget) { start -= 1; used += widthOf(names[start]); grew = true; } } return { names: names.slice(start, end), before: start > 0, after: end < names.length }; } private agentWindow(noticeRows = 0): { start: number; end: number } { const total = this.agents.length; if (!this.terminalRows) return { start: 0, end: total }; const room = Math.max(3, Math.floor(this.terminalRows()) - 2 - ROUTING_TABLE_FIXED_ROWS - noticeRows); if (total <= room) return { start: 0, end: total }; const visible = Math.max(1, room - 2); const focus = Math.max(0, this.cursor - 2); const start = Math.max(0, Math.min(focus - Math.floor(visible / 2), total - visible)); return { start, end: start + visible }; } // -- cards ------------------------------------------------------------------- private cardFor(inner: number): { lines: string[]; width: number; top: number; left: number } { switch (this.overlay) { case "model": return this.modelCard(inner); case "effort": return this.effortCard(inner); case "menu": return this.menuCard(inner, this.activeTabColumn); case "new": return this.menuCard(inner, this.newColumn); default: return this.switchCard(inner); } } private modelCard(inner: number): { lines: string[]; width: number; top: number; left: number } { const width = Math.min(56, inner - 4); const content = width - 4; const lines: string[] = []; const region = (action: RoutingTableMouseAction) => this.hitRegions.push({ y: lines.length, x0: 0, x1: content, action }); lines.push(this.renderText("Model for ", "muted") + this.renderText(this.rowLabel(this.target), "title")); lines.push(this.renderText("⌕ ", "accent") + (this.query ? sanitizeTerminalText(this.query) : this.renderText("type to search", "dim"))); lines.push(this.renderText("─".repeat(content), "border")); const options = this.modelOptions(); const current = this.currentValue(this.target, "model") ?? (this.target === ROUTING_ALL_ROW ? undefined : this.inheritLabel(this.target)); const rows = this.terminalRows ? Math.floor(this.terminalRows()) : options.length + ROUTING_MODEL_CARD_CHROME_ROWS; const visible = Math.max(3, Math.min(options.length, rows - ROUTING_MODEL_CARD_CHROME_ROWS)); const start = Math.max(0, Math.min(this.modelCursor - Math.floor(visible / 2), options.length - visible)); for (let index = start; index < Math.min(options.length, start + visible); index += 1) { const option = options[index] ?? ""; const focused = index === this.modelCursor; const tone: PanelTone = focused ? "title" : option === ROUTING_CUSTOM_MODEL || option === this.inheritLabel(this.target) ? "muted" : "text"; const mark = option === current ? " ✓" : ""; const plain = `${focused ? "▸ " : " "}${option}${mark}`; region({ kind: "model-option", index }); lines.push( this.hoverWrap( `model-option:${index}`, this.renderText(focused ? "▸ " : " ", "accent") + this.renderText(option, tone) + this.renderText(mark, "good"), plain, content, ), ); } lines.push(""); lines.push(this.renderText("↑↓ choose · enter pick · esc back", "dim")); return { lines, width, top: 4, left: Math.max(0, Math.floor((inner - width) / 2)) }; } private effortCard(inner: number): { lines: string[]; width: number; top: number; left: number } { // Provider review roles inherit Pi's persisted default, so their inherit // choice says so (gentle-pi#2271). const inheritLabel = this.target !== ROUTING_ALL_ROW && isProviderReviewRole(this.target) ? routingDefaultLabel(this.target, "effort") : "inherit"; const chips = this.effortOptions().map((option, index) => { const label = option === INHERIT_THINKING ? inheritLabel : option; const text = index === this.effortCursor ? `[${label}]` : ` ${label} `; return { index, text }; }); const oneRow = chips.reduce((sum, chip) => sum + chip.text.length + 1, 0) - 1; const width = Math.min(inner - 4, Math.max(44, oneRow + 4)); const content = width - 4; // One row when it fits; otherwise the levels wrap onto a second row, so a // narrow terminal never cuts a level in half. const rows: Array = [[]]; let used = 0; for (const chip of chips) { const cost = chip.text.length + (rows[rows.length - 1].length > 0 ? 1 : 0); if (used + cost > content && rows[rows.length - 1].length > 0) { rows.push([]); used = 0; } rows[rows.length - 1].push(chip); used += chip.text.length + (rows[rows.length - 1].length > 1 ? 1 : 0); } const lines: string[] = []; lines.push(this.renderText("Effort for ", "muted") + this.renderText(this.rowLabel(this.target), "title")); lines.push(this.renderText(truncateToWidth(this.effortSource(), content, "…"), "dim")); for (const row of rows) { let column = 0; const drawn: string[] = []; for (const chip of row) { this.hitRegions.push({ y: lines.length, x0: column, x1: column + chip.text.length, action: { kind: "effort-option", index: chip.index }, }); const focused = chip.index === this.effortCursor; drawn.push(this.hoverWrap(`effort-option:${chip.index}`, this.renderText(chip.text, focused ? "title" : "status"), chip.text)); column += chip.text.length + 1; } lines.push(drawn.join(" ")); } lines.push(""); lines.push(this.renderText("←→ choose · enter pick · esc back", "dim")); return { lines, width, top: 6, left: Math.max(0, Math.floor((inner - width) / 2)) }; } /** One line under the effort card's title: whose levels these are. */ private effortSource(): string { const supported = this.supportedLevels(this.target); if (this.target === ROUTING_ALL_ROW) { return supported ? "Levels every agent's model supports." : "Models unknown to the catalog: every level is shown."; } const model = this.draft[this.target]?.model; if (model === undefined) return "Inherits its model: every level is shown."; return supported ? `Levels ${model} supports.` : `${model} is not in the catalog: every level is shown.`; } private menuCard(inner: number, anchor: number): { lines: string[]; width: number; top: number; left: number } { const items = this.menuItems(); const width = Math.min(inner - 2, Math.max(28, ...items.map((item) => visibleWidth(item.label) + 8))); const content = width - 4; const lines: string[] = []; items.forEach((item, index) => { if (item.id === "delete") lines.push(this.renderText("─".repeat(content), "border")); const focused = index === this.menuCursor; const plain = `${focused ? "▸ " : " "}${item.label}`; this.hitRegions.push({ y: lines.length, x0: 0, x1: content, action: { kind: "menu-item", id: item.id } }); lines.push( this.hoverWrap( `menu:${item.id}`, this.renderText(focused ? "▸ " : " ", "accent") + this.renderText(item.label, item.tone ?? (focused ? "title" : "text")), plain, content, ), ); }); return { lines, width, top: 2, left: Math.max(0, Math.min(anchor, inner - width)) }; } private switchCard(inner: number): { lines: string[]; width: number; top: number; left: number } { const width = Math.min(inner - 4, 60); const changes = this.changeCount(); const lines: string[] = []; lines.push( this.renderText(sanitizeTerminalText(this.profileName), "title") + this.renderText(` has ${changes} unsaved change${changes === 1 ? "" : "s"}.`, "text"), ); lines.push(""); let column = 0; let buttons = ""; for (const id of ["switch-save", "switch-discard", "switch-stay"] as const) { const text = `[ ${ROUTING_TABLE_BUTTONS[id]} ]`; this.hitRegions.push({ y: lines.length, x0: column, x1: column + text.length, action: { kind: "button", button: id } }); buttons += this.hoverWrap(`button:${id}`, this.renderText(text, id === "switch-save" ? "title" : id === "switch-stay" ? "dim" : "accent"), text) + " "; column += text.length + 2; } lines.push(buttons); lines.push(this.renderText("enter save and switch · d discard · esc stay", "dim")); return { lines, width, top: 6, left: Math.max(0, Math.floor((inner - width) / 2)) }; } /** * Draws a card over the dimmed table. Card regions were recorded relative to * the card content; they move into frame coordinates here: frame border and * "│ " before the body, the card's own border and "│ " before its content. */ private placeCard( table: string[], card: { lines: string[]; width: number; top: number; left: number }, inner: number, ): string[] { const regions = this.hitRegions; this.hitRegions = regions.map((region) => ({ ...region, y: region.y + card.top + 2, x0: 2 + card.left + 2 + region.x0, x1: 2 + card.left + 2 + region.x1, })); const content = card.width - 4; const rule = "─".repeat(card.width - 2); const drawn = [ this.renderText(`╭${rule}╮`, "accent"), ...card.lines.map((line) => `${this.renderText("│", "accent")} ${this.fit(line, content)} ${this.renderText("│", "accent")}`), this.renderText(`╰${rule}╯`, "accent"), ]; const body = [...table]; const rows = Math.max(body.length, card.top + drawn.length); while (body.length < rows) body.push(""); for (let index = 0; index < drawn.length; index += 1) { const target = card.top + index; const right = " ".repeat(Math.max(0, inner - card.left - card.width)); body[target] = " ".repeat(card.left) + drawn[index] + right; } return body; } // -- text helpers -------------------------------------------------------------- /** * The hover fill. It uses the theme's selection background, which reads on * every bundled theme; the pending-tool background it replaced is a shade * away from the terminal background on Gentleman-Cute and never showed. */ private hoverWrap(key: string, styled: string, plain: string, fillTo?: number): string { if (this.hovered !== key || !this.theme) return styled; const text = fillTo === undefined ? plain : plain + " ".repeat(Math.max(0, fillTo - visibleWidth(plain))); return this.theme.bg("selectedBg", this.theme.fg("text", sanitizeTerminalText(text))); } private renderText(text: string, tone: PanelTone): string { const safe = sanitizeTerminalText(text); if (!this.theme) return safe; return this.theme.fg(PANEL_TONE_COLOR[tone] ?? "text", safe); } private fit(line: string, width: number): string { const plain = stripAnsi(line); const visible = visibleWidth(plain); if (visible > width) return truncateToWidth(plain, Math.max(1, width), "…", true); return `${line}${" ".repeat(Math.max(0, width - visible))}`; } } interface RoutingTableTestEvent { type: "move" | "click" | "wheel"; x: number; y: number; button?: string; wheelDelta?: number; } function renderRoutingTableForTesting( draft: AgentModelConfig, modelOptions: string[], agents: string[], width: number, theme?: Theme, terminalRows?: number, inputs: string[] = [], mouseEvents: RoutingTableTestEvent[] = [], results: RoutingTableResult[] = [], options: { file?: AgentProfilesFile; sessionBoundName?: string; pinWinner?: string; profileName?: string; feedback?: string; thinkingSupport?: Record; notices?: string[]; } = {}, ): string[] { const panel = new RoutingTablePanel( draft, modelOptions, agents, (result) => results.push(result), theme, options.profileName ?? "current", terminalRows === undefined ? undefined : () => terminalRows, options, ); panel.render(width); for (const data of inputs) { panel.handleInput(data); panel.render(width); } const send = (event: RoutingTableTestEvent, type: TuiMouseEvent["type"]) => panel.handleMouse({ type, x: event.x, y: event.y, button: event.button ?? (type === "move" ? "none" : "left"), wheelDelta: event.wheelDelta, screenX: event.x, screenY: event.y, width, height: terminalRows ?? 40, } as TuiMouseEvent); for (const event of mouseEvents) { // Real terminals send press then click; replay both so a test catches // any action that would fire twice. if (event.type === "click") send(event, "press"); send(event, event.type); panel.render(width); } return panel.render(width); } interface RoutingTablePanelRequest { name: string; config: AgentModelConfig; file: AgentProfilesFile; sessionBoundName?: string; pinWinner?: string; feedback?: string; notices?: string[]; switchProfile?: RoutingProfileSwitcher; } /** * What the models screen must not hide: a pin layer that does not resolve * (invalid file, or a name no profile defines) silently returns the repository * to the global routing, and a routing key no agent matches is silently ignored * at launch (issues #2196 and #2400). */ function routingScreenNotices( ctx: ExtensionContext, file: AgentProfilesFile, shownName: string, sessionBoundName: string | undefined, launchRouting?: AgentModelConfig, authorityAvailable = true, ): string[] { const notices: string[] = []; if (!authorityAvailable) notices.push("Session profile authority is unavailable; affected launches are blocked. Use in this session selects one."); const pins = evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles); for (const issue of pins.invalid) { notices.push(`The ${issue.source} pin file ${issue.path} is not a valid pin and is ignored. Fix or remove it.`); } for (const issue of pins.stale) { notices.push(`The ${issue.source} pin names "${issue.profile}", which no profile defines, so it is ignored (${issue.path}).`); } if (sessionBoundName !== undefined && !hasOwnProfile(file.profiles, sessionBoundName)) { notices.push(`This session launches with "${sessionBoundName}", which is no longer in profiles.json; its launches keep that routing.`); } // The table shows the saved profile; the session's launches keep the routing // they took at the freeze or the binding. Both sides compare role routes only. const roleRoutes = (config: unknown) => JSON.stringify(Object.entries(normalizeModelConfig(config) ?? {}) .filter(([key]) => !isProfileOrchestratorKey(key)).sort(([a], [b]) => a.localeCompare(b))); if (shownName === sessionBoundName && launchRouting !== undefined && hasOwnProfile(file.profiles, shownName) && roleRoutes(file.profiles[shownName]) !== roleRoutes(launchRouting)) { notices.push(`Saved "${shownName}" differs from this session's launches; Use in this session adopts it.`); } const roots = { cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd, home: homedir(), agentHome: resolveGentlePiAgentHome(), }; let discovered: Array<{ name: string }> = []; try { discovered = discoverAgents(roots).agents; } catch { return notices; } // gentle-pi#2271: provider review roles are routed by key and never have an // agent file, so they always count as matched. const known = [...PROVIDER_REVIEW_ROLES.map((name) => ({ name })), ...discovered]; const profileKeys = Object.keys(normalizeModelConfig(file.profiles[shownName]) ?? {}).filter((key) => !isProfileOrchestratorKey(key)); const unmatched = unmatchedProfileKeys(Object.fromEntries(profileKeys.map((key) => [key, { model: undefined, thinking: undefined }])), known); const ignored = unmatched.filter((entry) => entry.suggestion === undefined).map((entry) => entry.key); if (ignored.length > 0) { notices.push( `Profile key${ignored.length === 1 ? "" : "s"} ${ignored.map((key) => `"${key}"`).join(", ")} match${ignored.length === 1 ? "es" : ""} no agent configured in gentle-shell, so ${ignored.length === 1 ? "it is" : "they are"} ignored at launch.`, ); } for (const entry of unmatched) { if (entry.suggestion !== undefined) notices.push(`Profile key "${entry.key}" matches no agent configured in gentle-shell; did you mean ${entry.suggestion}?`); } // Issue #2400: model_profiles keys in subagents.json that match nothing and // suggest nothing. When the session resolves a profile (its binding or the one // frozen at startup), that profile replaces those launches' routing, so the // file's keys do not apply there; a pin the session did not freeze does not. if (sessionBoundName === undefined) { const stray = unmatchedProfileKeys(loadAgentsConfig(roots).modelProfiles, discovered) .filter((entry) => entry.suggestion === undefined && !isProviderReviewRole(entry.key) && !profileKeys.includes(entry.key)) .map((entry) => entry.key); if (stray.length > 0) { notices.push(`subagents.json routes ${stray.map((key) => `"${key}"`).join(", ")}, which no agent matches, so ${stray.length === 1 ? "it is" : "they are"} ignored.`); } } return notices; } async function showRoutingTablePanel( ctx: ExtensionContext, request: RoutingTablePanelRequest, ): Promise { const modelOptions = await getPiModelOptions(ctx); const thinkingSupport = await getPiThinkingSupport(ctx); const agents = modelAssignmentNames(ctx.cwd); return ctx.ui.custom( (tui, theme, _keybindings, done) => { const panel = new RoutingTablePanel( request.config, modelOptions, agents, done, theme, request.name, () => Math.max(0, tui.terminal.rows), { file: request.file, sessionBoundName: request.sessionBoundName, pinWinner: request.pinWinner, feedback: request.feedback, thinkingSupport, notices: request.notices, switchProfile: request.switchProfile, }, ); const container = createNativeFullscreenInteraction({ keyboardTarget: panel, requestRender: () => tui.requestRender(), }); container.addChild(panel); return container; }, { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%", margin: 0, }, }, ); } /** * Bare `/gentle:profiles` opens the models screen on the profile this * repository resolves, after the routing preflight: legacy project overrides * migrate and a pinned repository is named before any edit. An unreadable * saved routing file does not block the screen; Make default discloses it. */ async function openModelsScreen(ctx: ExtensionContext, live: LiveSession): Promise { migrateLegacyProjectModelOverrides(ctx.cwd); // A pinned repository resolves its subagent routing from the profile at launch, // so global routing written elsewhere will not reach its subagents. Saying it // before the edits, not after them, is the difference between a note and a surprise. const pinNote = profilePinScopeNote(ctx.cwd); if (pinNote) ctx.ui.notify(pinNote, "info"); const read = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())); const target = read.status === "valid" ? resolveCurrentProfileTarget(ctx.cwd, read) : read.status === "missing" ? ({ name: "current", source: "active" } as CurrentProfileTarget) : undefined; // The screen opens on the profile this session launches with while the store // still has it (gentle-shell#1064 slice 3c). const authorityAvailable = sessionProfileAuthorityAvailable(ctx); const launchName = sessionLaunchProfile(ctx, authorityAvailable)?.name; const opensOnLaunch = read.status === "valid" && launchName !== undefined && hasOwnProfile(read.file.profiles, launchName); await openProfilesPanelLoop(ctx, live, opensOnLaunch ? launchName : target?.name, authorityAvailable); } /** `/gentle:profiles export-routing`: write the saved global routing to models.export.json. */ async function exportRoutingCommand(ctx: ExtensionContext): Promise { try { const count = await exportSavedModelConfig(ctx); ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info"); } catch (error) { ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning"); } } /** `/gentle:profiles restore-routing`: replace the saved global routing with models.export.json, then apply it. */ async function restoreModelExport(ctx: ExtensionContext): Promise { const restored = await readModelExport(ctx); if (!restored) { ctx.ui.notify(`Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, "warning"); return; } const approved = await ctx.ui.confirm( "Restore saved model routing?", `Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`, ); if (!approved) return; try { await writeModelConfigAsync(ctx.cwd, restored); } catch (error) { ctx.ui.notify(`Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, "warning"); return; } try { const applyResult = await applyModelConfigAsync(ctx.cwd, restored); ctx.ui.notify([ "el Gentleman restored global model config.", `Import: ${modelExportPath(ctx.cwd)}`, `Global config: ${modelConfigPath(ctx.cwd)}`, `Agents updated: ${applyResult.updated}`, ].join("\n"), "info"); } catch (error) { ctx.ui.notify([ "el Gentleman restored global model config, but applying it to agents failed.", `Global config: ${modelConfigPath(ctx.cwd)}`, `Apply error: ${error instanceof Error ? error.message : String(error)}`, ].join("\n"), "warning"); } } /** The profile bare `/gentle:profiles` preselects in the unified panel, and why it is that one. */ interface CurrentProfileTarget { name: string; source: "pinned" | "active"; } /** * A pinned repository launches with its pinned profile, so that profile is the * one worth updating from here; anywhere else the globally active profile is. * Neither existing yields no target rather than a guess. */ function resolveCurrentProfileTarget( cwd: string, store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())), ): CurrentProfileTarget | undefined { const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); if (pin) return { name: pin.profile, source: "pinned" }; if (store.status !== "valid" || store.file.active === undefined) return undefined; if (!hasOwnProfile(store.file.profiles, store.file.active)) return undefined; return { name: store.file.active, source: "active" }; } /** * What a panel action hands the reopened panel: a one-line status for the footer, * because notifications stay hidden behind the fullscreen overlay until it closes, * and the profile to select when the action created or renamed one. */ interface ProfilesPanelReport { status?: string; selectedName?: string; } function hasOwnProfile(profiles: Record, name: string): boolean { return Object.prototype.hasOwnProperty.call(profiles, name); } function profilesErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } /** * The pin layer that wins, in one line: the winning layer's scope, name, and exact * file path, or the first stale layer named as missing from the loaded store. An * invalid layer is reported by `profilePinDetailLines` instead, because a file that * is not a pin is not a layer that could ever win. */ function profilePinStateLabel( status: ProfilePinStatus | undefined, evaluation: ProfilePinEvaluation, ): string { if (!status) return "none (not inside a Git worktree)"; if (evaluation.winner) { return `${evaluation.winner.source}: ${evaluation.winner.profile} — ${evaluation.winner.path}`; } const stale = evaluation.stale[0]; if (stale) return `${stale.source}: ${stale.profile} (missing from this store) — ${stale.path}`; return "none"; } /** * Every line the panel needs to tell a pinned repository the truth: the winning * layer and its exact file, an explicit warning for a file that is not a pin named * by path (kept separate from a pin naming a profile this store no longer defines), * and one sentence that a winning pin outranks the globally active profile and every * global routing write. */ function profilePinDetailLines( status: ProfilePinStatus | undefined, profiles: Record, ): string[] { const evaluation = evaluateProfilePin(status, profiles); const lines = [`pin ${profilePinStateLabel(status, evaluation)}`]; const shownStale = evaluation.winner ? undefined : evaluation.stale[0]; for (const issue of evaluation.invalid) { lines.push(` invalid pin file ${issue.path} (${issue.source} layer); ignoring it — fix or remove the file.`); } for (const issue of evaluation.stale) { if (issue === shownStale) continue; lines.push(` the ${issue.source} pin names "${issue.profile}", which this store does not define; ignoring it (${issue.path}).`); } if (evaluation.winner) { lines.push(" This repository's subagent routing comes from the pin; the globally active profile and global routing writes do not apply here."); } return lines; } /** * Keep the clone-scoped pin pointing at the profile it names after a rename. The * repository declaration is a tracked file, so rewriting it would churn a commit * behind the operator's back; a declaration that still names the old profile is * reported instead, and `P` republishes it. Layers that name something else, or no * layer at all, are left alone. */ function followRenamedPin( cwd: string, from: string, to: string, ): { followed: string[]; stillDeclared: string[] } { const status = readProfilePinStatus(cwd); if (!status) return { followed: [], stillDeclared: [] }; const followed: string[] = []; const stillDeclared: string[] = []; if (status.local.status === "valid" && status.local.profile === from) { try { writeProfilePinSync(status.localPath, to); followed.push(status.localPath); } catch { // A pin that cannot be rewritten is reported by the pin line and degrades to // the global routing, exactly like any other stale pin; a rename that already // succeeded in the store is not undone by it. } } if (status.repo.status === "valid" && status.repo.profile === from) { stillDeclared.push(status.repoPath); } return { followed, stillDeclared }; } /** * One sentence naming the profile this repository resolves, for the commands whose * writes a pin outranks: global routing applied from the unified panel does not * reach a pinned repository's subagents. The launch resolver decides, so the note * names exactly what a launch would use. */ function profilePinScopeNote(cwd: string): string | undefined { const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() }); if (!resolution) return undefined; return sanitizeTerminalText( `This repository pins profile "${resolution.profile}" (${resolution.source} pin at ${resolution.path}), so its subagent launches resolve that profile instead of the global routing. Change or remove it in /gentle:profiles (profile menu ▾ › Use in this repo), or with /gentle:profiles publish for the committed declaration.`, ); } /** * Whether the committed declaration would actually be committed. This shells out to * Git, so it runs only on the key press that writes the declaration and makes at most * two bounded calls. Any Git failure is reported as unknown instead of thrown: a pin * write that already succeeded must not become an error because a probe about sharing * it failed. */ export function repoDeclarationSharing(root: string, path: string): string { const relativePath = relative(root, path).split(sep).join("/"); const run = (...arguments_: string[]): string => execFileSync("git", arguments_, { cwd: root, encoding: "utf8", windowsHide: true }).trim(); try { // Exit 0 means gitignore matches the path; the exact negation line is what makes // the declaration committable again. run("check-ignore", "--quiet", relativePath); return [ `This worktree ignores ${relativePath}; add these exact lines to the root .gitignore to share only this declaration:`, ...REPO_PROFILE_DECLARATION_GITIGNORE_RULES, ].join("\n"); } catch (error) { // Exit 1 means "not ignored"; anything else (including a missing git) is unknown. const exitStatus = (error as { status?: number }).status; if (exitStatus !== 1) return `Git could not say whether ${relativePath} is tracked; check it before committing.`; } try { run("ls-files", "--error-unmatch", relativePath); return `${relativePath} is tracked by Git; commit the change to share it.`; } catch { return `${relativePath} is not ignored, so it is committable as-is.`; } } function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: ProfilesParseDrops): void { // Dropped names are by definition the ones that failed validation, so they are // untrusted input reaching the terminal and must be sanitized like any other // externally supplied text. const parts: string[] = []; if (drops.droppedProfiles.length > 0) { parts.push(`profiles: ${drops.droppedProfiles.map((name) => sanitizeTerminalText(name)).join(", ")}`); } if (drops.droppedAgents.length > 0) { parts.push( `routing entries: ${drops.droppedAgents.map(({ profile, agent }) => `${sanitizeTerminalText(profile)}/${sanitizeTerminalText(agent)}`).join(", ")}`, ); } if (drops.droppedActive !== undefined) { parts.push(`active marker: ${sanitizeTerminalText(drops.droppedActive)}`); } if (parts.length > 0) { ctx.ui.notify( `el Gentleman dropped invalid entries while loading ${sanitizeTerminalText(path)} — ${parts.join("; ")}.`, "warning", ); } } /** Pi's own live-session controls: the ExtensionAPI's setModel/setThinkingLevel. */ type LiveSession = Pick; /** * Switch the running session to the profile's orchestrator. `settings.json` * is the default for new sessions only; Pi's `setModel`/`setThinkingLevel` * are what move the live one without persisting global defaults. This helper * also serves explicit global apply, but never writes settings itself. Failures * keep the current live model and report what happened; callers retain their * routing snapshot or persisted default independently. */ async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession, entry: AgentRoutingEntry): Promise { const reference = parseOrchestratorModelRef(entry.model); if (reference === undefined) return ""; const label = `${reference.provider}/${reference.model}`; const registry = ctx.modelRegistry; if (!registry) { if (ctx.hasUI && ctx.ui.notify) { ctx.ui.notify("Model registry unavailable; this session keeps its current model.", "warning"); } return `\nModel registry unavailable; this session keeps its current model.`; } const model = registry.find(reference.provider, reference.model); if (model === undefined) return `\n${label} is not in the model catalog; this session keeps its current model.`; let switched = false; try { switched = await live.setModel(model); } catch (error) { return `\nThis session could not switch to ${label}: ${sanitizeTerminalText(error instanceof Error ? error.message : String(error))}.`; } if (!switched) return `\nno authentication is configured for ${reference.provider}; this session keeps its current model.`; if (entry.thinking === undefined) return `\nThis session now runs on ${label}.`; try { live.setThinkingLevel(entry.thinking); } catch (error) { return `\nThis session now runs on ${label}, but its thinking level could not be set to ${entry.thinking}: ${sanitizeTerminalText(error instanceof Error ? error.message : String(error))}.`; } return `\nThis session now runs on ${label} · ${entry.thinking}.`; } /** * The live session's orchestrator entry, when a model is running. `s` captures * what the session actually runs rather than the default new sessions would * get from settings.json; an unreadable thinking level still snapshots the * model, just without a thinking claim. */ /** The live session's orchestrator: a snapshot result shaped for `s`, not a settings-file read. */ interface LiveOrchestratorSnapshot { readonly status: "valid"; readonly entry: AgentRoutingEntry; } function liveOrchestratorSnapshot(ctx: ExtensionContext, live: LiveSession): LiveOrchestratorSnapshot | undefined { if (ctx.model === undefined || typeof ctx.model.provider !== "string" || typeof ctx.model.id !== "string") return undefined; let thinking: unknown; try { thinking = live.getThinkingLevel(); } catch { thinking = undefined; } return { status: "valid", entry: { model: `${ctx.model.provider}/${ctx.model.id}`, thinking: isThinkingLevel(thinking) ? thinking : undefined } }; } function profileSnapshotFrom( current: AgentModelConfig, orchestrator: LiveOrchestratorSnapshot | OrchestratorSettingsReadResult, ): AgentModelConfig { const snapshot = cloneModelConfig(current); if (orchestrator.status === "valid" && orchestrator.entry !== undefined) { snapshot[PROFILE_ORCHESTRATOR_KEY] = { ...orchestrator.entry }; } return snapshot; } /** The store actions runProfilesPanelAction implements; the loop handles the rest itself. */ type ProfileStoreAction = Extract< RoutingTableResult, { type: "use-session" | "apply-global" | "create" | "duplicate" | "rename" | "delete" | "export-profile" | "import-profile" | "pin" } >; /** Runs one finished panel action against the store and the live session, returning the file the reopened panel should show. */ async function runProfilesPanelAction( ctx: ExtensionContext, live: LiveSession, path: string, file: AgentProfilesFile, result: ProfileStoreAction, report: ProfilesPanelReport = {}, ): Promise { switch (result.type) { case "use-session": { if (!hasOwnProfile(file.profiles, result.name)) return file; // Enter persists a complete routing snapshot for this parent session, // publishes only the returned state, then switches its live orchestrator. // No shared defaults, materialized stores, agent frontmatter, pins, or // declarations are written. const sessionId = ctx.sessionManager?.getSessionId?.(); if (typeof sessionId !== "string" || sessionId.length === 0) { report.status = `This Pi session has no id to bind "${result.name}" to. Use Make default instead.`; ctx.ui.notify( `el Gentleman cannot bind profile "${result.name}" to this session: no parent session id is available here. Use Make default instead.`, "warning", ); return file; } const selected = await runCurrentSessionProfileSelection(ctx.sessionManager, async (attachment, owned) => { const outcome = attachment.bind(result.name, normalizeModelConfig(result.config ?? file.profiles[result.name]) ?? {}); if (!owned()) return false; const authority = publishSessionProfileOutcome(ctx.sessionManager, outcome); if ((outcome.status !== "persisted" && outcome.status !== "not-persisted") || outcome.state.status !== "bound" || !authority.available) { const kept = authority.available ? "The last disk-corroborated state was preserved." : "Session profile authority is unavailable; affected launches are blocked."; report.status = `"${result.name}" was not bound to this session. ${kept}`; ctx.ui.notify( `el Gentleman could not bind profile "${result.name}". ${kept} No live model or thinking change was applied.`, "warning", ); return false; } const snapshot = outcome.state.binding; // The live switch applies only while this exact snapshot is still current. const current = () => { if (!owned()) return false; const state = readCurrentSessionProfileOutcome(ctx.sessionManager)?.state; return state?.status === "bound" && JSON.stringify(state.binding) === JSON.stringify(snapshot); }; const orchestrator = readProfileOrchestrator(snapshot.modelProfiles); const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, { setModel: (model) => current() ? live.setModel(model) : Promise.resolve(false), setThinkingLevel: (level) => { if (current()) live.setThinkingLevel(level); }, getThinkingLevel: () => live.getThinkingLevel(), }, orchestrator); if (current()) { const persistence = outcome.status === "persisted" ? "persisted in this session" : "not yet persisted; active only until detach or disk corroboration"; report.status = `This session now uses "${snapshot.name}" (${persistence}). Nothing shared was written.${liveNote.replace(/\n/g, " ")}`; ctx.ui.notify( `el Gentleman bound profile "${snapshot.name}" to this session — its tab shows ◉; ${persistence}. Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Make default writes them.${liveNote}`, "info", ); } return true; }); // A selection skipped by a reload or session replacement leaves a stale // ctx; only a still-active command reports the missing attachment. if (selected === undefined && isCommandContextActive(ctx)) { report.status = `"${result.name}" was not bound: this session's attachment is unavailable.`; ctx.ui.notify( `el Gentleman cannot bind profile "${result.name}": the current session attachment is unavailable. No profile or live model change was applied.`, "warning", ); } return file; } case "apply-global": { if (!hasOwnProfile(file.profiles, result.name)) return file; // A pinned repository resolves its subagent routing from the profile at launch, // so a global apply would move global state this repository never reads. When a // pin wins, applying is repo-scoped: re-pin this clone and write no global // routing, no materialized stores, and no orchestrator. The committed // declaration is never rewritten behind a commit. const pinResolution = resolveProfilePin({ cwd: ctx.cwd, configHome: gentleAiConfigHome() }); if (pinResolution) { const localPath = pinResolution.status.localPath; let pinNote: string; try { writeProfilePinSync(localPath, result.name); pinNote = `el Gentleman applied profile "${result.name}" repo-scoped: this clone now pins it in ${sanitizeTerminalText(localPath)}.\nSubagent launches here keep resolving the pin; no global routing or orchestrator was written.`; } catch (error) { ctx.ui.notify( `el Gentleman could not pin profile "${result.name}" in ${sanitizeTerminalText(localPath)}: ${profilesErrorMessage(error)}`, "warning", ); return file; } if (pinResolution.source === "repo" && pinResolution.path !== localPath) { pinNote += `\nThis worktree's committed declaration ${sanitizeTerminalText(pinResolution.path)} still declares "${pinResolution.profile}"; the clone-local pin now takes precedence.`; } ctx.ui.notify(pinNote, "info"); return file; } const normalized = cloneModelConfig(result.config); const orchestratorEntry = readProfileOrchestrator(normalized); const hasAgentRoutes = Object.keys(normalized).some((name) => !isProfileOrchestratorKey(name)); // Every global apply confirms before anything is written: applying replaces // the whole routing map in models.json and clears materialized routes the // profile omits, so the dialog must name the concrete diff. The current // routing is read from the same authority every other consumer uses; an // unreadable config is tolerated as empty, exactly like readModelConfigAsync. const savedRouting = await readModelRoutingAuthorityAsync( modelConfigPath(ctx.cwd), legacyProjectModelConfigPath(ctx.cwd), ); // The apply pads omitted discoverable agents with clear entries, so the // diff must run against the effective current routing: the saved global // routing plus the materialized routes (frontmatter, subagents.json) of // agents the saved routing is silent about. models.json alone would hide // materialized-only routes the approval actually clears. const currentRouting = savedRouting.status === "valid" ? await readGlobalEffectiveModelConfigFromAsync(ctx.cwd, savedRouting.config) : {}; const agentNames = [ ...new Set([ ...Object.keys(currentRouting).filter((name) => !isProfileOrchestratorKey(name)), ...Object.keys(normalized).filter((name) => !isProfileOrchestratorKey(name)), ]), ].sort(); const replacedRoutes: string[] = []; const clearedRoutes: string[] = []; const addedRoutes: string[] = []; for (const name of agentNames) { const from = currentRouting[name]; const to = normalized[name]; if (to === undefined) { clearedRoutes.push(`${name}: ${formatOrchestratorSelection(from)} → inherit (cleared)`); } else if (from === undefined) { addedRoutes.push(`${name}: ${formatOrchestratorSelection(to)} (added)`); } else if (from.model !== to.model || from.thinking !== to.thinking) { replacedRoutes.push(`${name}: ${formatOrchestratorSelection(from)} → ${formatOrchestratorSelection(to)}`); } } if (!hasAgentRoutes) { // A genuinely empty profile and an orchestrator-only profile both wipe // every materialized agent route, but they read very differently to the // user: the orchestrator entry survives the apply and reconfigures the // orchestrator, so the dialog must not promise an entirely empty config. const [confirmTitle, confirmMessage] = orchestratorEntry !== undefined ? [ "Apply orchestrator-only profile?", `Profile "${result.name}" has an orchestrator entry but no agent routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with the orchestrator entry alone, clear every materialized agent route so every agent returns to inherit its default model, set the configured orchestrator entry in settings.json, and attempt to switch this session to that orchestrator model. Continue?`, ] : [ "Apply empty profile?", `Profile "${result.name}" has no routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with an empty configuration and return every agent to inherit its default model. Continue?`, ]; const approved = await ctx.ui.confirm(confirmTitle, confirmMessage); if (!approved) return file; } else { // A populated profile keeps the same abort semantics as the empty and // orchestrator-only dialogs: declining leaves every surface untouched. // When the routing authority is unreadable the diff above was computed // against an empty map, so the dialog must disclose the unreadable // routing and the replace/clear-to-inherit effect instead of presenting // existing routes as merely "(added)" (the #1349 wipe-bug class). // When the profile carries an orchestrator entry, approval also writes // settings.json and tries to move the live session, so both variants // must disclose those effects in the same words the orchestrator-only // dialog uses. An unconditional "will switch" is never claimed: the // registry/auth can refuse the live move. const orchestratorEffects = orchestratorEntry !== undefined ? ", set the configured orchestrator entry in settings.json, and attempt to switch this session to that orchestrator model" : ""; // A profile whose agent routes already match the effective current routing // and that moves no orchestrator changes nothing: re-selecting the active // profile or verifying state would otherwise train users to approve a // dialog without reading it, weakening the guard on the destructive cases // (issue #1683). Any routing change, any orchestrator change, or an // unreadable routing authority (the diff above cannot prove a no-op) // keeps the confirmation exactly as #1349/#1384 defined it. // `applyOrchestratorSettings` treats an entry without a model as "leave // settings.json alone", so such an entry is a no-op too, not a change. // The live session is a fourth surface: applying re-asserts the profile's // orchestrator on it, so a session already moved to another model or // thinking level mid-session is a real change the user must approve, // even when settings.json and the profile agree. const liveOrchestrator = (() => { if (ctx.model === undefined || typeof ctx.model.provider !== "string" || typeof ctx.model.id !== "string") return undefined; let thinking: unknown; try { thinking = live.getThinkingLevel(); } catch { return undefined; } return { model: `${ctx.model.provider}/${ctx.model.id}`, thinking: isThinkingLevel(thinking) ? thinking : undefined }; })(); const orchestratorUnchanged = (orchestratorEntry === undefined || orchestratorEntry.model === undefined) || (() => { const current = readOrchestratorSettings(orchestratorSettingsPath()); // An invalid stored defaultThinkingLevel is dropped from the entry but // applyOrchestratorSettings would delete the key, so the file would // change: a no-op cannot be proven and the dialog must stay. if (current.status === "valid" && "defaultThinkingLevel" in current.value && current.entry?.thinking === undefined) return false; return current.status === "valid" && current.entry !== undefined && current.entry.model === orchestratorEntry.model && current.entry.thinking === orchestratorEntry.thinking && liveOrchestrator !== undefined && liveOrchestrator.model === orchestratorEntry.model && liveOrchestrator.thinking === orchestratorEntry.thinking; })(); if (savedRouting.status === "valid" && replacedRoutes.length === 0 && clearedRoutes.length === 0 && addedRoutes.length === 0 && orchestratorUnchanged) { ctx.ui.notify( `Profile "${result.name}" already matches the current global routing${orchestratorEntry !== undefined ? " and orchestrator" : ""}; applying it changed nothing.`, "info", ); } else { let confirmMessage: string; if (savedRouting.status !== "valid") { const modelsPath = sanitizeTerminalText(modelConfigPath(ctx.cwd)); confirmMessage = `Profile "${result.name}" has agent routing entries, but the current global routing in ${modelsPath} could not be read, so existing routes are not listed. Applying it will replace global routing in ${modelsPath} with this profile's routes, so every existing agent route may be replaced or cleared back to inherit${orchestratorEffects}. Continue?`; } else { const changes = [...replacedRoutes, ...clearedRoutes, ...addedRoutes]; const changeSummary = changes.length > 0 ? changes.join("; ") : "its agent routes already match the current global routing"; confirmMessage = `Profile "${result.name}" has agent routing entries. Applying it will replace global routing in ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} with this profile's routes: ${changeSummary}${orchestratorEffects}. Continue?`; } const approved = await ctx.ui.confirm(`Apply profile "${result.name}"?`, confirmMessage); if (!approved) return file; } } // Applying spans three files — the store, models.json, and Pi's global // settings.json — and there is no cross-file rename, so order the writes to // keep the store truthful and compensate on failure: claim the profile in // the store first, then materialise routing, then the orchestrator. A claim // that fails leaves routing untouched; anything that fails after the claim // restores the previous claim and, when the previously active profile is // known, the routing that profile implies. const claimed = setActiveProfile(file, result.name); try { writeProfilesFileSync(path, claimed); } catch (error) { ctx.ui.notify( `el Gentleman could not update ${sanitizeTerminalText(path)}: ${profilesErrorMessage(error)}`, "warning", ); return file; } const previousActiveConfig = file.active !== undefined && hasOwnProfile(file.profiles, file.active) ? normalizeModelConfig(file.profiles[file.active]) ?? {} : undefined; // Set only when the orchestrator write succeeded, so the revert knows it has // something to undo. A rollback closure (not a previous-bytes value) is used // because "the file did not exist before" is a real state that must restore // by removing the file, and `undefined` bytes cannot carry that distinction. let orchestratorRollback: (() => void) | undefined; const revertClaim = async (routingWritten: boolean): Promise => { let restored = previousActiveConfig === undefined ? "" : "routing"; if (routingWritten && previousActiveConfig !== undefined) { try { await writeModelConfigAsync(ctx.cwd, previousActiveConfig); // Materialize the previous profile again with the same // replacement semantics, so the failed profile's routes do not // linger in subagents.json or the agent frontmatter. await applyModelConfigAsync( ctx.cwd, await withOmittedAgentsClearedAsync(ctx.cwd, previousActiveConfig), ); } catch { restored = ""; } } if (orchestratorRollback) { try { orchestratorRollback(); restored = restored === "" ? "settings" : `${restored} and settings`; } catch { restored = restored === "" ? "" : restored; } } try { writeProfilesFileSync(path, file); restored = restored === "" ? "active marker" : `${restored} and active marker`; } catch { restored = restored === "" ? "nothing" : restored; } const unresolved = routingWritten && previousActiveConfig === undefined ? ` ${sanitizeTerminalText(modelConfigPath(ctx.cwd))} still holds this profile's routing because no previously active profile was recorded to restore.` : ""; ctx.ui.notify( `el Gentleman could not apply profile "${result.name}". Restored: ${restored}.${unresolved}`, "warning", ); return file; }; try { await writeModelConfigAsync(ctx.cwd, normalized); } catch (error) { ctx.ui.notify( `el Gentleman could not write ${sanitizeTerminalText(modelConfigPath(ctx.cwd))}: ${profilesErrorMessage(error)}`, "warning", ); return revertClaim(false); } // models.json holds the profile as written; the padding with clear // entries only drives materialization, so agents the profile omits // return to inherit instead of keeping a previously materialized route. let applyResult: { updated: number; skipped: number }; try { applyResult = await applyModelConfigAsync( ctx.cwd, await withOmittedAgentsClearedAsync(ctx.cwd, normalized), ); } catch (error) { ctx.ui.notify( `el Gentleman could not materialize profile "${result.name}": ${profilesErrorMessage(error)}`, "warning", ); return revertClaim(true); } let orchestratorNote = ""; if (orchestratorEntry !== undefined) { const settingsPath = orchestratorSettingsPath(); const written = applyOrchestratorSettings(settingsPath, orchestratorEntry); if (written.status === "invalid") { ctx.ui.notify( `el Gentleman could not set the orchestrator from profile "${result.name}": ${sanitizeTerminalText(written.reason)}. ${sanitizeTerminalText(settingsPath)} was left unchanged.`, "warning", ); return revertClaim(true); } if (written.status === "written") { const previous = written.previous; orchestratorRollback = () => restoreOrchestratorSettings(settingsPath, previous); orchestratorNote = `\nOrchestrator set to ${formatOrchestratorSelection(orchestratorEntry)} in ${sanitizeTerminalText(settingsPath)}.`; } // settings.json only governs future sessions. The session the user is // sitting in keeps its model until told otherwise, which made a profile // look applied while the orchestrator kept answering with the old one. orchestratorNote += await switchLiveOrchestrator(ctx, live, orchestratorEntry); } // A pin that does not resolve changes nothing at launch, so the global apply // above is what governs this repository. Saying so keeps a broken pin from // looking like the reason a launch ignored the profile just applied. const pinEvaluation = evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles); let pinNote = ""; if (pinEvaluation.stale.length > 0 || pinEvaluation.invalid.length > 0) { pinNote = "\nThis repository also has a pin layer that does not resolve; the global routing above governs its subagents until the pin is fixed."; } ctx.ui.notify( [ `el Gentleman applied profile "${result.name}" — ${applyResult.updated} agent${applyResult.updated === 1 ? "" : "s"} updated.`, "New routing takes effect on the next subagent launch.", ].join("\n") + orchestratorNote + pinNote, "info", ); return claimed; } case "create": { const answer = await ctx.ui.input("New profile name", "e.g. deep-work"); if (answer === undefined) { report.status = "Create cancelled."; return file; } const name = answer.trim(); if (name === "") { report.status = "No profile created: no name entered."; return file; } try { const next = createProfile(file, name, {}); writeProfilesFileSync(path, next); report.status = `Profile "${name}" created.`; report.selectedName = name; return next; } catch (error) { report.status = `Profile not created: ${profilesErrorMessage(error)}`; ctx.ui.notify(report.status, "warning"); return file; } } case "duplicate": { // The Pi host starts the field empty and ignores the placeholder, so the title // names the suggestion and Enter on an untouched field accepts it. const suggested = `${result.name}-copy`; const answer = await ctx.ui.input(`Duplicate profile "${result.name}" as (empty = ${suggested})`, suggested); if (answer === undefined) { report.status = "Duplicate cancelled."; return file; } const name = answer.trim() || suggested; try { const next = duplicateProfile(file, result.name, name); writeProfilesFileSync(path, next); report.status = `Profile "${result.name}" duplicated as "${name}".`; report.selectedName = name; return next; } catch (error) { report.status = `Profile not duplicated: ${profilesErrorMessage(error)}`; ctx.ui.notify(report.status, "warning"); return file; } } case "rename": { const answer = await ctx.ui.input(`Rename profile "${result.name}" to (empty = keep ${result.name})`, result.name); if (answer === undefined) { report.status = "Rename cancelled."; return file; } const name = answer.trim(); if (name === "") { report.status = `Profile "${result.name}" unchanged: no new name entered.`; return file; } try { const next = renameProfile(file, result.name, name); writeProfilesFileSync(path, next); // A pin stores a name, so a rename that did not follow it would leave every // pinned repository with a name the store no longer defines, which silently // returns those repositories to the global routing. const follow = followRenamedPin(ctx.cwd, result.name, name); ctx.ui.notify( `el Gentleman renamed profile "${result.name}" to "${name}".` + (follow.followed.length > 0 ? `\nUpdated the clone pin: ${follow.followed.map((entry) => sanitizeTerminalText(entry)).join(", ")}.` : "") + (follow.stillDeclared.length > 0 ? `\nThe committed repository declaration ${follow.stillDeclared.map((entry) => sanitizeTerminalText(entry)).join(", ")} still names "${result.name}"; run /gentle:profiles publish ${name} to republish it.` : ""), "info", ); report.status = `Profile "${result.name}" renamed to "${name}".`; report.selectedName = name; return next; } catch (error) { report.status = `Profile not renamed: ${profilesErrorMessage(error)}`; ctx.ui.notify(report.status, "warning"); return file; } } case "delete": { // A pinned profile is live state for this repository, so deleting it is // refused the same way deleting the active profile is: the pin must be cleared // deliberately instead of leaving a name that resolves to nothing. Either // layer refuses, not just the winning one, because a non-winning layer still // names a live profile for this repository. const deletePinStatus = readProfilePinStatus(ctx.cwd); const namedLayers: string[] = []; if (deletePinStatus) { for (const layer of [ { scope: "local", read: deletePinStatus.local, path: deletePinStatus.localPath }, { scope: "repo", read: deletePinStatus.repo, path: deletePinStatus.repoPath }, ]) { if (layer.read.status === "valid" && layer.read.profile === result.name) { namedLayers.push(`${layer.scope} pin at ${sanitizeTerminalText(layer.path)}`); } } } if (namedLayers.length > 0) { ctx.ui.notify( `Profile "${result.name}" is pinned for this repository (${namedLayers.join(" and ")}). Remove the pin first (profile menu ▾ › Stop using in this repo for the clone pin, /gentle:profiles publish for the declaration), then delete it.`, "warning", ); return file; } const approved = await ctx.ui.confirm( "Delete profile?", `Delete profile "${result.name}" from ${path}? The routing in ${modelConfigPath(ctx.cwd)} is not changed.`, ); if (!approved) return file; try { const next = deleteProfile(file, result.name); writeProfilesFileSync(path, next); return next; } catch (error) { ctx.ui.notify(`Profile not deleted: ${profilesErrorMessage(error)}`, "warning"); return file; } } case "pin": { if (!hasOwnProfile(file.profiles, result.name)) return file; // The pin layer is chosen by the key, not by the file that happens to exist: // `p` sets the local pin in the clone's Git common directory, `P` sets the // committable per-worktree declaration. Both toggle: the same key on the // profile a layer already names removes that layer, so a repository can be // released without editing files by hand. Neither writes routing, so a // failure here has nothing to roll back. const status = readProfilePinStatus(ctx.cwd); if (!status) { report.status = "This session is not inside a Git worktree, so nothing was pinned."; ctx.ui.notify( "el Gentleman cannot pin a profile because this session is not inside a Git worktree. Pinning is per repository; apply the profile globally instead.", "warning", ); return file; } const pinPath = result.source === "local" ? status.localPath : status.repoPath; const current = result.source === "local" ? status.local : status.repo; const currentlyPinned = current.status === "valid" && current.profile === result.name; const scope = result.source === "local" ? "clone" : "worktree"; try { if (currentlyPinned) clearProfilePinSync(pinPath); else writeProfilePinSync(pinPath, result.name); } catch (error) { report.status = `The pin could not be updated: ${profilesErrorMessage(error)}`; ctx.ui.notify( `el Gentleman could not update profile pin ${sanitizeTerminalText(pinPath)}: ${profilesErrorMessage(error)}`, "warning", ); return file; } if (currentlyPinned) { ctx.ui.notify( [ `el Gentleman removed the ${result.source} pin for this ${scope} from ${sanitizeTerminalText(pinPath)}.`, result.source === "local" ? "This clone falls back to the repository declaration, then to the global routing." : "This worktree falls back to the global routing; a local pin in the clone still outranks it.", ].join("\n"), "info", ); return file; } // The sharing line is only worth a Git call when the declaration was just // written, and it must never turn a successful pin into a failure. const sharingNote = result.source === "repo" ? `\n${repoDeclarationSharing(status.root, pinPath)}` : ""; ctx.ui.notify( result.source === "local" ? [ `el Gentleman pinned profile "${result.name}" for this clone in ${sanitizeTerminalText(pinPath)}.`, "Subagent launches in this repository resolve that profile at launch; the orchestrator and every other repository keep their global routing. Choose Stop using in this repo in the profile menu (▾) to unpin.", ].join("\n") : [ `el Gentleman declared profile "${result.name}" for this worktree in ${sanitizeTerminalText(pinPath)}.`, `Subagent launches resolve it at launch. Commit the file to share the routing; a local pin takes precedence over it. Run /gentle:profiles publish ${result.name} again to remove the declaration.`, ].join("\n") + sharingNote, "info", ); return file; } case "export-profile": { if (!hasOwnProfile(file.profiles, result.name)) return file; const exportPath = profileExportPath(gentleAiConfigHome()); try { const text = serializeProfileExport(result.name, file.profiles[result.name]); await mkdir(dirname(exportPath), { recursive: true }); await writeFile(exportPath, text); ctx.ui.notify(`el Gentleman exported profile "${result.name}" to ${exportPath}.`, "info"); } catch (error) { ctx.ui.notify(`Profile export failed: ${profilesErrorMessage(error)}`, "warning"); } return file; } case "import-profile": { const importPath = profileExportPath(gentleAiConfigHome()); let text: string; try { text = await readFile(importPath, "utf8"); } catch { ctx.ui.notify(`Profile import failed: ${importPath} is missing or unreadable.`, "warning"); return file; } const parsed = parseProfileExportTextWithDrops(text); if (!parsed) { ctx.ui.notify( `Profile import failed: ${importPath} is not a valid agent-model profile export.`, "warning", ); return file; } if (parsed.droppedAgents.length > 0) { ctx.ui.notify( `el Gentleman dropped invalid routing entries while importing profile "${parsed.name}": ${parsed.droppedAgents.join(", ")}.`, "warning", ); } if (hasOwnProfile(file.profiles, parsed.name)) { const approved = await ctx.ui.confirm( "Replace existing profile?", `Profile "${parsed.name}" already exists in ${path}. Replace its routing with the import?`, ); if (!approved) return file; } try { const next = hasOwnProfile(file.profiles, parsed.name) ? updateProfile(file, parsed.name, parsed.config) : createProfile(file, parsed.name, parsed.config); writeProfilesFileSync(path, next); const entries = Object.keys(parsed.config).length; ctx.ui.notify( `el Gentleman imported profile "${parsed.name}" (${entries} routing ${entries === 1 ? "entry" : "entries"}) from ${importPath}.`, "info", ); return next; } catch (error) { ctx.ui.notify(`Profile import failed: ${profilesErrorMessage(error)}`, "warning"); return file; } } } } /** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */ const PROFILES_COMMAND_USAGE = "Usage: /gentle:profiles [export | import | publish | export-routing | restore-routing | clear]"; /** * `/gentle:profiles` opens the models screen. File operations live in * subcommands rather than on the screen: `clear` ends this session's profile * selection, `export ` and `import` move a * profile through `profiles.export.json`, `publish ` toggles the * committed repository declaration, and `export-routing` / `restore-routing` * move the saved global routing (models.json, not a profile) through * `models.export.json`. The routing verbs carry their own names so a profile * can never be mistaken for them. */ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession, args = ""): Promise { const [verb = "", ...rest] = args.trim().split(/\s+/).filter(Boolean); if (verb === "") { await openModelsScreen(ctx, live); return; } if (verb === "export-routing" || verb === "restore-routing" || verb === "clear") { if (rest.length > 0) { ctx.ui.notify(PROFILES_COMMAND_USAGE, "warning"); return; } if (verb === "export-routing") await exportRoutingCommand(ctx); else if (verb === "restore-routing") await restoreModelExport(ctx); else await handleProfilesClear(ctx); return; } const store = await loadProfilesStore(ctx); if (!store) return; const name = rest.join(" "); switch (verb) { case "export": case "publish": { if (name === "" || !hasOwnProfile(store.file.profiles, name)) { ctx.ui.notify( name === "" ? PROFILES_COMMAND_USAGE : `No profile named "${sanitizeTerminalText(name)}". ${PROFILES_COMMAND_USAGE}`, "warning", ); return; } await runProfilesPanelAction( ctx, live, store.path, store.file, verb === "export" ? { type: "export-profile", name } : { type: "pin", source: "repo", name }, ); return; } case "import": await runProfilesPanelAction(ctx, live, store.path, store.file, { type: "import-profile" }); return; default: ctx.ui.notify(PROFILES_COMMAND_USAGE, "warning"); } } /** Reads the profiles store, seeding it on first use; undefined after a reported failure. */ async function loadProfilesStore(ctx: ExtensionContext): Promise<{ path: string; file: AgentProfilesFile } | undefined> { const path = profilesFilePath(gentleAiConfigHome()); const read = readProfilesFileResult(path); if (read.status === "invalid") { ctx.ui.notify( `el Gentleman cannot open agent profiles because ${path} is invalid JSON or not a profiles file. Fix or remove the file, then run /gentle:profiles again.`, "warning", ); return undefined; } if (read.status === "valid") { reportProfilesDrops(ctx, path, read.drops); return { path, file: read.file }; } const file = bootstrapProfilesFile(await readEffectiveModelConfigAsync(ctx.cwd)); try { writeProfilesFileSync(path, file); } catch (error) { ctx.ui.notify(`el Gentleman could not create ${path}: ${profilesErrorMessage(error)}`, "warning"); return undefined; } ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the routing currently in effect.`, "info"); return { path, file }; } /** * The models screen loop: shows one profile's table, runs what the user chose, * and reopens with fresh store, binding, and pin state; a tab switch stays on * the open screen instead. Bare `/gentle:profiles` passes the profile this * repository resolves so it is the tab on screen. Every * outcome is reported on the screen's feedback line, because notifications stay * hidden behind the fullscreen overlay until it closes. */ async function openProfilesPanelLoop( ctx: ExtensionContext, live: LiveSession, initialSelectedName?: string, openingAuthority?: boolean, ): Promise { const store = await loadProfilesStore(ctx); if (!store) return; const { path } = store; let file = store.file; const pinWinner = () => evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles).winner?.profile; let selectedName = initialSelectedName ?? resolveCurrentProfileTarget(ctx.cwd, { status: "valid", file, drops: { droppedProfiles: [], droppedAgents: [], droppedActive: undefined } })?.name ?? Object.keys(file.profiles)[0]; let feedback: string | undefined; let draftOverride: AgentModelConfig | undefined; // Read once per visit: the opening read serves the first one. let authorityAvailable = true; const writeDraft = (name: string, config: AgentModelConfig): boolean => { try { const next = hasOwnProfile(file.profiles, name) ? updateProfile(file, name, cloneModelConfig(config)) : createProfile(file, name, cloneModelConfig(config)); writeProfilesFileSync(path, next); file = next; return true; } catch (error) { feedback = `"${name}" was not saved: ${profilesErrorMessage(error)}`; return false; } }; const switchProfile: RoutingProfileSwitcher = (name, saveFirst) => { const previous = selectedName!; if (saveFirst && !writeDraft(previous, saveFirst)) { const error = feedback ?? `"${previous}" was not saved.`; feedback = undefined; return { error }; } selectedName = name; // The profile this session launches with, bound or frozen: the tab marked ◉. const launch = sessionLaunchProfile(ctx, authorityAvailable); return { file, sessionBoundName: launch?.name, pinWinner: pinWinner(), notices: routingScreenNotices(ctx, file, name, launch?.name, launch?.modelProfiles, authorityAvailable), feedback: `${saveFirst ? `Saved "${previous}". ` : ""}Showing "${name}".`, }; }; const differsFromStore = (name: string, config: AgentModelConfig) => JSON.stringify(normalizeModelConfig(config) ?? {}) !== JSON.stringify(normalizeModelConfig(file.profiles[name]) ?? {}); // Pi's runner invalidates this command ctx in a microtask when a reload or // session replacement lands, possibly while a panel or input step is still in // flight. Every step therefore re-checks the ctx when it settles (or throws), // and a stale ctx closes the loop quietly instead of touching ctx.cwd again. const stale = Symbol("stale-profiles-ctx"); const step = async (run: () => Promise): Promise => { let value: T; try { value = await run(); } catch (error) { if (!isCommandContextActive(ctx)) throw stale; throw error; } if (!isCommandContextActive(ctx)) throw stale; return value; }; try { await runProfilesPanelSteps(); } catch (error) { if (error !== stale) throw error; } async function runProfilesPanelSteps(): Promise { while (true) { if (!isCommandContextActive(ctx)) return; if (selectedName === undefined || !hasOwnProfile(file.profiles, selectedName)) { selectedName = Object.keys(file.profiles)[0]; draftOverride = undefined; } if (selectedName === undefined) { ctx.ui.notify("el Gentleman has no profiles left. Run /gentle:profiles again to seed one from the routing in effect.", "info"); return; } const shown = selectedName; const stored = cloneModelConfig(normalizeModelConfig(file.profiles[shown]) ?? {}); authorityAvailable = openingAuthority ?? sessionProfileAuthorityAvailable(ctx); openingAuthority = undefined; const launch = sessionLaunchProfile(ctx, authorityAvailable); const result = await step(() => showRoutingTablePanel(ctx, { name: shown, config: draftOverride ?? stored, file, sessionBoundName: launch?.name, pinWinner: pinWinner(), feedback, notices: routingScreenNotices(ctx, file, shown, launch?.name, launch?.modelProfiles, authorityAvailable), switchProfile, })); draftOverride = undefined; feedback = undefined; const previous = selectedName; if (result.saveFirst && !writeDraft(previous, result.saveFirst)) { draftOverride = result.saveFirst; continue; } const savedNote = result.saveFirst ? `Saved "${previous}". ` : ""; switch (result.type) { case "close": return; case "load": selectedName = result.name; feedback = `${savedNote}Showing "${result.name}".`; continue; case "custom": { const label = result.target === PROFILE_ORCHESTRATOR_KEY ? "the orchestrator" : result.target === ROUTING_ALL_ROW ? "every agent" : sanitizeTerminalText(result.target); const answer = await step(() => ctx.ui.input(`Custom model id for ${label}`, "provider/model")); draftOverride = cloneModelConfig(result.config); if (answer === undefined || answer.trim() === "") { feedback = "No custom model set."; continue; } const model = normalizeModelId(answer.trim()); if (!model) { feedback = "Custom model id must be one provider/model line using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only."; continue; } const keys = result.target === ROUTING_ALL_ROW ? modelAssignmentNames(ctx.cwd) : [result.target]; for (const key of keys) draftOverride[key] = { ...(draftOverride[key] ?? {}), model }; feedback = `${label}: model ${model}. Save keeps it in "${previous}".`; continue; } case "save": if (writeDraft(previous, result.config)) feedback = `Saved "${previous}".`; else draftOverride = result.config; continue; case "use-session": { // What you see is what the session uses: edits are saved first. // A tab switch may have changed the shown profile since this visit opened. const config = result.config ?? cloneModelConfig(normalizeModelConfig(file.profiles[previous]) ?? {}); const saved = differsFromStore(previous, config); if (saved && !writeDraft(previous, config)) { draftOverride = config; continue; } const report: ProfilesPanelReport = {}; file = await step(() => runProfilesPanelAction(ctx, live, path, file, { type: "use-session", name: previous, config }, report)); feedback = `${saved ? `Saved "${previous}". ` : ""}${report.status ?? ""}`.trim() || undefined; continue; } case "apply-global": { // Make default saves what you see, then runs the guarded global // apply with its diff confirmation, pin honesty, and rollback. const saved = differsFromStore(previous, result.config); if (saved && !writeDraft(previous, result.config)) { draftOverride = result.config; continue; } file = await step(() => runProfilesPanelAction(ctx, live, path, file, { type: "apply-global", name: previous, config: cloneModelConfig(result.config) })); const prefix = saved ? `Saved "${previous}". ` : ""; feedback = file.active === previous ? `${prefix}"${previous}" is the default for new sessions.` : pinWinner() === previous ? `${prefix}This repo pins its profile, so "${previous}" now applies to this repo only.` : `${prefix}"${previous}" was not made the default.`; continue; } case "create-live": { if (!authorityAvailable) { feedback = `${savedNote}Session profile authority is unavailable; affected launches are blocked. No profile created.`; continue; } const answer = await step(() => ctx.ui.input("Name for a profile of what's running now", "e.g. snapshot")); const name = answer?.trim() ?? ""; if (name === "") { feedback = `${savedNote}No profile created.`; continue; } // Captures the routing this session's launches resolve and the // orchestrator it actually runs, the way the old snapshot did. const snapshot = profileSnapshotFrom( await step(() => readSessionLaunchModelConfigAsync(ctx, authorityAvailable)), liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()), ); try { const next = createProfile(file, name, snapshot); writeProfilesFileSync(path, next); file = next; selectedName = name; feedback = `${savedNote}Created "${name}" from what's running now.`; } catch (error) { feedback = `${savedNote}Profile not created: ${profilesErrorMessage(error)}`; } continue; } default: { const report: ProfilesPanelReport = {}; file = await step(() => runProfilesPanelAction(ctx, live, path, file, result, report)); selectedName = report.selectedName ?? selectedName; if (result.type === "delete" && !hasOwnProfile(file.profiles, previous)) { feedback = `Deleted "${previous}".`; } else if (result.type === "pin") { feedback = report.status ?? (pinWinner() === previous ? `"${previous}" is used in this repo.` : `"${previous}" is no longer pinned to this repo.`); } else { feedback = report.status ? `${savedNote}${report.status}` : savedNote || undefined; } // Rename, pin, and a declined delete keep the profile on screen; carry // unsaved edits across them instead of reloading from the store. if (result.draft && hasOwnProfile(file.profiles, selectedName) && (selectedName === previous || result.type === "rename")) { draftOverride = result.draft; } } } } } } /** * `/gentle:profiles clear`: append an explicit clear record for this session and * publish only the returned state. Never-bound and already-cleared sessions write * nothing; an unreadable record is the recovery case and is cleared. The live * orchestrator and every shared default stay untouched. */ async function handleProfilesClear(ctx: ExtensionContext): Promise { const sessionId = ctx.sessionManager?.getSessionId?.(); if (typeof sessionId !== "string" || sessionId.length === 0) { ctx.ui.notify("el Gentleman cannot clear the session profile: no parent session id is available here.", "warning"); return; } const selected = await runCurrentSessionProfileSelection(ctx.sessionManager, async (attachment, owned) => { const prior = attachment.refresh().state; // An unsaved or in-memory branch reads as indeterminate; without any profile // record on the branch it was never selected, so nothing is written. const selectedBefore = ctx.sessionManager.getBranch().some((entry) => isSessionProfileFamilyEntry(entry)); if (prior.status === "absent" || (prior.status === "indeterminate" && !selectedBefore)) { ctx.ui.notify("el Gentleman: no session profile was ever selected in this session; nothing to clear.", "info"); return true; } if (prior.status === "cleared") { ctx.ui.notify("el Gentleman: this session profile is already cleared; nothing to clear.", "info"); return true; } const outcome = attachment.clear(); if (!owned()) return false; const authority = publishSessionProfileOutcome(ctx.sessionManager, outcome); if ((outcome.status !== "persisted" && outcome.status !== "not-persisted") || outcome.state.status !== "cleared" || !authority.available) { ctx.ui.notify( `el Gentleman could not clear the session profile. ${authority.available ? "The last disk-corroborated state was preserved." : "Session profile authority is unavailable; affected launches are blocked."}`, "warning", ); return false; } const what = prior.status === "bound" ? `session profile "${prior.binding.name}"` : "the unreadable session profile record"; ctx.ui.notify( `el Gentleman cleared ${what} — ${outcome.status === "persisted" ? "persisted in this session" : "not yet persisted; active only until detach or disk corroboration"}. New launches use the routing without a session profile (pins, repository declaration, global). The live orchestrator is unchanged.`, "info", ); return true; }); // A selection skipped by a reload or session replacement leaves a stale ctx; // only a still-active command reports the missing attachment. if (selected === undefined && isCommandContextActive(ctx)) ctx.ui.notify("el Gentleman cannot clear the session profile: the current session attachment is unavailable. Nothing was changed.", "warning"); } /** * Warn when the current session profile record cannot be read: an invalid record * or one from a newer version blocks affected launches instead of falling back. */ function notifySessionProfileProblem(ctx: ExtensionContext): void { const state = readCurrentSessionProfileOutcome(ctx.sessionManager)?.state; if (state?.status !== "invalid" && state?.status !== "unsupported") return; const file = ctx.sessionManager.getSessionFile?.() ?? "this in-memory session"; const where = `${sanitizeTerminalText(file)} entry ${state.entryIndex ?? "?"}${state.lineNumber === undefined ? "" : ` (line ${state.lineNumber})`}`; const kind = state.status === "invalid" ? "invalid" : "from a newer version"; ctx.ui.notify( `el Gentleman: the session profile record at ${where} is ${kind}. Subagent launches are blocked until it is replaced. Select a profile with Enter or run /gentle:profiles clear.`, "warning", ); } /** Pi's runner throws on any access to a ctx made stale by reload or session replacement. */ function isCommandContextActive(ctx: ExtensionContext): boolean { try { void ctx.sessionManager; return true; } catch { return false; } } async function handlePersonaCommand(ctx: ExtensionContext): Promise { const current = readPersonaMode(ctx.cwd); const selected = await ctx.ui.select( `el Gentleman persona (current: ${current})`, [...PERSONA_OPTIONS], ); if (selected !== "gentleman" && selected !== "neutral") return; const writtenPaths = writePersonaMode(ctx.cwd, selected); ctx.ui.notify( [ `el Gentleman persona set to: ${selected}`, `Global config: ${personaConfigPath(ctx.cwd)}`, ...(writtenPaths.length > 1 ? [`Project override updated: ${projectPersonaConfigPath(ctx.cwd)}`] : []), "Run /reload or start a new Pi session for already-injected prompts to refresh.", ].join("\n"), "info", ); } // --------------------------------------------------------------------------- // Review gate helpers — pure, exported via __testing for unit tests // --------------------------------------------------------------------------- const REVIEW_CONTROLLER_OPERATION = { START: "start", ANSWER_CONSENT: "answer-consent", ADVANCE: "advance", ACKNOWLEDGE_APPROVED: "acknowledge-approved", STATUS: "status", SELECT_INTENDED_UNTRACKED: "select-intended-untracked", EXPORT: "export", IMPORT: "import", INSPECT: "inspect", RESET: "reset", RECOVER: "recover", RECOVER_LOCK: "recover-lock", ABANDON: "abandon", QUARANTINE_LEGACY: "quarantine-legacy", RECONCILE_AUTHORITY: "reconcile-authority", REPAIR_LEGACY_ALIAS: "repair-legacy-alias", REPAIR: "repair", // gentle-pi#662: read-only native risk assessment (gentle-ai#4295). Never // mutates review authority state and never requires a lineageId. ASSESS: "assess", } as const; type ReviewControllerOperation = (typeof REVIEW_CONTROLLER_OPERATION)[keyof typeof REVIEW_CONTROLLER_OPERATION]; function reviewToolOperationPath(args: unknown): string { const operation = isRecord(args) ? args.operation : undefined; if ( typeof operation !== "string" || !Object.values(REVIEW_CONTROLLER_OPERATION).includes(operation as ReviewControllerOperation) ) { return "review"; } return `review ${operation.replaceAll("-", " ")}`; } // A JSON-object string pattern prevents Pi primitive coercion from admitting // numbers/booleans as strings. Facade parsers still own JSON and authority checks. const REVIEW_JSON_STRING = { type: "string", pattern: "^\\s*\\{" } as const; const REVIEW_JSON_ARGUMENT = { anyOf: [REVIEW_JSON_STRING, { type: "object" }] } as const; const REVIEW_CONTROLLER_PARAMETER_FIELDS = { type: "object", additionalProperties: false, required: ["operation"], properties: { operation: { type: "string", enum: Object.values(REVIEW_CONTROLLER_OPERATION), description: "Controller operation. Inspect authority before start. Reset requires the exact challenge returned by inspect.", }, lineageId: { type: "string", description: "Bounded review lineage identifier. A failed start creates no lineage; do not use it with status or advance.", }, selectionBinding: { type: "string", description: "Opaque provider-issued pre-lineage intended-untracked selection binding." }, intendedUntracked: { type: "array", items: { type: "string" }, description: 'Repository-relative paths selected from the provider binding; on inspect they are accepted only with untrackedScope "select".' }, untrackedScope: { type: "string", enum: ["exclude", "select"], description: 'Inspect-only: resolve the intended-untracked selection stop in one call. "exclude" excludes every eligible untracked path and forbids intendedUntracked; "select" includes exactly the supplied intendedUntracked paths.' }, changeName: { type: "string", description: "Canonical OpenSpec change name required to resolve a recovered authority during lifecycle validate.", }, idempotencyKey: { type: "string", description: "Required for graph-v1 start and advance operations.", }, transition: { type: "string", description: "A supported REVIEW_TRANSITION value for advance.", }, input: { type: "string", description: "A JSON object or serialized object string for START/ASSESS/answer-consent; every other operation requires a serialized object string. New native ordinary START uses {\"mode\":\"ordinary\"}, plus the agent lens selection {\"lenses\":[\"risk\",...],\"lensesReason\":\"\"} (both or neither, 1-4 of risk, resilience, readability, reliability; never with focus; providers that predate it keep the tier default); answer-consent uses exactly {\"consentBinding\":\"\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. baseRef must be HEAD, a full 40- or 64-character commit id, or a ref name; abbreviated commit ids are rejected as base-ref-unresolvable. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668/#1175). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); writerModelId and writerEffort are only a fallback when no runtime evidence exists (caller), and with neither the profile fails closed to small (fallback). The writer profile is reported but never adds a verifier: the plan adds the independent verifier only when escalate names a Task Size high-risk item. ASSESS derives `closed` only from the native candidate.consumed fact for this exact candidate, which native records only when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown. Every outcome yields the exact risk-gated plan ASSESS returns when RDD is off: a closed review never removes the separate verifier the tier requires (verify-always-rdd-high S1), unknown is never treated as closed, and a decline is candidate-scoped and never lowers the bar below the RDD-off path. A declined or unavailable outcome, stated explicitly or recorded by this process for this exact candidate (never a different one, and never from repository state alone), wins over closure. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.", }, outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." }, inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." }, operationId: { type: "string", description: "Retired with legacy bundle transport; ignored. Export/import return legacy-operation-retired." }, lineageIds: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." }, workspaceRoot: { type: "string", description: "Optional explicit user-authorized absolute path to an existing directory that owns this review. Nested Git paths are canonicalized to their worktree root. Only inspect or new ordinary START with validated RDD on may ask native Gentle AI to prepare a non-Git directory; Pi never invents this selector. Absent, the session cwd is used unless one unambiguous lineage binding already identifies its target root.", }, }, } as const; // Root-level union combinators (anyOf/oneOf/allOf) are forbidden by the Anthropic // tool definition spec and cause Claude Code/Agent SDK to drop the tool (#1698). // The root schema is a plain object; the nullable root shell prevents Pi from // deleting an optional supplied null, while runtime branches in the facade // enforce that null and non-START/ASSESS objects are rejected fail-closed. const REVIEW_CONTROLLER_PARAMETERS = { ...REVIEW_CONTROLLER_PARAMETER_FIELDS, properties: { ...REVIEW_CONTROLLER_PARAMETER_FIELDS.properties, input: { anyOf: [...REVIEW_JSON_ARGUMENT.anyOf, { type: "null" }], description: `${REVIEW_CONTROLLER_PARAMETER_FIELDS.properties.input.description} Null is invalid; omit input when optional.`, }, }, } as const; const REVIEW_CAPTURE_PARAMETERS = { type: "object", additionalProperties: false, required: ["lineageId", "collectBinding"], properties: { lineageId: { type: "string", minLength: 1, description: "Exact lineage from the current provider-issued collect transition.", }, collectBinding: { ...REVIEW_JSON_ARGUMENT, description: "JSON object or serialized exact copy of one complete provider-owned collect input from current STATUS; never compose or alter it.", }, reviewerRunAcknowledged: { type: "boolean", description: "Required only after the one-slot materialize reviewer forecast; authorizes exactly one Pi host-relay run.", }, correctionLines: { type: "integer", minimum: 1, description: "Positive correction-line plan in diff lines: one replaced source line counts as two (one deletion plus one addition). A different unit from the provider's frozen logical correction budget. Accepted only for the selected provider correction-plan slot and within its exact bounds.", }, workspaceRoot: { type: "string", description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics.", }, }, } as const; interface ReviewCaptureParameters { lineageId: string; collectBinding: string; reviewerRunAcknowledged?: boolean; correctionLines?: number; workspaceRoot?: string; } const REVIEW_CAPTURE_GROUP_PARAMETERS = { type: "object", additionalProperties: false, required: ["lineageId", "collectBindings"], properties: { lineageId: { type: "string", minLength: 1, description: "Exact lineage from the current provider-issued collect transition." }, collectBindings: { type: "array", minItems: 1, items: REVIEW_JSON_ARGUMENT, description: "Ordered JSON objects or serialized exact copies of the complete current STATUS materialize reviewer collect set; never mix, reorder, or alter bindings." }, reviewerRunAcknowledged: { type: "boolean", description: "Required after the one group forecast; authorizes exactly the forecast reviewer runs." }, workspaceRoot: { type: "string", description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics." }, }, } as const; interface ReviewCaptureGroupParameters { lineageId: string; collectBindings: readonly string[]; reviewerRunAcknowledged?: boolean; workspaceRoot?: string; } const REVIEW_SCOPE_PARAMETERS = { type: "object", additionalProperties: false, required: ["manifest", "sha256"], properties: { manifest: { type: "string", maxLength: 4_096, description: "Exact controller-supplied gzip/base64url frozen changed-scope manifest." }, sha256: { type: "string", pattern: "^[0-9a-f]{64}$", description: "Exact controller-supplied SHA-256 of the decompressed canonical manifest bytes." }, cursor: { type: "integer", minimum: 0, description: "Pagination cursor. Start at 0 and continue with nextCursor until absent." }, }, } as const; interface ReviewScopeParameters { manifest: string; sha256: string; cursor?: number; } // gentle-pi#662: read-only native risk assessment, gating the separate // verifier on native risk instead of a task-description judgment when the // rendered `Receipt-driven development:` line is `off` or `unknown`. Exposed // as `gentle_review` operation `assess` (not a dedicated tool), taking its // optional fields through the controller's existing generic `input` JSON // string, exactly like START's `{"mode":...,"baseRef":...}`. interface ReviewAssessInput extends Pick { baseRef?: string; committedOnly?: boolean; writerModelId?: string; writerEffort?: string; // gentle-pi#668: the caller's own known outcome for this candidate. // Omitted tries to auto-derive declined/unavailable for this EXACT // candidate's own target identity (never a different one); `closed` is // never auto-derived -- pass it explicitly. nativeReviewOutcome?: NativeReviewOutcome; // gentle-shell#1494: the agent raises this candidate to high by citing a // high-risk item; it can never lower the native tier. escalate?: AgentRiskEscalation; } function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome { return typeof value === "string" && (Object.values(NATIVE_REVIEW_OUTCOME) as readonly string[]).includes(value); } function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput { if (raw === undefined) return {}; const value = parseControllerJson(raw, operation); const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome", "escalate", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"]); const unexpected = Object.keys(value).find((key) => !allowed.has(key)); if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`); const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome, escalate } = value; if (baseRef !== undefined && typeof baseRef !== "string") throw new Error(`Review controller ${operation} input baseRef must be a string`); if (committedOnly !== undefined && typeof committedOnly !== "boolean") throw new Error(`Review controller ${operation} input committedOnly must be a boolean`); if (writerModelId !== undefined && typeof writerModelId !== "string") throw new Error(`Review controller ${operation} input writerModelId must be a string`); if (writerEffort !== undefined && typeof writerEffort !== "string") throw new Error(`Review controller ${operation} input writerEffort must be a string`); if (nativeReviewOutcome !== undefined && !isNativeReviewOutcome(nativeReviewOutcome)) { throw new Error(`Review controller ${operation} input nativeReviewOutcome must be one of ${Object.values(NATIVE_REVIEW_OUTCOME).join(", ")}`); } return { ...(baseRef === undefined ? {} : { baseRef: baseRef as string }), ...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }), ...nativeUntrackedSelection(value), ...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }), ...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }), ...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }), ...(escalate === undefined ? {} : { escalate: decodeAgentRiskEscalation(escalate) }), }; } interface ReviewControllerParameters { operation: ReviewControllerOperation; lineageId?: string; selectionBinding?: string; intendedUntracked?: readonly string[]; untrackedScope?: NativeStartUntrackedScope; changeName?: string; idempotencyKey?: string; transition?: string; input?: string; outputPath?: string; inputPath?: string; operationId?: string; lineageIds?: string; acknowledgeUntrustedBundleSource?: string; workspaceRoot?: string; } type NativeReviewAcknowledgementCli = NativeReviewCli & { acknowledgeApproved?: (request: NativeReviewAcknowledgeApprovedRequest) => Promise; }; interface ReviewControllerStartInput { mode: ReviewMode; projection: ReviewProjectionV1; policyHash: string; evidenceHash: string; budget: ReviewBudgetV1; parentLineageId?: string; } function isReviewControllerOperation(value: string): value is ReviewControllerOperation { return Object.values(REVIEW_CONTROLLER_OPERATION).some((operation) => operation === value); } function serializeReviewJsonArgument(value: unknown): string { if (typeof value === "string") return value; if (isProxy(value) || !isRecord(value)) throw new Error("Review JSON argument must be a serialized object string or JSON object"); const ancestors = new Set(); const check = (item: unknown): void => { if (item === null || typeof item === "string" || typeof item === "boolean") return; if (typeof item === "number" && Number.isFinite(item) && !Object.is(item, -0)) return; if (typeof item !== "object" || item === null || isProxy(item) || ancestors.has(item)) throw new Error("Review JSON argument contains a non-JSON or cyclic value"); const array = Array.isArray(item); if (array && Object.getPrototypeOf(item) !== Array.prototype) throw new Error("Review JSON argument must contain only plain JSON arrays"); if (!array && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("Review JSON argument must contain only plain JSON objects"); ancestors.add(item); const keys = Reflect.ownKeys(item); if (array && keys.length !== item.length + 1) throw new Error("Review JSON argument contains a sparse or extended array"); for (const key of keys) { if (array && key === "length") continue; const descriptor = Object.getOwnPropertyDescriptor(item, key)!; if (typeof key !== "string" || !descriptor.enumerable || !("value" in descriptor) || (array && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= item.length))) throw new Error("Review JSON argument contains an ambiguous property"); check(descriptor.value); } ancestors.delete(item); }; check(value); return JSON.stringify(value); } function parseReviewControllerParameters(value: unknown): ReviewControllerParameters { if (!isRecord(value)) throw new Error("Review controller parameters must be an object"); if (typeof value.operation !== "string" || !isReviewControllerOperation(value.operation)) { throw new Error("Review controller operation is unsupported"); } if (value.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) { const unexpected = Object.keys(value).find((key) => !["operation", "selectionBinding", "intendedUntracked", "workspaceRoot"].includes(key)); if (unexpected !== undefined || typeof value.selectionBinding !== "string" || !Array.isArray(value.intendedUntracked) || (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string")) throw new Error("Review intended-untracked selection accepts exactly selectionBinding and intendedUntracked, with optional workspaceRoot"); return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) }; } // gentle-pi#706: top-level untrackedScope/intendedUntracked resolve the // intended-untracked stop through inspect alone. intendedUntracked is not a // standalone selector: inspect accepts it only for an explicit select scope. const hasIntendedUntracked = "intendedUntracked" in value; if (hasIntendedUntracked && value.operation !== REVIEW_CONTROLLER_OPERATION.INSPECT) { throw new Error( `Review controller ${value.operation} does not accept intendedUntracked; it is accepted only by inspect with untrackedScope select`, ); } if (value.untrackedScope !== undefined) { if (value.operation !== REVIEW_CONTROLLER_OPERATION.INSPECT) throw new Error( `Review controller ${value.operation} does not accept untrackedScope; it is accepted only by inspect`, ); if ( value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.SELECT ) throw new Error( "Review controller inspect untrackedScope must be exclude or select", ); } if (hasIntendedUntracked) { if (value.untrackedScope !== NATIVE_START_UNTRACKED_SCOPE.SELECT) { throw new Error( "Review controller inspect intendedUntracked requires untrackedScope select", ); } if (!Array.isArray(value.intendedUntracked)) { throw new Error( "Review controller inspect intendedUntracked must be an array of repository-relative paths", ); } } const needsLineage = !([REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR, REVIEW_CONTROLLER_OPERATION.ASSESS] as readonly ReviewControllerOperation[]).includes(value.operation); if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) { throw new Error("Review controller requires a lineageId"); } const parameters: ReviewControllerParameters = { operation: value.operation, ...(typeof value.lineageId === "string" ? { lineageId: value.lineageId } : {}), ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && (value.untrackedScope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE || value.untrackedScope === NATIVE_START_UNTRACKED_SCOPE.SELECT) ? { untrackedScope: value.untrackedScope } : {}), ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && Array.isArray(value.intendedUntracked) ? { intendedUntracked: [...value.intendedUntracked] } : {}), }; for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) { const optional = value[key]; if (key === "input" && key in value && (value.operation === REVIEW_CONTROLLER_OPERATION.START || value.operation === REVIEW_CONTROLLER_OPERATION.ASSESS || value.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT)) { parameters.input = serializeReviewJsonArgument(optional); continue; } if (optional !== undefined && typeof optional !== "string") { throw new Error(`Review controller ${key} must be a string`); } if (typeof optional === "string") parameters[key] = optional; } return parameters; } function parseReviewCaptureParameters(value: unknown): ReviewCaptureParameters { if (!isRecord(value)) throw new Error("Review capture parameters must be an object"); const allowed = new Set(["lineageId", "collectBinding", "reviewerRunAcknowledged", "correctionLines", "workspaceRoot"]); const unexpected = Object.keys(value).find((key) => !allowed.has(key)); if (unexpected !== undefined) throw new Error(`Review capture does not accept ${unexpected}`); if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture requires an exact non-empty lineageId"); const { reviewerRunAcknowledged, correctionLines, workspaceRoot } = value; const collectBinding = serializeReviewJsonArgument(value.collectBinding); if (collectBinding.length === 0) throw new Error("Review capture requires a non-empty collectBinding"); if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture reviewerRunAcknowledged must be boolean"); if (value.correctionLines !== undefined && (typeof value.correctionLines !== "number" || !Number.isSafeInteger(value.correctionLines) || value.correctionLines < 1)) throw new Error("Review capture correctionLines must be a positive integer"); if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture workspaceRoot must be a string"); return { lineageId: value.lineageId, collectBinding, ...(typeof reviewerRunAcknowledged === "boolean" ? { reviewerRunAcknowledged } : {}), ...(typeof correctionLines === "number" ? { correctionLines } : {}), ...(typeof workspaceRoot === "string" ? { workspaceRoot } : {}), }; } function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupParameters { if (!isRecord(value)) throw new Error("Review capture group parameters must be an object"); const allowed = new Set(["lineageId", "collectBindings", "reviewerRunAcknowledged", "workspaceRoot"]); const unexpected = Object.keys(value).find((key) => !allowed.has(key)); if (unexpected !== undefined) throw new Error(`Review capture group does not accept ${unexpected}`); if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture group requires an exact non-empty lineageId"); if (!Array.isArray(value.collectBindings) || value.collectBindings.length === 0) throw new Error("Review capture group requires one or more collectBindings"); const { reviewerRunAcknowledged, workspaceRoot } = value; const collectBindings = value.collectBindings.map(serializeReviewJsonArgument); if (collectBindings.some((binding) => binding.length === 0)) throw new Error("Review capture group requires non-empty collectBindings"); if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture group reviewerRunAcknowledged must be boolean"); if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture group workspaceRoot must be a string"); return { lineageId: value.lineageId, collectBindings, ...(typeof reviewerRunAcknowledged === "boolean" ? { reviewerRunAcknowledged } : {}), ...(typeof workspaceRoot === "string" ? { workspaceRoot } : {}), }; } function requiredControllerString( parameters: ReviewControllerParameters, key: "idempotencyKey" | "transition" | "command" | "input" | "outputPath" | "inputPath" | "operationId", ): string { const value = parameters[key]; if (typeof value !== "string" || value.trim().length === 0) { throw new Error(`Review controller ${parameters.operation} requires ${key}`); } return value; } function readRepositoryControllerInput(inputPath: string, repositoryRoot: string): string { const canonicalRoot = realpathSync(repositoryRoot); const requestedPath = resolve(canonicalRoot, inputPath); const relativePath = relative(canonicalRoot, requestedPath); if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { throw new Error("Review controller inputPath must be confined to the repository"); } const stat = lstatSync(requestedPath); if (!stat.isFile() || stat.isSymbolicLink() || realpathSync(requestedPath) !== requestedPath) { throw new Error("Review controller inputPath must be a regular non-symlink file"); } return readFileSync(requestedPath, "utf8"); } function parseControllerJson(input: string, operation: ReviewControllerOperation): Record { let value: unknown; try { value = JSON.parse(input); } catch (error) { if (operation === REVIEW_CONTROLLER_OPERATION.START) { throw new Error( `Review controller START input must be a JSON string encoding an object: ${error instanceof Error ? error.message : String(error)}. No lineage was created; do not call STATUS or ADVANCE for this attempted lineage.`, ); } throw new Error( `Review controller ${operation} input is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, ); } if (!isRecord(value)) throw new Error(`Review controller ${operation} input must be a JSON object`); return value; } // Explicit cancellation uses the AbortError convention so observers can tell it from failure. function reviewCancellation(message: string): Error { return Object.assign(new Error(message), { name: "AbortError" }); } async function authorizeDestructiveReviewOperation( parametersValue: unknown, ctx: ExtensionContext, ): Promise { const parameters = parseReviewControllerParameters(parametersValue); // RESET alone carries the legacy repository-wide challenge. Native compact-v2 // RECOVER has its own six-field contract and its own derived // `gentle-ai.review-recovery-authorization/v1` binding, neither of which the // legacy `repositoryId`/`commonDirHash`/`inventoryHash`/`confirmation` quartet // can express. Native INSPECT never publishes that quartet either, so // demanding it here made the only supported recovery flow unreachable // (issue #212). // RECOVER authorizes itself in `executeReviewControllerOperation`, the way // REPAIR_LEGACY_ALIAS does, because its binding can only be derived from a // fresh native target-status read. ABANDON joins that self-authorizing // family for the same reason (issue #1159): its binding is derived from a // fresh native authority inventory read. if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ABANDON) return; const isReset = parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET; const maintenance = nativeMaintenanceOperation(parameters.operation); if (!isReset && maintenance === undefined) return; const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); if (maintenance !== undefined && (missingNativeMaintenanceInputs(maintenance, input).length > 0 || invalidNativeMaintenanceInput(maintenance, input))) return; if (isReset) { for (const key of ["repositoryId", "commonDirHash", "inventoryHash"] as const) { if (typeof input[key] !== "string" || input[key].length === 0) throw new Error(`Review controller ${parameters.operation} requires an exact string ${key}`); } if (typeof input.confirmation !== "string" || input.confirmation.length === 0) throw new Error(`Review controller ${parameters.operation} requires an exact string confirmation`); } if (!ctx.hasUI) { throw new Error(`Review controller ${parameters.operation.toUpperCase()} requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed`); } const maintenanceAuthorization = maintenance === undefined ? undefined : nativeMaintenanceAuthorization(maintenance, input); const approved = await ctx.ui.confirm( maintenance !== undefined ? `Authorize review authority ${parameters.operation.toUpperCase()}?` : `Authorize destructive review authority ${parameters.operation.toUpperCase()}?`, maintenance !== undefined ? ["Operation: " + parameters.operation.toUpperCase(), "Exact published authorization binding:", maintenanceAuthorization!, maintenance === "quarantineLegacy" ? "The native command may quarantine only the published malformed freeze-findings legacy diagnostic." : "The native command may quarantine only the bound invalid recovery successor; the predecessor stays untouched."].join("\n") : [`Operation: ${parameters.operation.toUpperCase()}`, `Repository: ${input.repositoryId}`, `Exact challenge: ${input.confirmation}`, "This invalidates all prior review authority for this repository."].join("\n"), ); if (!approved) throw new Error(`Review controller ${parameters.operation.toUpperCase()} was not explicitly authorized`); } function parseReviewBudget(value: unknown, label: string): ReviewBudgetV1 { if (!isRecord(value)) throw new Error(`${label} must be an object`); return value as unknown as ReviewBudgetV1; } function parseStartInput(value: Record): ReviewControllerStartInput { if (value.mode !== REVIEW_MODE.ORDINARY && value.mode !== REVIEW_MODE.JUDGMENT_DAY) { throw new Error( 'Review controller START supports only "ordinary" or "judgment-day" mode; use "ordinary" unless Judgment Day was explicitly selected. Pass input as a JSON string encoding the START object. START failed before authority access, so no lineage was created; do not call STATUS or ADVANCE for this attempted lineage.', ); } if (!isRecord(value.projection) || typeof value.projection.kind !== "string") { throw new Error("Review controller start requires a projection"); } let projection: ReviewProjectionV1; if (value.projection.kind === REVIEW_PROJECTION.COMPLETE) { projection = { kind: REVIEW_PROJECTION.COMPLETE }; } else if ( value.projection.kind === REVIEW_PROJECTION.INTENDED_COMMIT && typeof value.projection.tree === "string" ) { projection = { kind: REVIEW_PROJECTION.INTENDED_COMMIT, tree: value.projection.tree, }; } else { throw new Error("Review controller start projection is unsupported or unresolved"); } if (typeof value.policyHash !== "string" || typeof value.evidenceHash !== "string") { throw new Error("Review controller start requires policyHash and evidenceHash"); } if (value.parentLineageId !== undefined && typeof value.parentLineageId !== "string") { throw new Error("Review controller parentLineageId must be a string"); } const result: ReviewControllerStartInput = { mode: value.mode, projection, policyHash: value.policyHash, evidenceHash: value.evidenceHash, budget: parseReviewBudget(value.budget, "Review controller start budget"), }; if (typeof value.parentLineageId === "string") result.parentLineageId = value.parentLineageId; return result; } function isReviewTransition(value: string): value is ReviewTransition { return Object.values(REVIEW_TRANSITION).some((transition) => transition === value); } interface NativeStartPreAuthorityRejection { lineage_created: false; mutation_performed: false; mutation_outcome: "none"; reset_eligible: false; } function nativeStartPreAuthorityRejection(): NativeStartPreAuthorityRejection { return { lineage_created: false, mutation_performed: false, mutation_outcome: "none", reset_eligible: false, }; } // Organic-rdd-parity Phase 3 (Design Decision #7): consulted once at the top // of the ORDINARY START branch, before targetStatus. Dark until the // negotiated version reports the `mode` capability true — `reviewMode` // throws VERSION_INCOMPATIBLE in that case, which this treats identically to // "capability absent" (today's path unchanged), never as a failure. Any // other error (a real native process failure) still surfaces through the // caller's existing nativeOperationFailure handling. const REVIEW_MODE_DISABLED_OUTCOME = "review-mode-disabled"; // Parity with gentle-ai's reviewModeScopeForSource // (internal/reviewtransaction/rdd_mode.go): the continuation is scoped to the // source that actually decided, so the operator is not left to work out which // of the two independent sources they have to change. // // A clone-local override can only disable. Pi's explicit clone-scope enable // clears that override, but cannot enable global RDD: when global is still // unset or off, clearing it leaves the effective mode off. Tell the operator // to make the global opt-in first when needed, then clear this clone override. // Pi never mutates the operator's global gentle-ai state automatically. // // Receipt-driven development is on by default (gentle-ai v4.0.0, #4803): an // all-sources-unset install resolves to ON with source `default`. The pinned // v2.4.0 runtime had briefly made it opt-in, which is why this branch exists. // Today `default` only reaches here as gentle-ai's fail-closed projection: the // mode could not be resolved, so it reports OFF with source `default`. The // continuation says so instead of claiming the install never opted in, and // still names `global`, the only scope that can turn reviews on explicitly. // Leaving this undefined would hand that state a dead end. function reviewModeContinuation(source: NativeReviewModeSource): string | undefined { if (source === NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL) return "Run `gentle-ai review mode enable --scope=global` if global RDD is still off, then run /gentle:review-mode enable to clear this clone-local override."; if (source === NATIVE_REVIEW_MODE_SOURCE.GLOBAL) return "Run `gentle-ai review mode enable --scope=global` to turn reviews back on; /gentle:review-mode enable only clears the clone-local setting, which cannot override a global off."; return "RDD is on by default, but this install could not resolve the review mode and failed closed to off. Run `gentle-ai review mode status` to see why, or `gentle-ai review mode enable --scope=global` to turn reviews on explicitly. /gentle:review-mode enable only clears a clone-local override and cannot enable global RDD."; } // Names the situation before the mechanism, then the mechanism, mirroring // gentle-ai's RDDDisabledError.Error(). Pi skips rather than rejects — a // disabled switch never blocks here — but it must not discard which source // decided, because that is precisely the information the operator needs and // the only thing that selects a working way back on. function nativeReviewModeSkipped(operation: ReviewControllerOperation, source: NativeReviewModeSource): Record { const continuation = reviewModeContinuation(source); return { operation, status: "skipped", outcome: REVIEW_MODE_DISABLED_OUTCOME, delivery: "disabled/unmanaged", mode_source: source, reason: `receipt-driven development is disabled: ${operation} is skipped because the ${source} mode source keeps it off`, ...(continuation === undefined ? {} : { next_action: continuation }), ...nativeStartPreAuthorityRejection(), }; } // The legacy START compatibility gate is deliberately permissive when mode is // unavailable. It is not authorization to enter bootstrap-capable native code. // `rememberForPrompt` lets a decoded read serve the next prompt's RDD status // line from the memo instead of spawning the same STATUS again. async function nativeRepositoryPreparationEnabled(nativeReviewCli: NativeReviewCli | null, cwd: string, signal?: AbortSignal, rememberForPrompt = false): Promise { if (nativeReviewCli?.reviewMode === undefined) return false; try { const mode = await nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(signal === undefined ? {} : { signal }) }); const decoded = mode.operation === NATIVE_REVIEW_MODE_OPERATION.STATUS && isValidRddModeStatus(mode.status); if (decoded && rememberForPrompt) rddStatusMemo.set(cwd, { status: mode.status, expiresAt: Date.now() + RDD_STATUS_MEMO_TTL_MS }); return decoded && mode.status.effective === "on"; } catch { return false; } } function nativeRepositoryPreparationBlocked(operation: ReviewControllerOperation): Record { return { operation, status: "blocked", outcome: "native-repository-bootstrap-not-authorized", reason: "A non-Git target requires successfully decoded effective RDD on before native repository preparation. The user owns the RDD switch; Pi does not enable it.", mutation_performed: false, mutation_outcome: "none", lineage_created: false, }; } async function resolveReviewModeGate( nativeReviewCli: NativeReviewCli | null, operation: ReviewControllerOperation, cwd: string, signal: AbortSignal | undefined, ): Promise | undefined> { if (nativeReviewCli?.reviewMode === undefined) return undefined; try { const mode = await nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(signal === undefined ? {} : { signal }) }); return mode.status.effective === "off" ? nativeReviewModeSkipped(operation, mode.status.source) : undefined; } catch (error) { if (asNativeReviewCliError(error)?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return undefined; throw error; } } // gentle-pi#185: a native CLI without negotiated STATUS support (no // `targetStatus`, or a version-incompatible provider) hits this boundary // before any candidate-view restoration is attempted, so it can never // reproduce the #176 empty-registry failure — but the boundary's own // `next_action` was a machine token with nothing a human or an agent could // run. `remediation_command` names the exact upstream command that // re-establishes negotiated STATUS for this session's Pi host identity. const NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND = "gentle-ai review status --cwd --contract gentle-ai.review-integration/v2 --agent pi --next-transition"; function nativeStatusUnsupported(operation: ReviewControllerOperation): Record { return { operation, status: "blocked", outcome: "native-status-unsupported", ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { mutation_performed: false }), inventory_complete: false, next_action: "require-upstream-read-only-native-status-inventory", remediation_command: NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND, evidence: { native_contract: "gentle-ai/2.1.4", general_status: "unsupported", claimant_inventory: "unsupported", }, }; } // Bundled and source module instances can coexist, making instanceof insufficient. function asNativeReviewCliError(error: unknown): { code: string; diagnostics: NativeReviewProcessDiagnostics } | undefined { if (error instanceof NativeReviewCliError) return error; if (!isRecord(error) || error.name !== "NativeReviewCliError") return undefined; const value = error as { code?: unknown; diagnostics?: unknown }; if (typeof value.code !== "string") return undefined; const diagnostics = sanitizeForeignNativeReviewDiagnostics(value.diagnostics); return diagnostics === undefined || value.code !== diagnostics.error_code ? undefined : { code: value.code, diagnostics }; } // Same coexisting-module-instance caveat as asNativeReviewCliError above. function asNativeReviewConsentBindingError(error: unknown): { reason: string; message: string } | undefined { if (error instanceof NativeReviewConsentBindingError) return { reason: error.reason, message: error.message }; if (!(error instanceof Error) || error.name !== "NativeReviewConsentBindingError") return undefined; const reason = (error as unknown as { reason?: unknown }).reason; return typeof reason !== "string" || reason.length === 0 ? undefined : { reason, message: error.message }; } function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation | "gentle_review_capture", diagnostics: NativeReviewProcessDiagnostics): Record { return { operation, status: "blocked", outcome: "native-status-package-binary-missing", ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { lineage_created: false, mutation_performed: false, mutation_outcome: "none" }), inventory_complete: false, diagnostics, reason: `The verified package-local binary is unavailable. ${GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS} This does not prove install lifecycle scripts were disabled.`, recovery_command: GENTLE_AI_INSTALL_RECOVERY_COMMAND, next_action: GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS, }; } function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown): Record { const cliError = asNativeReviewCliError(error); if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return nativeStatusUnsupported(operation); if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, cliError.diagnostics); if (cliError !== undefined) { return { ...nativeOperationFailure(operation, error), outcome: "native-status-unavailable", inventory_complete: false, next_action: "require-complete-native-authority-inventory", }; } // gentle-pi#599: a negotiated STATUS/inspect request the native provider // rejects with a decoded failure/v2 envelope (for example a preflight // `invalid_request` refusal for a nested foreign Git repository) used to // fall through to the generic outcome below, discarding the envelope's own // cause, code, retry_safe, and next_action -- the one piece of information // that makes the refusal actionable (pass the intended nested repo as // workspaceRoot). `nativeOperationFailure` already renders this exact // failure-envelope shape faithfully for every mutating operation; reuse it // here instead of masking the refusal as opaque authority-inventory // corruption. if (error instanceof NativeReviewIntegrationError) { return { ...nativeOperationFailure(operation, error), outcome: "native-status-unavailable", inventory_complete: false, }; } return { operation, status: "blocked", outcome: "native-status-unavailable", lineage_created: false, mutation_performed: false, mutation_outcome: "none", inventory_complete: false, next_action: "require-complete-native-authority-inventory", }; } const NATIVE_RECOVERY_INPUT = { reclaim: ["lineage", "actor", "reason"], recover: ["predecessorLineage", "expectedPredecessorRevision", "successorLineage", "disposition", "actor", "reason"], } as const; const NATIVE_MAINTENANCE_INPUT = { quarantineLegacy: ["repository", "lineage", "expectedRevision", "diagnostic", "disposition", "actor", "reason"], reconcileAuthority: ["predecessorLineage", "expectedPredecessorRevision", "successorLineage", "expectedSuccessorRevision", "actor", "reason"], } as const; type NativeMaintenanceOperation = keyof typeof NATIVE_MAINTENANCE_INPUT; // ABANDON left this caller-supplied family: its binding can only be derived // from a fresh native authority inventory read (issue #1159), so it takes the // self-authorizing route RECOVER and REPAIR_LEGACY_ALIAS use. const NATIVE_ABANDON_INPUT = ["lineage", "actor", "reason"] as const; function nativeMaintenanceOperation(operation: ReviewControllerOperation): NativeMaintenanceOperation | undefined { if (operation === REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY) return "quarantineLegacy"; if (operation === REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY) return "reconcileAuthority"; return undefined; } function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, input: Record): readonly string[] { return NATIVE_MAINTENANCE_INPUT[operation].filter((key) => !isCanonicalProcessString(input[key])); } function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, input: Record): boolean { if (operation === "quarantineLegacy") return input.diagnostic !== NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC || input.disposition !== NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION; return operation === "reconcileAuthority" && input.anomalies !== undefined && input.anomalies !== NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED; } function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record): string { if (operation === "quarantineLegacy") return nativeReviewLegacyQuarantineAuthorization({ repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason) }); return nativeReviewReconcileAuthorization({ predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }) }); } async function executeNativeAuthorityMaintenance( operation: ReviewControllerOperation, nativeOperation: NativeMaintenanceOperation, input: Record, cwd: string, nativeReviewCli: NativeReviewCli | null, signal: AbortSignal | undefined, ): Promise> { const method = nativeOperation === "quarantineLegacy" ? nativeReviewCli?.quarantineLegacy : nativeReviewCli?.reconcileAuthority; const nativeCommand = nativeOperation === "quarantineLegacy" ? "review quarantine-legacy" : "review reconcile-authority"; if (method === undefined) { return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly" }; } const missing = missingNativeMaintenanceInputs(nativeOperation, input); if (missing.length > 0) { return { operation, status: "blocked", outcome: "native-input-required", native_operation: nativeCommand, missing_input: missing, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-exact-native-maintenance-input" }; } if (invalidNativeMaintenanceInput(nativeOperation, input)) { return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-the-exact-published-native-maintenance-binding" }; } try { const result = nativeOperation === "quarantineLegacy" ? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) }) : await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) }); return { operation, native_operation: nativeCommand, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" }; } catch (error) { return nativeOperationFailure(operation, error); } } // Terminal authority states observed in the native lifecycle (closure, // retirement, quarantine, abandonment records). Native re-derives the real // eligibility; this pre-check only fails fast on clearly dead lineages so a // terminal entry never reaches the approval path. const NATIVE_ABANDON_TERMINAL_STATES = new Set(["approved", "closed", "superseded", "quarantined", "abandoned"]); /** * The single eligible ABANDON candidate for a lineage, or undefined. Requires * an authoritative inventory, unique lineage identity across ALL entries * before any eligibility filter (an incomplete duplicate must not be hidden by * the selection), and a live compact-v2 entry (active status, non-terminal * state) carrying the discarded-work projection (reviewer input from dnlrsls * and CodeRabbit on #1668: complete:true authoritative:false, hidden duplicates, * and terminal lineages must not reach the approval path). */ function nativeAbandonCandidate(inventory: { authoritative: boolean; entries: readonly NativeReviewAuthorityEntry[] }, lineage: string): NativeReviewAuthorityEntry | undefined { if (!inventory.authoritative) return undefined; const matches = inventory.entries.filter((entry) => entry.lineageId === lineage); if (matches.length !== 1) return undefined; const entry = matches[0]!; if (entry.version !== "compact-v2" || entry.status !== NATIVE_REVIEW_AUTHORITY_STATUS.ACTIVE || (entry.state !== undefined && NATIVE_ABANDON_TERMINAL_STATES.has(entry.state)) || entry.discardedWork === undefined || !isCanonicalProcessString(entry.revision) || entry.snapshotIdentity === undefined) return undefined; return entry; } function sameOrderedLensList(actual: readonly string[], expected: readonly string[]): boolean { return actual.length === expected.length && actual.every((lens, index) => lens === expected[index]); } /** * ABANDON freshly reads the native authority inventory, locates the single * eligible compact-v2 entry for the caller-specified lineage, derives its * revision, snapshot identity, and discarded-work summary, and renders the * exact eight-line `gentle-ai.review-abandon-authorization/v2` binding for * interactive approval before any mutation (issue #1159). The caller supplies * only lineage, actor, and reason; every inventory-derived field is derived, * never requested, because no negotiated facade operation publishes the * discarded-work projection (the encoding the native gate recomputes carries * the ordinal-prefixed lens order, which a caller cannot reconstruct). */ async function executeNativeAbandon( input: Record, lineageParameter: string | undefined, cwd: string, nativeReviewCli: NativeReviewCli | null, signal: AbortSignal | undefined, context: ExtensionContext | undefined, ): Promise> { const operation = REVIEW_CONTROLLER_OPERATION.ABANDON; const nativeOperation = "review abandon"; if (nativeReviewCli?.abandon === undefined || nativeReviewCli.reviewStatus === undefined) { return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly" }; } const unknown = Object.keys(input).filter((key) => !(NATIVE_ABANDON_INPUT as readonly string[]).includes(key)); if (unknown.length > 0 || (input.lineage !== undefined && lineageParameter !== undefined && String(input.lineage) !== lineageParameter)) { return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason-only-pi-derives-the-discarded-work-summary-from-fresh-native-inventory" }; } const lineage = input.lineage !== undefined ? String(input.lineage) : lineageParameter; const missing = [ ...(isCanonicalProcessString(lineage) ? [] : ["lineage"]), ...(isCanonicalProcessString(input.actor) ? [] : ["actor"]), ...(isCanonicalProcessString(input.reason) ? [] : ["reason"]), ]; if (missing.length > 0) { return { operation, status: "blocked", outcome: "native-input-required", native_operation: nativeOperation, missing_input: missing, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason" }; } if (!isNativeReviewAbandonReason(input.reason)) { return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeOperation, allowed_reasons: NATIVE_REVIEW_ABANDON_REASONS, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-reason-operator_disposition-or-retired_schema" }; } let inventory; try { inventory = await nativeReviewCli.reviewStatus({ cwd, ...(signal === undefined ? {} : { signal }) }); } catch (error) { return nativeOperationFailure(operation, error); } if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled"); const entry = nativeAbandonCandidate(inventory, lineage); if (entry === undefined) { return { operation, status: "blocked", outcome: "native-abandon-ineligible", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "inspect-complete-native-authority-inventory" }; } const request = { cwd, lineage: entry.lineageId!, expectedRevision: entry.revision!, snapshotIdentity: entry.snapshotIdentity!, capturedLensResults: entry.discardedWork!.capturedLensResults, findingsPresent: entry.discardedWork!.findingsPresent, actor: String(input.actor), reason: input.reason, }; const authorization = nativeReviewAbandonAuthorization(request); if (context?.hasUI !== true) throw new Error("Review controller ABANDON requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed"); const approved = await context.ui.confirm( "Authorize review authority ABANDON?", ["Operation: ABANDON", "Exact published authorization binding:", authorization, "The native command may quarantine only an eligible pristine compact-v2 lineage."].join("\n"), ); if (!approved) throw new Error("Review controller ABANDON was not explicitly authorized"); // The approval binds the exact derived authority. Re-read the inventory and // fail closed on any drift before mutating, the way RECOVER rechecks its // provider-bound authorization (same TOCTOU window: another actor may have // advanced the lineage between the deriving read and the approval). if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled"); let recheck; try { recheck = await nativeReviewCli.reviewStatus({ cwd, ...(signal === undefined ? {} : { signal }) }); } catch (error) { return nativeOperationFailure(operation, error); } if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled"); const reconfirmed = nativeAbandonCandidate(recheck, request.lineage); if (reconfirmed === undefined || reconfirmed.revision !== request.expectedRevision || reconfirmed.snapshotIdentity !== request.snapshotIdentity || reconfirmed.discardedWork!.findingsPresent !== request.findingsPresent || !sameOrderedLensList(reconfirmed.discardedWork!.capturedLensResults, request.capturedLensResults)) { return { operation, status: "blocked", outcome: "native-abandon-authority-changed", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "inspect-and-restart-abandon-from-fresh-inventory" }; } if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled"); try { const result = await nativeReviewCli.abandon({ ...request, maintainerAuthorization: authorization, ...(signal === undefined ? {} : { signal }) }); return { operation, native_operation: nativeOperation, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" }; } catch (error) { return nativeOperationFailure(operation, error); } } const NATIVE_LEGACY_ALIAS_REPAIR_INPUT = ["lineage", "actor", "reason"] as const; async function executeNativeLegacyAliasRepair( input: Record, cwd: string, nativeReviewCli: NativeReviewCli | null, signal: AbortSignal | undefined, context: ExtensionContext | undefined, ): Promise> { const operation = REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS; const nativeOperation = "review repair-legacy-alias"; if (Object.keys(input).some((key) => !NATIVE_LEGACY_ALIAS_REPAIR_INPUT.includes(key as (typeof NATIVE_LEGACY_ALIAS_REPAIR_INPUT)[number]))) { return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason-only" }; } const missing = NATIVE_LEGACY_ALIAS_REPAIR_INPUT.filter((key) => !isCanonicalProcessString(input[key])); if (missing.length > 0) { return { operation, status: "blocked", outcome: "native-input-required", native_operation: nativeOperation, missing_input: missing, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason" }; } if (nativeReviewCli?.reviewStatus === undefined || nativeReviewCli.repairLegacyAlias === undefined) { return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-v2.1.11-or-run-native-review-cli-directly" }; } let inventory; try { inventory = await nativeReviewCli.reviewStatus({ cwd, ...(signal === undefined ? {} : { signal }) }); } catch (error) { return nativeOperationFailure(operation, error); } const candidate = inventory.complete ? inventory.entries.filter((entry) => entry.version === "legacy-v1" && entry.status === "invalid" && entry.lineageId === input.lineage && isCanonicalProcessString(entry.revision) && entry.problems.length === 1 && entry.problems[0] === NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DIAGNOSTIC, ) : []; if (candidate.length !== 1 || !isCanonicalProcessString(inventory.repository)) { return { operation, status: "blocked", outcome: "native-alias-repair-ineligible", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "inspect-complete-native-authority-inventory" }; } const entry = candidate[0]!; const request = { cwd, repository: inventory.repository, lineage: entry.lineageId!, expectedRevision: entry.revision!, diagnostic: NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DISPOSITION, actor: input.actor as string, reason: input.reason as string, }; const authorization = nativeReviewLegacyAliasRepairAuthorization(request); if (context?.hasUI !== true) throw new Error("Review controller REPAIR_LEGACY_ALIAS requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed"); const approved = await context.ui.confirm( "Authorize review authority REPAIR_LEGACY_ALIAS?", ["Operation: REPAIR_LEGACY_ALIAS", "Exact published authorization binding:", authorization, "The native command may quarantine only this fresh, invalid legacy-v1 alias lineage; it never rewrites or validates historical authority."].join("\n"), ); if (!approved) throw new Error("Review controller REPAIR_LEGACY_ALIAS was not explicitly authorized"); try { const result = await nativeReviewCli.repairLegacyAlias({ ...request, maintainerAuthorization: authorization, ...(signal === undefined ? {} : { signal }) }); return { operation, native_operation: nativeOperation, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" }; } catch (error) { return nativeOperationFailure(operation, error); } } /** * Routes the destructive controller operations to their closest audited native * equivalent: RESET and RECOVER_LOCK map to `gentle-ai review reclaim` * (audited quarantine of one incomplete entry) and RECOVER maps to * `gentle-ai review recover` (auditable successor authority). Native inputs * the legacy flow never carried are requested through a structured envelope * instead of being invented. Pi-owned authorization semantics run before this * routing and are unchanged. */ async function executeNativeRecoveryRoute( operation: ReviewControllerOperation, nativeOperation: "reclaim" | "recover", input: Record, cwd: string, nativeReviewCli: NativeReviewCli | null, signal: AbortSignal | undefined, targetSelectors: { baseRef?: string; committedOnly?: boolean } = {}, ): Promise> { const nativeCommand = `review ${nativeOperation}`; const method = nativeOperation === "reclaim" ? nativeReviewCli?.reclaim : nativeReviewCli?.recover; if (nativeReviewCli === null || method === undefined) { return { operation, status: "blocked", outcome: "native-recovery-unavailable", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly", }; } const missing = NATIVE_RECOVERY_INPUT[nativeOperation].filter((key) => key === "disposition" ? input[key] !== "scope_changed" && input[key] !== "invalidated" && input[key] !== "escalated" : typeof input[key] !== "string" || (input[key] as string).trim().length === 0, ); if (missing.length > 0) { return { operation, status: "blocked", outcome: "native-input-required", native_operation: nativeCommand, missing_input: missing, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-exact-native-recovery-input", }; } try { const result = nativeOperation === "reclaim" ? await nativeReviewCli.reclaim!({ cwd, lineage: String(input.lineage), actor: String(input.actor), reason: String(input.reason), ...(signal === undefined ? {} : { signal }) }) : await nativeReviewCli.recover!({ cwd, ...targetSelectors, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), disposition: input.disposition as "scope_changed" | "invalidated" | "escalated", actor: String(input.actor), reason: String(input.reason), ...(typeof input.maintainerAuthorization === "string" ? { maintainerAuthorization: input.maintainerAuthorization } : {}), ...(signal === undefined ? {} : { signal }), }); return { operation, native_operation: nativeCommand, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect", }; } catch (error) { return nativeOperationFailure(operation, error); } } function mapNativeStartResult(result: NativeStartResult): Record { return { lineage_id: result.lineageId, state: result.state, risk_tier: result.riskLevel, selected_lenses: result.selectedLenses, changed_files: result.changedFiles, original_changed_lines: result.changedLines, correction_budget: result.correctionBudget, action: result.action, lenses_required: result.lensesRequired, ...(result.riskReasons === undefined ? {} : { risk_reasons: result.riskReasons }), // Organic-parity passthrough (Design Decision #8, organic-rdd-parity): // risk_evidence/hint are rendered verbatim from the native start result, // with zero local derivation; both stay absent whenever the negotiated // version's capability is dark (every shipped row today). ...(result.riskEvidence === undefined ? {} : { risk_evidence: result.riskEvidence }), ...(result.hint === undefined ? {} : { hint: result.hint }), ...(result.nextTransition === undefined ? {} : { next_transition: result.nextTransition }), }; } function requiredStatusActionText(lineageId?: string): string { return `Run target-scoped review.status${lineageId === undefined ? "" : ` for lineage ${lineageId}`} and follow only its declared action.`; } // The public collect projection is collectBindings: each provider collect // input serialized once as the opaque binding gentle_review_capture consumes. // The raw next_transition.collect.inputs carry the same bytes, so a four-lens // collect state used to cost about 28k characters per STATUS, INSPECT, or // START answer and again on every blocked retry (#465). The raw transition // keeps its kind and reason so the orchestrator still sees the collect state. function withoutRawCollectInputs(raw: Record): Record { if (!isRecord(raw.next_transition)) return raw; const { collect: _collect, ...transition } = raw.next_transition; return { ...raw, next_transition: transition }; } function mapNativeTargetStatus(operation: ReviewControllerOperation, status: ReviewStatusV3, requestedLineageId?: string, workspaceRoot?: string): Record { if ( status.nextTransition?.kind === "collect" && (operation === REVIEW_CONTROLLER_OPERATION.START || operation === REVIEW_CONTROLLER_OPERATION.INSPECT || operation === REVIEW_CONTROLLER_OPERATION.STATUS) ) { const selection = reviewIntendedUntrackedInput(status); return { operation, status: "blocked", result: withoutRawCollectInputs(status.raw), ...(selection === undefined ? { collectBindings: publicReviewCaptureBindings(status) } : { selectionBinding: canonicalReviewCaptureBinding(selection) }), }; } if (status.action === "recover") { return { operation, status: "blocked", result: status.raw, provider_action: "recover", recovery_disposition: status.actionDisposition, next_action: "recover-with-provider-disposition", required_status_action: "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.", }; } // gentle-pi#627: a stale managed-asset set stops the transition with the // exact `gentle-ai sync` invocation that resolves it. Render that command // as the one actionable next step; every other reason code keeps rendering // as a plain blocked result. if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "managed_assets_outdated" && status.nextTransition.continuation !== undefined) { return { operation, status: "blocked", result: status.raw, ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }), hint: `run ${status.nextTransition.continuation.command}`, }; } // gentle-pi#638: an unachievable-lens stop carries the exact withdraw command for every declared slot, mirroring the managed_assets_outdated precedent. A restart that never saw the collect offer still finds its way back from this hint alone. // gentle-pi#822: when the caller asked about one lineage, render only that lineage's withdraw command; the first entry may belong to an unrelated lineage, so an unmatched request omits the hint instead of surfacing a potentially unrelated withdraw command. Without a requested lineage the first entry stays the fallback. if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" && status.nextTransition.unachievableLensSlots !== undefined) { const withdrawSlot = requestedLineageId === undefined ? status.nextTransition.unachievableLensSlots[0] : status.nextTransition.unachievableLensSlots.find((slot) => slot.withdraw.binding.lineageId === requestedLineageId); return { operation, status: "blocked", result: status.raw, ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }), ...(withdrawSlot === undefined ? {} : { hint: `run ${withdrawSlot.withdraw.command}` }), }; } if ( status.nextTransition?.kind === "execute" && status.nextTransition.execute.operation === "review.acknowledge-approved" ) { const lineageId = status.authority?.lineageId ?? requestedLineageId; const nextAction = lineageId === undefined ? undefined : `gentle_review {"operation":"acknowledge-approved","lineageId":"${lineageId}"${workspaceRoot ? `,"workspaceRoot":${JSON.stringify(workspaceRoot)}` : ""}}`; return { operation, status: "blocked", result: status.raw, ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }), ...(nextAction === undefined ? {} : { next_action: nextAction }), }; } return { operation, status: status.action === "start" ? "ready" : "blocked", result: status.raw, ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }), }; } interface NativeStartPolicyValidation { policyPath?: string; reason?: string; } function isStrictDescendantPath(parent: string, candidate: string): boolean { const pathFromParent = relative(parent, candidate); return pathFromParent.length > 0 && pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent); } function validateNativeStartPolicyPath(cwd: string, value: unknown): NativeStartPolicyValidation { if (typeof value !== "string" || value.trim().length === 0) return { reason: "policy-path-not-regular" }; let repository: string; try { repository = realpathSync(cwd); } catch { return { reason: "policy-path-outside-scope" }; } const policyRoot = join(repository, ".gentle-ai", "policies"); const candidate = resolve(repository, value); if (!isStrictDescendantPath(policyRoot, candidate)) return { reason: "policy-path-outside-scope" }; const gentleDirectory = join(repository, ".gentle-ai"); for (const directory of [gentleDirectory, policyRoot]) { try { const metadata = lstatSync(directory); if (metadata.isSymbolicLink()) return { reason: "policy-path-symlink" }; if (!metadata.isDirectory()) return { reason: "policy-path-not-regular" }; } catch { return { reason: "policy-path-not-regular" }; } } const segments = relative(policyRoot, candidate).split(sep); let current = policyRoot; for (const [index, segment] of segments.entries()) { current = join(current, segment); try { const metadata = lstatSync(current); if (metadata.isSymbolicLink()) return { reason: "policy-path-symlink" }; if (index === segments.length - 1) { if (!metadata.isFile()) return { reason: "policy-path-not-regular" }; } else if (!metadata.isDirectory()) { return { reason: "policy-path-not-regular" }; } } catch { return { reason: "policy-path-not-regular" }; } } try { const canonicalPath = realpathSync(candidate); if (canonicalPath !== candidate || !isStrictDescendantPath(policyRoot, canonicalPath)) return { reason: "policy-path-symlink" }; return { policyPath: canonicalPath }; } catch { return { reason: "policy-path-not-regular" }; } } const NATIVE_START_FOCUS = { RISK: "risk", RESILIENCE: "resilience", READABILITY: "readability", RELIABILITY: "reliability", } as const; type NativeStartFocus = (typeof NATIVE_START_FOCUS)[keyof typeof NATIVE_START_FOCUS]; const NATIVE_START_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const; const NATIVE_START_LENS_REASON_MAX = 500; /** * Validates the optional agent lens selection of an ordinary START input * (gentle-ai START --lenses): both fields or neither, one to four known lens * names (short or review- form) without repeats, a non-empty single-line * reason of at most 500 bytes, and never together with focus. Returns the * canonical request fields, {} without a selection, or undefined when invalid. */ function validateNativeStartLensSelection(rawStart: Record): { lenses?: readonly string[]; lensesReason?: string } | undefined { const { lenses, lensesReason } = rawStart; if (lenses === undefined && lensesReason === undefined) return {}; if (!Array.isArray(lenses) || lenses.length === 0 || typeof lensesReason !== "string" || rawStart.focus !== undefined) return undefined; if (lensesReason.trim() === "" || /[\r\n]/.test(lensesReason) || Buffer.byteLength(lensesReason, "utf8") > NATIVE_START_LENS_REASON_MAX) return undefined; const chosen = new Set(); for (const lens of lenses) { if (typeof lens !== "string") return undefined; const canonical = lens.startsWith("review-") ? lens : `review-${lens}`; if (!(NATIVE_START_LENSES as readonly string[]).includes(canonical) || chosen.has(canonical)) return undefined; chosen.add(canonical); } return { lenses: NATIVE_START_LENSES.filter((lens) => chosen.has(lens)), lensesReason }; } function isNativeStartFocus(value: unknown): value is NativeStartFocus { return typeof value === "string" && (Object.values(NATIVE_START_FOCUS) as readonly string[]).includes(value); } const NATIVE_START_UNTRACKED_SCOPE = { EXCLUDE: "exclude", SELECT: "select", } as const; type NativeStartUntrackedScope = (typeof NATIVE_START_UNTRACKED_SCOPE)[keyof typeof NATIVE_START_UNTRACKED_SCOPE]; interface NativeStartUntrackedSelection { untrackedScope?: NativeStartUntrackedScope; expectedUntrackedInventory?: string; intendedUntracked?: readonly string[]; reason?: string; } interface RetainedNativeUntrackedSelection { readonly untrackedScope: NativeStartUntrackedScope; readonly expectedUntrackedInventory: string; readonly intendedUntracked: readonly string[]; readonly submission?: NativeIntendedUntrackedSelectionSubmission; } // The untracked-selection fields are optional here, unlike the lineage-scoped // RetainedNativeUntrackedSelection above: a plain inspect that resolves ready // without ever needing an untrackedScope decision still retains its own // committed-range selector alone, bound only to targetIdentity/candidateTree. interface RetainedPreLineageNativeUntrackedSelection { readonly untrackedScope?: NativeStartUntrackedScope; readonly expectedUntrackedInventory?: string; readonly intendedUntracked?: readonly string[]; readonly submission?: NativeIntendedUntrackedSelectionSubmission; readonly targetIdentity: string; readonly candidateTree: string; // gentle-pi#1192: only set when the inspect that produced this entry was a // committed-range inspect, so a later plain START can replay the same // selector instead of silently falling back to the workspace-diff range. readonly baseRef?: string; readonly committedOnly?: true; } interface RetainedNativeCaptureRoute { readonly workspaceRoot: string; readonly lineageId: string; readonly baseRef?: string; readonly committedOnly?: true; } // An inspect that stops before submission has no resolved untracked selection // yet. Keep only its selector, bound to the exact provider collect input. interface RetainedNativeUntrackedStopSelector { readonly selectionBinding: string; readonly targetIdentity: string; readonly baseRef: string; readonly committedOnly: true; } type RetainedNativeStatusSelection = RetainedNativeUntrackedSelection | RetainedPreLineageNativeUntrackedSelection | RetainedNativeCaptureRoute | RetainedNativeUntrackedStopSelector; const MAX_RETAINED_NATIVE_STATUS_SELECTIONS = 64; class NativeCaptureRouteRegistrationError extends Error {} function isNativeStartUntrackedPath(value: unknown): value is string { return isCanonicalProcessString(value) && !isAbsolute(value) && !/^[A-Za-z]:\//.test(value) && !value.includes("\\") && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); } function validateNativeStartUntrackedSelection(value: Record): NativeStartUntrackedSelection { const declared = "untrackedScope" in value || "expectedUntrackedInventory" in value || "intendedUntracked" in value; if (!declared) return {}; const scope = value.untrackedScope; const expectedUntrackedInventory = value.expectedUntrackedInventory; const intendedUntracked = value.intendedUntracked; if ( (scope !== NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && scope !== NATIVE_START_UNTRACKED_SCOPE.SELECT) || !isCanonicalProcessString(expectedUntrackedInventory) || (intendedUntracked !== undefined && (!Array.isArray(intendedUntracked) || intendedUntracked.some((path) => !isNativeStartUntrackedPath(path) || intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path)))) ) return { reason: "untracked-selection-invalid" }; const paths = Array.isArray(intendedUntracked) ? intendedUntracked : []; if (scope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && paths.length > 0) return { reason: "untracked-selection-invalid" }; if (scope === NATIVE_START_UNTRACKED_SCOPE.SELECT && paths.length === 0) return { reason: "untracked-selection-invalid" }; return { untrackedScope: scope, expectedUntrackedInventory, intendedUntracked: [...paths], }; } const BASE_REF_REJECTION_REASONS = new Set(["base-ref-unresolvable", "base-ref-ambiguous", "base-ref-moved", "base-ref-invalid"]); function baseRefRejectionHint(reason: string): { hint?: string } { return BASE_REF_REJECTION_REASONS.has(reason) ? { hint: BASE_REF_ACCEPTED_FORMS } : {}; } function nativeStartRejection(reason: string, field?: string): Record { return { operation: REVIEW_CONTROLLER_OPERATION.START, status: "blocked", outcome: reason === "legacy-policy-hash-unsupported" ? "native-start-legacy-policy-hash-unsupported" : reason === "base-ref-invalid" ? "native-start-base-ref-invalid" : reason === "base-ref-ambiguous" ? "native-start-base-ref-ambiguous" : reason === "base-ref-unresolvable" || reason === "base-ref-moved" ? "native-start-base-ref-unresolvable" : reason === "committed-only-required" ? "native-start-committed-only-required" : reason === "committed-only-invalid" ? "native-start-committed-only-invalid" : reason === "unknown-field" || reason === "focus-invalid" || reason === "lenses-invalid" || reason === "untracked-selection-invalid" || reason === "provider-base-offer-invalid" || reason === "provider-base-tree-mismatch" ? "native-start-input-invalid" : "native-start-policy-path-invalid", reason, ...(field === undefined ? {} : { field }), ...baseRefRejectionHint(reason), ...nativeStartPreAuthorityRejection(), }; } function nativeStatusInputRejection(reason: string, field?: string): Record { return { operation: REVIEW_CONTROLLER_OPERATION.STATUS, status: "blocked", outcome: "native-status-input-invalid", reason, ...(field === undefined ? {} : { field }), ...baseRefRejectionHint(reason), mutation_performed: false, mutation_outcome: "none", }; } function nativeInspectInputRejection(reason: string, field?: string): Record { return { operation: REVIEW_CONTROLLER_OPERATION.INSPECT, status: "blocked", outcome: "native-inspect-input-invalid", reason, ...(field === undefined ? {} : { field }), ...baseRefRejectionHint(reason), mutation_performed: false, mutation_outcome: "none", }; } const REVIEW_SESSION_PERMISSION_STATUS_KEY = "gentle-review-session-permission"; const REVIEW_SESSION_PERMISSION_STATUS_TEXT = "reviews allowed for this session"; type PendingReviewConsentSessionKey = string | symbol; interface PendingReviewConsent { id: string; repositoryCwd: string; authorityCwd: string; candidateView: CandidateView; candidateViews: CandidateViewRegistry | null; verifyCandidate: () => void; cleanupCandidate: () => void; untrackedSelection?: RetainedNativeUntrackedSelection; consent: ReviewConsentEnvelope; consentDigest: string; } const PENDING_REVIEW_CONSENT_DISPOSITION = { CONSUMED: "consumed", } as const; type PendingReviewConsentDisposition = (typeof PENDING_REVIEW_CONSENT_DISPOSITION)[keyof typeof PENDING_REVIEW_CONSENT_DISPOSITION]; const PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT = 32; /** * Process-memory-only pending consent partitions. A loaded extension module * shares this registry across registrations, while exact Pi session IDs remain * the only continuity boundary. It intentionally has no persistence surface. */ export class PendingReviewConsentRegistry { private readonly sessions = new Map>(); // gentle-pi#455: a binding id is globally unique (randomUUID), so its live // owner and its stale disposition are tracked by binding id alone. This // index lets answer-consent resolve and atomically take exactly once a // binding another active Pi session's START created; the per-session map // above stays authoritative for session-scoped listings and shutdown cleanup. private readonly byBinding = new Map(); private readonly staleDispositions = new Map(); get(sessionKey: PendingReviewConsentSessionKey): Map | undefined { return this.sessions.get(sessionKey); } ensure(sessionKey: PendingReviewConsentSessionKey): Map { let pending = this.sessions.get(sessionKey); if (pending === undefined) { pending = new Map(); this.sessions.set(sessionKey, pending); } return pending; } // Registers a freshly created pending binding under its owning session and // the cross-session binding index in one step so the two never drift. add(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void { this.ensure(sessionKey).set(pending.id, pending); this.byBinding.set(pending.id, sessionKey); } // Resolves a live binding to its owning session regardless of which // session asks, so answer-consent can reach a binding another session's // START created (gentle-pi#455). resolve(bindingId: string): { sessionKey: PendingReviewConsentSessionKey; pending: PendingReviewConsent } | undefined { const sessionKey = this.byBinding.get(bindingId); const pending = sessionKey === undefined ? undefined : this.sessions.get(sessionKey)?.get(bindingId); return pending === undefined ? undefined : { sessionKey, pending }; } private remove(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean { const session = this.sessions.get(sessionKey); if (session?.get(pending.id) !== pending) return false; session.delete(pending.id); if (session.size === 0) this.sessions.delete(sessionKey); if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id); return true; } private rememberDisposition(pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void { this.staleDispositions.delete(pending.id); this.staleDispositions.set(pending.id, disposition); while (this.staleDispositions.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) this.staleDispositions.delete(this.staleDispositions.keys().next().value!); } consume(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean { if (!this.remove(sessionKey, pending)) return false; this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED); return true; } discard(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void { this.remove(sessionKey, pending); } staleDisposition(binding: string): PendingReviewConsentDisposition | undefined { return this.staleDispositions.get(binding); } take(sessionKey: PendingReviewConsentSessionKey): PendingReviewConsent[] { const session = this.sessions.get(sessionKey); this.sessions.delete(sessionKey); if (session !== undefined) for (const pending of session.values()) if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id); return session === undefined ? [] : [...session.values()]; } } const processPendingReviewConsentRegistry = new PendingReviewConsentRegistry(); const processRetainedNativeStatusSelections = new Map>(); // gentle-pi#556 / gentle-ai#4051: nesting depth of named-agent ( // executor or other subagent) starts vs. ends for a session. Starts and // ends are paired so a subagent's own loop end never leaves the primary // loop's `agent_end` preflight suppressed for the rest of the session: a // named-agent start increments the depth, a matching end decrements it, // and a fresh primary-loop start resets it to 0. const processAgentEndSubagentDepth = new Map(); // gentle-shell#2195: the RDD status line each primary session last rendered into its system prompt. const pinnedRddStatusLines = new Map(); // gentle-shell#1064 slice 1: the parent-session profile binding store lives in // lib/session-profile-binding.ts (in-process, keyed by parent session id). The // panel binds on Enter; the launch resolver, the shell status reader, and the // usage provider scope read the same store through the lib. // gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end; // Pi only nudges it once per process. This is a plain process-lifetime // guard, not a session-keyed map, because the nudge is meant to fire at most // once no matter how many primary-session `before_agent_start` events this // process observes. let processTelemetryTriggerAttempted = false; /** Testing-only reset for the once-per-process telemetry trigger guard. */ function resetTelemetryTriggerGuardForTesting(): void { processTelemetryTriggerAttempted = false; } function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, fallbackKey: symbol): PendingReviewConsentSessionKey { try { const sessionManager = (context as unknown as { sessionManager?: { getSessionId?: () => unknown } } | undefined)?.sessionManager; const sessionId = sessionManager?.getSessionId?.(); if (typeof sessionId === "string") return sessionId; } catch { /* Minimal or test contexts use the registration-local fallback. */ } return fallbackKey; } function consumePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): boolean { return registry.consume(sessionKey, pending); } function discardPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { registry.discard(sessionKey, pending); } function cleanupPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { discardPendingReviewConsent(pending, registry, sessionKey); pending.cleanupCandidate(); } function cleanupAllPendingReviewConsents(registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { for (const pending of registry.take(sessionKey)) cleanupPendingReviewConsent(pending, registry, sessionKey); } // Pending consent waits for the human without a deadline. Candidate verification, // one-shot consumption, replacement, and session teardown still own its lifecycle. function reviewConsentDigest(consent: ReviewConsentEnvelope): string { return createHash("sha256").update(JSON.stringify(consent)).digest("hex"); } function reviewSessionManagerAndId(context: ExtensionContext): { manager: object; sessionId: string } | undefined { try { const manager = context.sessionManager as unknown as { getSessionId?: () => unknown }; const sessionId = manager.getSessionId?.(); if (typeof manager !== "object" || manager === null || typeof sessionId !== "string" || sessionId.length === 0) return undefined; return { manager, sessionId }; } catch { return undefined; } } function isDirectOrdinaryReviewStart(parametersValue: unknown): boolean { try { const parameters = parseReviewControllerParameters(parametersValue); if (parameters.operation !== REVIEW_CONTROLLER_OPERATION.START) return false; const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); return input.mode === REVIEW_MODE.ORDINARY; } catch { return false; } } function isHostReviewConsentEligibleOperation(parametersValue: unknown): boolean { if (isDirectOrdinaryReviewStart(parametersValue)) return true; try { return parseReviewControllerParameters(parametersValue).operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED; } catch { return false; } } function completedGrantedReviewConsent(outcome: Record): boolean { if ( outcome.operation !== REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT || outcome.status !== undefined || outcome.outcome !== undefined || outcome.native_invocation_attempted === false || outcome.mutation_performed === false || outcome.lineage_created === false || !isCanonicalProcessString(outcome.workspace_root) ) return false; const result = outcome.result; if (!isRecord(result)) return false; const nonnegativeInteger = (value: unknown): boolean => Number.isInteger(value) && Number(value) >= 0; return isCanonicalProcessString(result.lineage_id) && isCanonicalProcessString(result.state) && isCanonicalProcessString(result.risk_tier) && Array.isArray(result.selected_lenses) && result.selected_lenses.every(isCanonicalProcessString) && nonnegativeInteger(result.changed_files) && nonnegativeInteger(result.original_changed_lines) && nonnegativeInteger(result.correction_budget) && (result.action === "created" || result.action === "resumed" || result.action === "replayed" || result.action === "closed") && typeof result.lenses_required === "boolean"; } // gentle-pi#516: a binding this session does not hold (already answered, // or issued by another Pi process) used to fall through // to the plain negotiated STATUS, which reads exactly like a healthy pre-start // "ready" and sent the model back into START for a second consent prompt. The // fact is local and proven before any provider call, so the outcome names the // binding and the exit; the current STATUS rides along as context only. const STALE_CONSENT_BINDING_DIAGNOSTIC_CODE = { ALREADY_CONSUMED: "consent-binding-already-consumed", UNKNOWN: "consent-binding-unknown", } as const; type StaleConsentBindingDiagnosticCode = (typeof STALE_CONSENT_BINDING_DIAGNOSTIC_CODE)[keyof typeof STALE_CONSENT_BINDING_DIAGNOSTIC_CODE]; interface StaleConsentBindingDiagnostics { code: StaleConsentBindingDiagnosticCode; message: string; } function staleConsentBindingDiagnostics(binding: string, disposition: PendingReviewConsentDisposition | undefined): StaleConsentBindingDiagnostics { const exit = "Run START again for this candidate to obtain a fresh consent envelope and answer that envelope's binding once; do not resend this binding."; if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED) { return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.ALREADY_CONSUMED, message: `consent binding ${binding} was already consumed by an earlier answer. ${exit}` }; } return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.UNKNOWN, message: `consent binding ${binding} is not held by this Pi session. ${exit}` }; } function staleConsentBindingOutcome(operation: ReviewControllerOperation, binding: string, diagnostics: ReturnType, status: ReviewStatusV3, workspaceRoot?: string): Record { const mapped = mapNativeTargetStatus(operation, status, undefined, workspaceRoot); return { ...mapped, status: "blocked", outcome: "consent-binding-stale", consent_binding: binding, diagnostics, native_invocation_attempted: false, ...nativeStartPreAuthorityRejection(), provider_action: status.action, ...(status.action === "start" ? { next_action: "restart-for-fresh-consent" } : mapped.next_action === undefined ? { next_action: status.action } : {}), }; } // gentle-pi#455 correction: cross-session resolution looks a binding up by // its opaque id alone, so it must independently confirm the answering // invocation addresses the same repository its owning START minted it for. // This is a typed, non-bearer refusal in the same shape family as the // stale-binding outcome -- it never runs the mode gate or native // answerConsent, and never consumes the binding, so it stays answerable from // the correct repository afterwards. function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOperation, binding: string, mintingRepositoryCwd: string, answeringRepositoryCwd: string): Record { return { operation, status: "blocked", outcome: "consent-binding-repository-mismatch", consent_binding: binding, diagnostics: { code: "consent-binding-repository-mismatch", message: `consent binding ${binding} was minted for repository ${mintingRepositoryCwd}, not ${answeringRepositoryCwd}. Answer this binding from a session addressing ${mintingRepositoryCwd}; do not resend this binding from a different repository.`, }, native_invocation_attempted: false, ...nativeStartPreAuthorityRejection(), next_action: "answer-from-minting-repository", }; } // gentle-pi#874: the committed-range selector a current STATUS owns. A // selectorless STATUS on a clean, fully committed worktree answers with a // review.start execute transition naming the exact merge-base the provider // wants, so a plain START can adopt the route the provider just rendered // instead of making the caller hand-copy `base-ref` into its input. Only the // provider's own offered argument is read -- never a guess, a persisted value, // or a stale transition. Full object ids paired with committed-only may name // commits or trees; only provider trees enter the separate frozen tree-base // path. Caller-supplied tree selectors remain invalid. // The worktree anchor is always a commit, but a provider-offered range can // have a tree selector. Transport and lineage replay must use the latter. function nativeCommittedRangeSelector(target: { baseCommit: string; providerBaseTree?: string }): string { return target.providerBaseTree ?? target.baseCommit; } const OFFERED_COMMITTED_RANGE_BASE_REF = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; function offeredCommittedRangeBaseRef(target: ReviewStatusV3): string | undefined { const execute = target.nextTransition?.kind === "execute" ? target.nextTransition.execute : undefined; if (execute?.operation !== "review.start") return undefined; const selectors = execute.arguments.filter((argument) => argument.name === "base-ref" || argument.name === "committed-only"); if (selectors.length !== 2 || new Set(selectors.map((argument) => argument.name)).size !== 2) return undefined; if (selectors.some((argument) => argument.token !== undefined && argument.token !== `--${argument.name}=${argument.value}`)) return undefined; const baseRef = selectors.find((argument) => argument.name === "base-ref")!.value; if (!OFFERED_COMMITTED_RANGE_BASE_REF.test(baseRef)) return undefined; if (selectors.find((argument) => argument.name === "committed-only")!.value !== "true") return undefined; return baseRef; } function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void { candidateView.verify(); if ( target.projection.projection !== "workspace" || target.projection.baseTree !== candidateView.baseTree || target.projection.initialReviewTree !== candidateView.candidateTree || target.projection.currentCandidateTree !== candidateView.candidateTree || JSON.stringify([...target.projection.paths].sort()) !== JSON.stringify([...candidateView.paths].sort()) || (candidateView.intendedUntracked !== undefined && JSON.stringify([...target.projection.intendedUntracked].sort()) !== JSON.stringify([...candidateView.intendedUntracked].sort())) ) { throw new CandidateViewError("native START workspace target does not match the immutable reviewer candidate view", "candidate-target-projection-drift"); } } function completeNativeStart( operation: ReviewControllerOperation, result: NativeStartResult, workspaceRoot: string, candidateView: CandidateView | undefined, candidateViews: CandidateViewRegistry | null, ): Record { if (candidateView === undefined) return { operation, result: mapNativeStartResult(result), workspace_root: workspaceRoot }; if (candidateViews && result.lensesRequired) { const binding = { token: candidateView.token, lineageId: result.lineageId, selectedLenses: result.selectedLenses }; if (result.action === "resumed" && !candidateViews.hasCurrentBinding(candidateView.contributorRoot)) candidateViews.restoreCurrentFromNativeStart(binding); else candidateViews.bindCurrent(binding); } else if (candidateViews && ((result.action === "created" && result.state === "reviewing") || result.action === "resumed")) candidateViews.retain(candidateView.token, result.lineageId); else candidateViews?.cleanup(candidateView.token); const actorBinding = result.lensesRequired ? { workspace_root: workspaceRoot, candidate_root: candidateView.root, candidate_tree: candidateView.candidateTree, candidate_paths: candidateView.paths, } : undefined; return { operation, result: mapNativeStartResult(result), workspace_root: workspaceRoot, ...(actorBinding === undefined ? {} : { actor_binding: actorBinding }), }; } // gentle-ai#4003: every Pi-side teardown step that fails after the native // burn is deferred cleanup, not a failed acknowledgement. Only the // already-sanitized CandidateViewError surface is relayed; anything else is // reduced to the step's fixed code so no path or command text reaches the // caller. The candidate-view hint is out-of-band on purpose: no controller // operation exposes a cleanup-only retry, and replaying acknowledge-approved // would hit the already-burned lineage. const POST_BURN_CLEANUP = { candidateView: { code: "candidate-view-cleanup-failed", nextAction: "retry-candidate-view-cleanup-or-remove-the-view-out-of-band" }, retainedSelection: { code: "retained-selection-cleanup-failed", nextAction: "retained-selection-clears-on-the-next-terminal-status" }, } as const; function deferredPostBurnCleanup(step: (typeof POST_BURN_CLEANUP)[keyof typeof POST_BURN_CLEANUP], cleanup: () => void): Record | undefined { try { cleanup(); return undefined; } catch (error) { return { status: "deferred", diagnostics: error instanceof CandidateViewError ? { code: error.reason, message: error.message } : { code: step.code }, next_action: step.nextAction, }; } } function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record { const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown; code?: unknown; continuation?: { command?: unknown } } }; if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) { const mutationOutcome = value.failureEnvelope.mutationOutcome; return { operation, status: "blocked", native_failure: value.failureEnvelope.raw, ...(mutationOutcome === "committed" ? { mutation_performed: true, mutation_outcome: "committed" } : mutationOutcome === "unknown" ? { mutation_outcome: "unknown" } : { mutation_performed: false, mutation_outcome: "none" }), ...(typeof value.failureEnvelope.replayability === "string" ? { replayability: value.failureEnvelope.replayability } : {}), ...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}), // gentle-pi#627: START's preflight failure envelope for a stale // managed-asset set carries a top-level continuation; render its // `gentle-ai sync` command as the one actionable next step. ...(value.failureEnvelope.code === "managed_assets_outdated" && typeof value.failureEnvelope.continuation?.command === "string" ? { hint: `run ${value.failureEnvelope.continuation.command}` } : {}), }; } // Every consent binding guard runs before the provider is launched, so this // is a local mismatch with nothing to reconcile. Reporting it as a native // operation failure hides the one fact that makes it fixable. const consentBinding = asNativeReviewConsentBindingError(error); if (consentBinding !== undefined) { return { operation, status: "blocked", outcome: "consent-binding-invalid", native_invocation_attempted: false, lineage_created: false, mutation_performed: false, mutation_outcome: "none" as const, diagnostics: { code: consentBinding.reason, message: consentBinding.message }, next_action: "resolve-consent-binding", }; } if (error instanceof NativeCaptureRouteRegistrationError) { return { operation, status: "blocked", outcome: "capture-route-registration-rejected", mutation_performed: false, mutation_outcome: "none", }; } const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none"; const nativeCliError = asNativeReviewCliError(error); if (nativeCliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, nativeCliError.diagnostics); const nativeDiagnostics = nativeCliError?.diagnostics; // Target-status probes use the adapter's review/status diagnostic domain. // Preserve that sanitized diagnostic on every controller route rather than // relabeling an actionable failure as an opaque controller failure. const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS; const preservesAnswerConsentStartDiagnostic = operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT && nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.START; const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true ? error.diagnostics ?? { code: error.reason, message: "candidate view rejected before native START" } : error instanceof CandidateViewError ? { code: error.reason, message: error.message } : nativeDiagnostics?.operation === `review/${operation}` || preservesNativeTargetStatusDiagnostic || preservesAnswerConsentStartDiagnostic ? nativeDiagnostics : undefined; return { operation, status: "blocked", outcome: "native-operation-failed", ...(operation === REVIEW_CONTROLLER_OPERATION.START && mutationOutcome === "none" ? nativeStartPreAuthorityRejection() : mutationOutcome === "none" ? { lineage_created: false, mutation_performed: false, mutation_outcome: "none" as const } : { mutation_outcome: mutationOutcome }), ...(diagnostics === undefined ? {} : { diagnostics }), ...(isRecord(value.auditRecord) ? { native_audit_record: value.auditRecord } : {}), ...(mutationOutcome === "unknown" || value.nextAction === "review.status" ? { replayability: "status_required", next_action: "review.status", required_status_action: requiredStatusActionText() } : { next_action: "resolve-native-operation-failure" }), }; } function nativeMutationRequiresStatus(error: unknown): boolean { const value = error as { mutationOutcome?: unknown; nextAction?: unknown; failureEnvelope?: { mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown }; }; return value.mutationOutcome === "unknown" || value.nextAction === "review.status" || value.failureEnvelope?.mutationOutcome === "unknown" || value.failureEnvelope?.replayability === "status_required" || value.failureEnvelope?.nextAction === "review.status"; } async function reconcileNativeMutationFailure( operation: ReviewControllerOperation, error: unknown, nativeReviewCli: NativeReviewCli, target: Parameters>[0], retainedSelections: Map, preOperationRevision?: string, canonicalRetentionRoot = target.cwd, ): Promise> { const failure = nativeOperationFailure(operation, error); if (!nativeMutationRequiresStatus(error)) return failure; if (nativeReviewCli.targetStatus === undefined) { return { ...failure, outcome: "native-mutation-status-required", replayability: "status_required", next_action: "review.status", required_status_action: requiredStatusActionText(target.lineageId), }; } try { const status = await nativeReviewCli.targetStatus(target); syncRetainedNativeStatusSelections(retainedSelections, canonicalRetentionRoot, status, target.baseRef); const projectedStatus = mapNativeTargetStatus(operation, status, target.lineageId); const { next_action: staleNextAction, required_status_action: staleStatusDirective, ...reconciledBase } = failure; void staleNextAction; void staleStatusDirective; // Field defect (fambig, 2026-08-16): an envelope-less mutating failure // is stamped mutationOutcome "unknown", but a reconciled authority // revision identical to the pre-operation revision PROVES the failed // call never mutated. Report that proof as mutation_outcome none and // claim no replay prohibition for it. Every genuinely ambiguous result // — revision moved, no pre-operation revision held, or STATUS // unavailable — stays fail-closed exactly as before. if (preOperationRevision !== undefined && status.authority?.revision === preOperationRevision) { const { replayability: staleReplayability, ...provenBase } = reconciledBase; void staleReplayability; return { ...provenBase, ...projectedStatus, status: "blocked", outcome: "native-mutation-status-reconciled", reconciliation: status.raw, authority_applicability: status.applicability, provider_action: status.action, mutation_performed: false, mutation_outcome: "none", mutation_outcome_reason: `authority revision unchanged across reconciliation (${preOperationRevision}); the failed operation provably did not mutate`, }; } return { ...reconciledBase, ...projectedStatus, status: "blocked", outcome: "native-mutation-status-reconciled", reconciliation: status.raw, authority_applicability: status.applicability, provider_action: status.action, replayability: status.replayability, ...(status.action === "start" && projectedStatus.next_action === undefined ? { next_action: "start" } : {}), required_status_action: projectedStatus.required_status_action ?? requiredStatusActionText(target.lineageId), }; } catch (statusError) { return { ...failure, outcome: "native-mutation-status-reconciliation-failed", reconciliation_failure: nativeOperationFailure(REVIEW_CONTROLLER_OPERATION.STATUS, statusError), replayability: "status_required", next_action: "review.status", required_status_action: requiredStatusActionText(target.lineageId), }; } } function reviewWorkspaceGitIdentity(cwd: string): { toplevel: string; commonDir: string } { // Discovery must describe the selected directory, never an ambient Git // override pointing at a different repository or worktree. const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); const git = (...arguments_: string[]): string => execFileSync("git", arguments_, { cwd, env, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).trim(); const toplevel = realpathSync(git("rev-parse", "--show-toplevel")); const commonDir = realpathSync(resolve(cwd, git("rev-parse", "--git-common-dir"))); return { toplevel, commonDir }; } /** * Resolves the explicit user-authorized workspace target. An explicit path may * be nested and may belong to a repository unrelated to the Pi session cwd; * Git resolves it to its canonical worktree top-level. Only unbound inspect or * ordinary START may retain an existing unresolved directory for a later strict * effective-on gate; native, not this resolver, owns preparation and safeguards. * The session cwd is the default when no target was selected or remembered. */ function resolveReviewControllerWorkspaceRoot( requested: string | undefined, sessionCwd: string, candidateViews: CandidateViewRegistry | null, lineageId: string | undefined, allowNativePreparation = false, resolution?: { identity?: { toplevel: string; commonDir: string } }, ): string { const remembered = requested === undefined && lineageId !== undefined ? candidateViews?.resolveWorkspaceRoot(lineageId) : undefined; const selected = requested ?? remembered ?? sessionCwd; if (selected.trim().length === 0 || !isAbsolute(selected)) { throw new Error(`Review controller workspaceRoot must be an absolute path to an existing Git worktree root; received ${JSON.stringify(selected)}`); } let resolved: string; try { resolved = realpathSync(selected); if (!lstatSync(resolved).isDirectory()) throw new Error("not a directory"); } catch { throw new Error(`Review controller workspaceRoot ${selected} is not an existing directory; create or adopt the worktree before binding review operations to it`); } let target: { toplevel: string; commonDir: string }; try { target = reviewWorkspaceGitIdentity(resolved); } catch { if (allowNativePreparation && lineageId === undefined && safeBootstrapDirectory(resolved) === resolved) return resolved; throw new Error(`Review controller workspaceRoot ${resolved} is not inside a Git worktree; review operations bind only to real worktrees of the session repository`); } if (lineageId !== undefined) candidateViews?.assertWorkspaceRoot(lineageId, target.toplevel); if (resolution) resolution.identity = target; return target.toplevel; } function reviewLifecycleStorageKey(workspaceRoot: string, lineageId: string): string { return `${workspaceRoot}\u0000${lineageId}`; } function reviewCaptureSelectionStorageKey(collectBinding: string): string { return `capture\u0000${collectBinding}`; } function cloneRetainedNativeUntrackedSelection(selection: NativeStartUntrackedSelection): RetainedNativeUntrackedSelection | undefined { if (selection.untrackedScope === undefined || selection.expectedUntrackedInventory === undefined) return undefined; return Object.freeze({ untrackedScope: selection.untrackedScope, expectedUntrackedInventory: selection.expectedUntrackedInventory, intendedUntracked: Object.freeze([...(selection.intendedUntracked ?? [])]), }); } function retainNativeStatusSelection(selections: Map, key: string, selection: RetainedNativeStatusSelection): void { if (!selections.has(key)) { while (selections.size >= MAX_RETAINED_NATIVE_STATUS_SELECTIONS) { const oldestKey = selections.keys().next().value; if (oldestKey === undefined) return; selections.delete(oldestKey); } } selections.set(key, selection); } function retainNativeUntrackedSelection(selections: Map, workspaceRoot: string, lineageId: string, selection: RetainedNativeUntrackedSelection | undefined): void { if (selection !== undefined) retainNativeStatusSelection(selections, reviewLifecycleStorageKey(workspaceRoot, lineageId), selection); } function readRetainedNativeUntrackedSelection(selections: Map, workspaceRoot: string, lineageId: string): NativeStartUntrackedSelection { const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, lineageId)); return selection === undefined || !("untrackedScope" in selection) || "targetIdentity" in selection ? {} : { untrackedScope: selection.untrackedScope, expectedUntrackedInventory: selection.expectedUntrackedInventory, intendedUntracked: [...selection.intendedUntracked], }; } // gentle-pi#706: inspect's untrackedScope round trip retains the resolved // selection under the pre-lineage empty-lineage key so the next plain START in // that worktree adopts it without re-deriving the selection. function nativePreLineageCandidateIdentity( status: ReviewStatusV3, ): { targetIdentity: string; candidateTree: string } | undefined { const candidateTree = status.projection.currentCandidateTree; return isCanonicalProcessString(status.targetIdentity) && isCanonicalProcessString(candidateTree) ? { targetIdentity: status.targetIdentity, candidateTree } : undefined; } function readRetainedPreLineageNativeUntrackedSelection( selections: Map, workspaceRoot: string, ): RetainedPreLineageNativeUntrackedSelection | undefined { const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, "")); // gentle-pi#1192: this entry may now carry its own optional baseRef, so the // discriminant against RetainedNativeCaptureRoute (a distinct key namespace // that always carries workspaceRoot/lineageId) can no longer be "no baseRef". return selection !== undefined && !isRetainedNativeCaptureRoute(selection) && !("selectionBinding" in selection) && typeof (selection as Partial).targetIdentity === "string" && typeof (selection as Partial).candidateTree === "string" ? selection as RetainedPreLineageNativeUntrackedSelection : undefined; } function sameNativePreLineageCandidate( selection: RetainedPreLineageNativeUntrackedSelection, status: ReviewStatusV3, ): boolean { const identity = nativePreLineageCandidateIdentity(status); return identity !== undefined && identity.targetIdentity === selection.targetIdentity && identity.candidateTree === selection.candidateTree; } function isRetainedNativeCaptureRoute(selection: RetainedNativeStatusSelection | undefined): selection is RetainedNativeCaptureRoute { return selection !== undefined && "workspaceRoot" in selection; } function retainNativeCaptureRoutes(selections: Map, workspaceRoot: string, status: ReviewStatusV3, baseRef: string | undefined): void { const lineageId = status.authority?.lineageId; if (!canOfferNativeCaptureBindings(status) || !isCanonicalProcessString(lineageId)) return; const routes = (status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : []).map((input) => ({ key: reviewCaptureSelectionStorageKey(canonicalReviewCaptureBinding(input)), route: Object.freeze({ workspaceRoot, lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true as const }) }) })); for (const { key, route } of routes) { const existing = selections.get(key); if (isRetainedNativeCaptureRoute(existing) && (existing.workspaceRoot !== route.workspaceRoot || existing.lineageId !== route.lineageId || existing.baseRef !== route.baseRef || existing.committedOnly !== route.committedOnly)) throw new NativeCaptureRouteRegistrationError("Provider collectBinding collides with a different registered route"); } const current = new Set(routes.map(({ key }) => key)); for (const [key, selection] of selections) if (isRetainedNativeCaptureRoute(selection) && selection.workspaceRoot === workspaceRoot && selection.lineageId === lineageId && !current.has(key)) selections.delete(key); for (const { key, route } of routes) if (!selections.has(key)) retainNativeStatusSelection(selections, key, route); } function readRetainedNativeCaptureRoute(selections: Map, collectBinding: string): RetainedNativeCaptureRoute | undefined { const selection = selections.get(reviewCaptureSelectionStorageKey(collectBinding)); return isRetainedNativeCaptureRoute(selection) ? selection : undefined; } function trustedNativeCaptureBaseRef(route: RetainedNativeCaptureRoute | undefined, candidateViews: CandidateViewRegistry | null, cwd: string, lineageId: string): string | undefined { if (route !== undefined) return route.baseRef; const target = candidateViews?.hasProjection(lineageId, cwd) ? candidateViews.resolveProjection(lineageId, cwd) : undefined; return target?.committedOnly === true ? nativeCommittedRangeSelector(target) : undefined; } function isTerminalReviewAuthorityState(state: string | undefined): boolean { return state === "invalidated" || state === "approved" || state === "escalated"; } function clearRetainedNativeUntrackedSelection(selections: Map, workspaceRoot: string, lineageId: string): void { selections.delete(reviewLifecycleStorageKey(workspaceRoot, lineageId)); } function clearRetainedNativeStatusSelectionsOnTerminal(selections: Map, workspaceRoot: string, lineageId: string | undefined, state: string | undefined): void { if (lineageId === undefined || !isTerminalReviewAuthorityState(state)) return; if (state !== "approved") clearRetainedNativeUntrackedSelection(selections, workspaceRoot, lineageId); for (const [key, selection] of selections) if (isRetainedNativeCaptureRoute(selection) && selection.workspaceRoot === workspaceRoot && selection.lineageId === lineageId) selections.delete(key); } function syncRetainedNativeStatusSelections(selections: Map, workspaceRoot: string, status: ReviewStatusV3, baseRef: string | undefined): void { clearRetainedNativeStatusSelectionsOnTerminal(selections, workspaceRoot, status.authority?.lineageId, status.authority?.state); retainNativeCaptureRoutes(selections, workspaceRoot, status, baseRef); } function requiresExplicitTargetLifecycleRoot(requested: string | undefined, sessionCwd: string, workspaceRoot: string): boolean { return requested !== undefined || workspaceRoot !== sessionCwd; } // gentle-pi#311 P4 — the thin Pi host relay. The provider decides which // capture slots the host satisfies by issuing the --materialize token on a // pi-bound `review.capture-result` collect input; nothing is ever inferred. // The runner is injectable for tests only; production always uses the real // relay in lib/review-host-relay.ts. let activeReviewHostRelayRunner: ReviewHostRelayRunner = runReviewHostRelaySlot; let activeReviewHostRelayReviewerGroupRunner = runReviewHostRelayReviewerGroup; let activeReviewHostRelaySubmissionRunner = submitReviewHostRelayPreparedResult; function setReviewHostRelayRunnerForTesting(runner?: ReviewHostRelayRunner): void { activeReviewHostRelayRunner = runner ?? runReviewHostRelaySlot; } function setReviewHostRelayGroupRunnersForTesting(reviewerGroup?: typeof runReviewHostRelayReviewerGroup, submission?: typeof submitReviewHostRelayPreparedResult): void { activeReviewHostRelayReviewerGroupRunner = reviewerGroup ?? runReviewHostRelayReviewerGroup; activeReviewHostRelaySubmissionRunner = submission ?? submitReviewHostRelayPreparedResult; } const REVIEW_HOST_RELAY_RETRY_ACTION = "Call fresh STATUS and submit only an exact reoffered one-slot binding; never replay this capture from transcript inference."; // gentle-pi#522 / #524: gentle-ai refused the submission at admission and // stated that the lens slot was not consumed. The refused bytes are the // problem, so the continuation is a fresh reviewer run on the reoffered slot, // not a replay and not an unknown-outcome reconciliation. const REVIEW_HOST_RELAY_REFUSED_ACTION = "gentle-ai refused this submission at admission and did not consume the lens slot; the reason is in failure.stderr. " + "Call fresh STATUS and run only the exact slot it reoffers so the reviewer produces a new result that satisfies that refusal; never resubmit the refused bytes."; // gentle-pi#638: the declaration recorded, so fresh STATUS stops the review with one withdraw binding per declared slot instead of reoffering the reviewer. The withdraw command is the only way back to the same slot; everything else needs a smaller candidate and a new review. const REVIEW_HOST_RELAY_UNACHIEVABLE_ACTION = "A deterministic relay failure was declared unachievable for this bound slot, and fresh STATUS now stops this review instead of reoffering the reviewer. " + "If the failure was transient, run the withdraw command in unachievable_lens_slots with the same binding so the review re-offers this exact reviewer; otherwise reduce the candidate scope and start a new review."; // gentle-pi#638: the relay failure was deterministic but gentle-ai refused the declaration, so no slot state changed and the review still needs a provider-bound continuation. const REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION = "The relay failure was deterministic for this slot, but gentle-ai refused the unachievable declaration, so no slot state changed. " + "Call fresh STATUS and follow only its declared action; never replay this capture from transcript inference."; function reviewHostRelayTimeoutNextAction(error: ReviewHostRelayError): string { const measured = error.elapsedMs === null || error.timeoutMs === null ? "" : ` The reviewer was killed after ${error.elapsedMs}ms against a ${error.timeoutMs}ms bound.`; return `Do not relaunch this slot unchanged: the same bound kills the same reviewer run again and re-spends the model tokens for nothing.${measured} ` + `Change one of two things first: export ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}= above the reviewer's real wall time (hard ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}), or reduce the candidate scope so the materialized prompt is smaller. ` + "Then call fresh STATUS and submit only its exact reoffered slot."; } function reviewHostRelayFailureReport(error: ReviewHostRelayError): Record { return { kind: error.kind, stage: error.stage, exit_code: error.exitCode, timed_out: error.timedOut, ...(error.elapsedMs === null ? {} : { elapsed_ms: error.elapsedMs }), ...(error.timeoutMs === null ? {} : { timeout_ms: error.timeoutMs }), // The captured native stderr is the only place the provider's exact // refusal reason lives (gentle-pi#524); dropping it hid every admission // refusal behind "submission-refused". ...(error.stderr.length === 0 ? {} : { stderr: error.stderr }), // gentle-shell#1156: what the reviewer child's own event stream revealed. ...(error.reviewerEvidence === undefined ? {} : { reviewer: error.reviewerEvidence }), }; } // gentle-pi#311 P2 (superseding gentle-shell#1136 / #1158): the lens's // completion selection comes from its entry in the agent model routing // config, keyed by its routing key (`review-`). There is no extension // allowlist and no ambient default model: the in-process completion resolves // its provider through the live model registry the caller supplies, or it is // refused before materialize ever runs (validateReviewerSelectionConfiguration // in lib/review-host-relay.ts). // // gentle-pi#2271: review-refuter and review-validator are provider review // roles, not installable agents, so nothing seeds their entries and profiles // never carry them. For those two keys only, a snapshot with no model falls // back to Pi's persisted default model and effort, the default the routing // panel already labels them with. Lens keys keep the fail-closed rule. function reviewHostRelaySelection(lens: string | undefined, config: AgentModelConfig): { selection?: string; thinking?: string; routingKey: string } { const routingKey = lens === undefined || lens.length === 0 ? "review capture" : lens.startsWith("review-") ? lens : `review-${lens}`; const entry = config[routingKey]; let model = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).model === "string" && (entry as AgentRoutingEntry).model!.length > 0 ? (entry as AgentRoutingEntry).model : undefined; let thinking = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).thinking === "string" ? (entry as AgentRoutingEntry).thinking : undefined; if (model === undefined && isProviderReviewRole(routingKey)) { const persisted = readOrchestratorSettings(orchestratorSettingsPath()); if (persisted.status === "valid" && persisted.entry?.model !== undefined) { model = persisted.entry.model; thinking ??= persisted.entry.thinking; } } return { ...(model === undefined ? {} : { selection: model }), ...(thinking === undefined ? {} : { thinking }), routingKey, }; } function mapLastEventClosure( closure: ReviewLastEventClosureV1, binding: ReviewLastEventClosureBinding, workspaceRoot?: string, implicitWorkspaceRoot: string = process.cwd(), ): Record { if (closure.lineageId !== binding.lineageId) throw new CandidateViewError("last-event closure returned a different lineage", "last-event-closure-binding-drift"); if (binding.targetIdentity !== undefined && closure.targetIdentity !== undefined && closure.targetIdentity !== binding.targetIdentity) { throw new CandidateViewError("last-event closure returned a different target", "last-event-closure-binding-drift"); } const nextAction = closure.acknowledgement !== undefined ? `gentle_review {"operation":"acknowledge-approved","lineageId":"${closure.lineageId}"${workspaceRoot && (workspaceRoot !== implicitWorkspaceRoot || workspaceRoot !== process.cwd()) ? `,"workspaceRoot":${JSON.stringify(workspaceRoot)}` : ""}}` : undefined; return { tool: "gentle_review_capture", status: "closed", outcome: "native-last-event-closure", closure: { schema: closure.schema, operation: closure.operation, lineage_id: closure.lineageId, state: closure.state, store_revision: closure.storeRevision, ...(closure.action === undefined ? {} : { action: closure.action }), ...(closure.targetIdentity === undefined ? {} : { target_identity: closure.targetIdentity }), ...(closure.requestHash === undefined ? {} : { request_hash: closure.requestHash }), ...(closure.correctionLines === undefined ? {} : { correction_lines: closure.correctionLines }), ...(closure.advisoryFindings === undefined ? {} : { advisory_findings: closure.advisoryFindings }), ...(closure.escalation === undefined ? {} : { escalation: { cause: closure.escalation.cause, finding_ids: closure.escalation.findingIds, ...(closure.escalation.refuterOutcomes === undefined ? {} : { refuter_outcomes: closure.escalation.refuterOutcomes.map(({ findingId, ...outcome }) => ({ finding_id: findingId, ...outcome })) }), } }), ...(closure.targetedValidatorEvidence === undefined ? {} : { targeted_validator_evidence: closure.targetedValidatorEvidence.raw }), ...(closure.statusContinuation === undefined ? {} : { status_continuation: closure.statusContinuation.raw }), // The host has to see the acknowledgement to run it: approval now // waits for that exact invocation instead of burning on its own, so // dropping it here would strand the lineage as approved forever. ...(closure.acknowledgement === undefined ? {} : { acknowledgement: closure.acknowledgement.raw }), // A present-but-unreadable continuation is not the same as none: the // host is approved and cannot end it here, and silence would read as // nothing left to do. ...(closure.acknowledgementUndecodable === undefined ? {} : { acknowledgement_undecodable: true }), ...(nextAction === undefined ? {} : { next_action: nextAction }), }, lineage_id: closure.lineageId, state: closure.state, store_revision: closure.storeRevision, ...(nextAction === undefined ? {} : { next_action: nextAction }), }; } function mapAndClearLastEventClosure( closure: ReviewLastEventClosureV1, binding: ReviewLastEventClosureBinding, selections: Map, workspaceRoot: string, implicitWorkspaceRoot?: string, ): Record { const mapped = mapLastEventClosure(closure, binding, workspaceRoot, implicitWorkspaceRoot); clearRetainedNativeStatusSelectionsOnTerminal(selections, workspaceRoot, closure.lineageId, closure.state); return mapped; } function decodeRelayLastEventClosure(submission: string): ReviewLastEventClosureV1 | undefined { let body: unknown; try { body = JSON.parse(submission); } catch { throw new CandidateViewError("host relay submission returned malformed JSON", "last-event-closure-decode-failed"); } if (typeof body !== "object" || body === null || Array.isArray(body) || (body as { schema?: unknown }).schema !== "gentle-ai.review-last-event-closure/v1") return undefined; return decodeReviewLastEventClosureV1(body); } async function reconcileUnknownReviewCaptureFailure( error: unknown | undefined, nativeReviewCli: NativeReviewCli, cwd: string, binding: ReviewLastEventClosureBinding, selections: Map, route: RetainedNativeCaptureRoute | undefined, expectedReviewCaptureSuffix?: readonly string[], agent?: "pi", ): Promise> { const failure = error === undefined ? undefined : nativeOperationFailure("gentle_review_capture", error); if (error !== undefined && !nativeMutationRequiresStatus(error)) return failure; try { const selector = agent === undefined ? route : { ...route, agent }; const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, selector); syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef); if (expectedReviewCaptureSuffix !== undefined && !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix)) return captureGroupAuthorityDrift(status); return { tool: "gentle_review_capture", status: "reconciled", outcome: "native-capture-outcome-unknown", ...(failure === undefined ? {} : { native_failure: failure }), lineage_id: binding.lineageId, target_identity: status.targetIdentity, provider_action: status.action, ...(status.nextTransition === undefined ? {} : { next_transition: status.nextTransition }), result: status.raw, }; } catch (statusError) { const reconciliationFailure = nativeOperationFailure("gentle_review_capture", statusError); return { ...(failure ?? reconciliationFailure), outcome: "native-capture-status-reconciliation-failed", reconciliation_failure: reconciliationFailure }; } } async function executeReviewHostRelayCapture( slot: ReviewHostRelaySlot, nativeReviewCli: NativeReviewCli, cwd: string, binding: ReviewLastEventClosureBinding, selections: Map, route: RetainedNativeCaptureRoute | undefined, signal?: AbortSignal, modelRegistry?: InProcessReviewerRegistry, // The caller's live session id: forwarded into the relay request so an // OpenCode-routed reviewer completion carries its x-opencode-session // attribution header. Appended last so every existing positional call site // keeps compiling unchanged. reviewerSessionId?: string, implicitWorkspaceRoot?: string, getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"], ): Promise> { try { if (slot.submission === undefined) { throw new ReviewHostRelayError( REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, ); } const reviewerTransport = getReviewerTransport?.(); const result = await activeReviewHostRelayRunner((() => { // gentle-pi#311 P2 / P3: the lens's (or, for a v9 host-mediated role // slot, the fixed review-refuter/review-validator routing key's) // user-owned completion selection rides the request alongside the // live model registry; the relay validates and refuses a missing // registry or a routing entry with no configured model typed before // anything launches, never a fallback to a child. // gentle-shell#1544: a repository profile pin re-anchors the whole reviewer // routing, so the relay resolves the pinned profile wholesale before the // global routing. A pin that omits a required role stays omitted: the // typed reviewer-config-invalid refusal below is fail-closed, never a // silent per-role fallback to another account's routing. const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, readReviewerModelConfig(cwd, reviewerSessionId)); return { captureArgumentTokens: slot.captureArgumentTokens, targetCwd: cwd, submission: slot.submission, ...launch, ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }), ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }), ...(reviewerTransport === undefined ? {} : { reviewerTransport }), ...(signal === undefined ? {} : { signal }), }; })()); const closure = decodeRelayLastEventClosure(result.submission); if (closure !== undefined) return mapAndClearLastEventClosure(closure, binding, selections, cwd, implicitWorkspaceRoot); return { tool: "gentle_review_capture", status: "captured", outcome: "native-reviewer-result-captured", lineage_id: binding.lineageId, host_relay: { transport: "pi_host_relay", ...(slot.lens === undefined ? {} : { lens: slot.lens }), ...(slot.order === undefined ? {} : { order: slot.order }), ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }), ...(slot.routingKey === undefined ? {} : { role: slot.name }), prompt_bytes: result.promptByteLength, result_bytes: result.resultByteLength, submission: result.submission, }, }; } catch (error) { if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT); if (error.mutationOutcome === "unknown") { return { ...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT)), failure: reviewHostRelayFailureReport(error), reason: error.message, }; } if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE) { return { tool: "gentle_review_capture", status: "blocked", outcome: "pi-host-relay-unavailable", reason: REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, mutation_performed: false, mutation_outcome: "none", }; } if (error.kind === REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED) { return { tool: "gentle_review_capture", status: "blocked", outcome: "pi-host-relay-handshake-refused", reason: error.message, refusal: error.stderr, mutation_performed: false, mutation_outcome: "none", }; } // gentle-pi#638: the two deterministic relay failure classes end the slot, not the transport. Declaring the slot unachievable through the native verb records the provider-owned fact that this reviewer cannot complete under current conditions, then exactly one bound STATUS re-query renders the typed stop with its withdraw binding instead of reoffering the same slot. The declaration binding is re-derived from the slot's own provider-issued `--name=value` tokens, never from transcript state. const unachievableReason = reviewHostRelayUnachievableReason(error); const declarationBinding = unachievableSlotDeclarationBinding(slot); if (unachievableReason !== undefined && declarationBinding !== undefined && nativeReviewCli.captureUnachievableLens !== undefined) { let declared: NativeReviewUnachievableLensCaptureArtifact | undefined; try { declared = await nativeReviewCli.captureUnachievableLens({ cwd, ...declarationBinding, reason: unachievableReason, ...(reviewHostRelayUnachievableDetail(error) === undefined ? {} : { detail: reviewHostRelayUnachievableDetail(error)! }), ...(signal === undefined ? {} : { signal }) }); } catch (declarationError) { // Fail open only on the unknown-verb capability refusal: an older binary without `capture-unachievable` keeps today's transport-failure behavior below. Every other declaration failure is surfaced, never hidden behind the relay failure it followed. if (!isNativeReviewUnachievableVerbRefused(declarationError)) { // gentle-pi#822 (outside-diff): the declaration failure's own envelope carries the mutation truth — the process may have recorded the declaration before failing — so the mutation fields are derived from it instead of hardcoded none, and an unknown outcome is proven or disproven by one bound STATUS re-query without ever changing the failure outcome. const declarationFailureReport = nativeOperationFailure("gentle_review_capture", declarationError); let declarationMutationPerformed = declarationFailureReport.mutation_performed === true; let declarationMutationOutcome: "none" | "unknown" | "committed" = declarationFailureReport.mutation_outcome === "committed" ? "committed" : declarationFailureReport.mutation_outcome === "unknown" ? "unknown" : "none"; if (declarationMutationOutcome === "unknown") { try { const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route === undefined ? { agent: REVIEW_HOST_AGENT } : { ...route, agent: REVIEW_HOST_AGENT }); syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef); const stop = status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" ? status.nextTransition : undefined; const declaredSlot = stop?.unachievableLensSlots?.find((candidate) => candidate.lens === slot.lens && String(candidate.selectedOrder) === slot.order && candidate.subjectHash === declarationBinding.requestHash && candidate.withdraw.binding.targetIdentity === declarationBinding.targetIdentity && candidate.withdraw.binding.lineageId === declarationBinding.lineageId && candidate.withdraw.binding.revision === declarationBinding.expectedRevision); if (stop !== undefined && declaredSlot !== undefined) { declarationMutationPerformed = true; declarationMutationOutcome = "committed"; } } catch { // Without proof the mutation stays unknown; the declaration failure is already the reported outcome. } } return { tool: "gentle_review_capture", status: "blocked", outcome: "unachievable-lens-declaration-failed", reason: error.message, failure: reviewHostRelayFailureReport(error), declaration_failure: declarationFailureReport, mutation_performed: declarationMutationPerformed, mutation_outcome: declarationMutationOutcome, next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION, }; } } if (declared !== undefined) { const declaration = { lens: declared.lens, selected_order: declared.selectedOrder, subject_hash: declarationBinding.requestHash, reason: declared.reason }; try { const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, route === undefined ? { agent: REVIEW_HOST_AGENT } : { ...route, agent: REVIEW_HOST_AGENT }); syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef); const stop = status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "unachievable_lens_slot" ? status.nextTransition : undefined; // gentle-pi#822: the stop may also carry slots declared by other runs, so expose only the entry matching the identity this session just declared. A stop with slots but no matching entry is a reconciliation failure, never a success rendering someone else's withdraw command. const declaredSlot = stop?.unachievableLensSlots?.find((slot) => slot.lens === declaration.lens && slot.selectedOrder === declaration.selected_order && slot.subjectHash === declaration.subject_hash && slot.withdraw.binding.targetIdentity === declarationBinding.targetIdentity && slot.withdraw.binding.lineageId === declarationBinding.lineageId && slot.withdraw.binding.revision === declarationBinding.expectedRevision); // gentle-pi#822: success is proven, never assumed — a STATUS with no unachievable_lens_slot stop at all (no transition, a different reason code, or a collect reoffer) is a reconciliation failure exactly like a stop whose entries do not match the declared identity. if (declaredSlot === undefined) { return { tool: "gentle_review_capture", status: "blocked", outcome: "unachievable-lens-declaration-reconciliation-failed", reason: error.message, failure: reviewHostRelayFailureReport(error), declaration, reconciliation_failure: { operation: "gentle_review_capture", status: "blocked", outcome: "unachievable-lens-slot-declaration-unmatched", reason: stop === undefined ? "the bound STATUS did not return the unachievable_lens_slot stop" : "no unachievable_lens_slots entry matches the declared slot identity", declared_slot: { lens: declaration.lens, selected_order: declaration.selected_order, subject_hash: declaration.subject_hash }, mutation_performed: true, mutation_outcome: "committed" }, mutation_performed: true, mutation_outcome: "committed", next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION, }; } return { tool: "gentle_review_capture", status: "blocked", outcome: "unachievable-lens-slot-declared", reason: error.message, failure: reviewHostRelayFailureReport(error), declaration, provider_action: status.action, ...(status.nextTransition === undefined ? {} : { next_transition: status.nextTransition }), unachievable_lens_slots: [{ lens: declaredSlot.lens, selected_order: declaredSlot.selectedOrder, subject_hash: declaredSlot.subjectHash, reason: declaredSlot.reason, ...(declaredSlot.detail === undefined ? {} : { detail: declaredSlot.detail }), withdraw: declaredSlot.withdraw.command }], result: status.raw, next_action: REVIEW_HOST_RELAY_UNACHIEVABLE_ACTION, mutation_performed: true, mutation_outcome: "committed", }; } catch (statusError) { return { tool: "gentle_review_capture", status: "blocked", outcome: "unachievable-lens-declaration-reconciliation-failed", reason: error.message, failure: reviewHostRelayFailureReport(error), declaration, reconciliation_failure: nativeOperationFailure("gentle_review_capture", statusError), mutation_performed: true, mutation_outcome: "committed", next_action: REVIEW_HOST_RELAY_DECLARATION_FAILED_ACTION, }; } } } return { tool: "gentle_review_capture", status: "blocked", outcome: error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT ? "pi-host-relay-timeout" : "pi-host-relay-transport-failure", failure: reviewHostRelayFailureReport(error), reason: error.message, mutation_performed: false, mutation_outcome: "none", next_action: error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT ? reviewHostRelayTimeoutNextAction(error) : error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED ? REVIEW_HOST_RELAY_REFUSED_ACTION : REVIEW_HOST_RELAY_RETRY_ACTION, }; } } const REVIEW_PROVIDER_ROLE_RETRY_ACTION = "Call fresh STATUS and execute only the exact one-slot role vector it reoffers; never relaunch from transcript inference."; // gentle-pi#638: re-derives the capture-unachievable declaration binding from one materialize slot's own provider-issued tokens. The provider renders those tokens as `--name=value` pairs (review-host-relay.ts renderToken), and Go verifies every value against the frozen authority before recording, so a missing required value or subject hash means the slot cannot be declared and the caller keeps its fall-back behavior. function unachievableSlotDeclarationBinding(slot: ReviewHostRelaySlot): { lineageId: string; targetIdentity: string; expectedRevision: string; requestHash: string; repositoryContext?: string } | undefined { const tokenValue = (name: string): string | undefined => { const prefix = `--${name}=`; const token = slot.captureArgumentTokens.find((candidate) => candidate.startsWith(prefix)); return token === undefined ? undefined : token.slice(prefix.length); }; const lineageId = tokenValue("lineage"); const targetIdentity = tokenValue("target"); const expectedRevision = tokenValue("expected-revision"); const repositoryContext = tokenValue("repository-context"); if (lineageId === undefined || targetIdentity === undefined || expectedRevision === undefined || slot.subjectHash === undefined) return undefined; return { lineageId, targetIdentity, expectedRevision, requestHash: slot.subjectHash, ...(repositoryContext === undefined ? {} : { repositoryContext }) }; } async function executeProviderRoleVectorCapture( slot: ReviewProviderRoleVectorSlot, nativeReviewCli: NativeReviewCli, cwd: string, binding: ReviewLastEventClosureBinding, selections: Map, route: RetainedNativeCaptureRoute | undefined, signal?: AbortSignal, implicitWorkspaceRoot?: string, ): Promise> { if (nativeReviewCli.captureProviderRole === undefined) { return { tool: "gentle_review_capture", status: "blocked", outcome: "provider-role-capture-unsupported", reason: "The provider issued a self-contained role capture vector, but this runtime has no native provider-role capture surface.", mutation_performed: false, mutation_outcome: "none", }; } try { const artifact = await nativeReviewCli.captureProviderRole({ captureOperation: slot.captureOperation, argumentTokens: slot.argumentTokens, cwd, ...(signal === undefined ? {} : { signal }), }); if ("operation" in artifact) return mapAndClearLastEventClosure(artifact, binding, selections, cwd, implicitWorkspaceRoot); return { tool: "gentle_review_capture", status: "captured", outcome: "native-provider-role-captured", lineage_id: artifact.lineageId, provider_role: { transport: "go_owned_pi_process", capture_operation: slot.captureOperation, role: artifact.role, target_identity: artifact.targetIdentity, captured: artifact.captured, }, }; } catch (error) { const outcome = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route); return { ...outcome, ...(outcome.status === "reconciled" ? {} : { retry_discipline: REVIEW_PROVIDER_ROLE_RETRY_ACTION }), }; } } // The provider-named lenses still awaiting a reviewer result: one lens per // pending `review.capture-result` collect input, in provider order. function pendingReviewerLenses(status: ReviewStatusV3): readonly string[] { if (status.nextTransition?.kind !== "collect") return []; return [...new Set((status.nextTransition.collect?.inputs ?? []) .filter((input) => input.captureOperation === "review.capture-result") .map((input) => input.artifactSubject?.lens) .filter((lens): lens is NonNullable => lens !== undefined))]; } // Live defect (2026-08-16, Engram #12461): a successor lineage created by // native `review recover` exists only in native authority — this controller // never saw its START, so direct reviewer dispatch refused with // current-binding-missing even though the controller itself had just decoded // the successor's authoritative STATUS. Mirror the START-time registration // from STATUS discovery: when an unknown-but-live lineage still collecting // reviewer results appears in a status this controller decoded, restore its // frozen projection from the native descriptor and bind the dispatch-facing // current candidate view with the provider-named pending lenses. // // Field report (2026-08-16, gentle-pi 402f9f77): hydration must run from // EVERY lane that decodes an authoritative status, not from the STATUS // operation alone — the reported flow was `finalize` (blocked on // review.capture-result) followed by a reviewer dispatch, which never passed // through STATUS. It also never fails its caller: STATUS and the blocked // FINALIZE envelope stay read-only, and the outcome is returned so the caller // can report it instead of swallowing it. // Field defect (2026-08-16, third report): the Pi host relay never ran for a // real lineage. Measured against the live 2.4.0-main provider on a faithful // reproduction — an agent-less `review status` returns a bare capture-result // collect input (lineage, expected-revision, target, repository-context, lens, // order, subject-hash), while the SAME status with `--agent pi` additionally // carries agent=pi, materialize=true and the provider submission. The adapter // never named its agent, so reviewHostRelaySlots() saw zero materialize slots, // the relay was unreachable, and no lens was ever launched. // // The agent is PROBED, never assumed. The pinned provider defines `--agent` as // of v2.4.0 — v2.2.3 did not define it on `review status` at all and refused it // outright — but Pi still never version-sniffs: the installed binary remains // the only authority on whether the flag exists. A typed refusal is remembered // per provider instance and blocks the lifecycle with its exact provider cause; // Pi never degrades it into an agent-less STATUS fallback. const REVIEW_HOST_AGENT = "pi" as const; const REVIEW_TRANSPORT_REFUSAL_CODES = new Set([ "immutable_review_transport_unsupported", "unsupported_agent", "unknown_flag", ]); interface ReviewTransportRefusal { supported: false; code: string; message: string; } interface NegotiatedHostTransportStatus { status?: ReviewStatusV3; transport?: ReviewTransportRefusal; } const reviewTransportRefusalByProvider = new WeakMap(); function clearReviewTransportProbeForTesting(nativeReviewCli: NativeReviewCli | null): void { if (nativeReviewCli !== null) reviewTransportRefusalByProvider.delete(nativeReviewCli as unknown as object); } function hostTransportUnavailable( operation: ReviewControllerOperation | "gentle_review_capture" | "gentle_review_capture_group", transport: ReviewTransportRefusal, ): Record { // #535: a provider-printed raw `gentle-ai review ...` continuation is a dead // end in this runtime — Pi is not in the provider's immutable review runtime // list, so every CLI-only exit refuses with this same transport code. The // refusal therefore names the continuation that runs in this surface (the // gentle_review / gentle_review_capture wrapper tools) while the provider's // own diagnostic stays intact in relay_transport as evidence. const isCapture = operation === "gentle_review_capture" || operation === "gentle_review_capture_group"; return { ...(isCapture ? { tool: operation } : { operation }), status: "blocked", outcome: "pi-host-relay-transport-unavailable", reason: `The native provider refused the required pi reviewer transport (${transport.code}): ${transport.message}`, relay_transport: transport, mutation_performed: false, mutation_outcome: "none", wrapper_continuation: { tool: "gentle_review", operation: REVIEW_CONTROLLER_OPERATION.INSPECT, ...(isCapture ? { then: operation } : {}), }, next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${!isCapture ? " and follow the transition it returns" : operation === "gentle_review_capture_group" ? " and resubmit gentle_review_capture_group with the complete exact ordered collectBindings that fresh STATUS returns" : " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`, }; } /** * Queries negotiated STATUS for the required pi reviewer transport. A typed * refusal is cached per provider and returned as unavailable; neither a fresh * nor remembered refusal may issue an agent-less lifecycle STATUS request. */ async function negotiatedStatusForHostTransport( nativeReviewCli: NativeReviewCli, request: NativeTargetStatusRequest, retainedSelections: Map, canonicalRetentionRoot = request.cwd, ): Promise { const provider = nativeReviewCli as unknown as object; const remembered = reviewTransportRefusalByProvider.get(provider); if (remembered !== undefined) return { transport: remembered }; try { const status = await nativeReviewCli.targetStatus!({ ...request, agent: REVIEW_HOST_AGENT }); syncRetainedNativeStatusSelections(retainedSelections, canonicalRetentionRoot, status, request.baseRef); return { status }; } catch (error) { const code = error instanceof NativeReviewIntegrationError ? error.failureEnvelope.code : undefined; // Only the closed transport-refusal set is typed unavailable; every // other failure remains an error for the caller's normal error path. if (code === undefined || !REVIEW_TRANSPORT_REFUSAL_CODES.has(code)) throw error; const transport: ReviewTransportRefusal = { supported: false, code, message: error.message }; reviewTransportRefusalByProvider.set(provider, transport); return { transport }; } } // gentle-pi#568: resolves the current negotiated review STATUS for a session, // under the exact guards `agent_end` uses to decide whether to nudge: a // native review CLI with both `reviewMode` and `targetStatus`, a UI-bearing // context, and RDD effectively on. Returns `undefined` on any missing guard, // an effective-off mode, or any STATUS error or transport refusal. Startup // negotiation and mutation-gated `agent_end` use the same native whole-target // path; neither derives candidate scope from local mutation receipts. // S5 (startup-home-repo-and-changes): the startup negotiation runs after // session_start returns, bounded by this deadline. Its in-flight review mode // read is kept per cwd so the first prompt waits for it instead of spawning // the same STATUS again. const STARTUP_NEGOTIATION_TIMEOUT_MS = 10_000; const startupModeReads = new Map>(); const startupNegotiations = new Set>(); async function resolveNegotiatedReviewStatusForSession( nativeReviewCli: NativeReviewCli | null, ctx: ExtensionContext, sessionKey: PendingReviewConsentSessionKey, signal?: AbortSignal, ): Promise { if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return undefined; if (ctx.hasUI !== true || !resolveSessionWorktree(ctx.cwd, ctx.cwd)) return undefined; // STATUS can prepare an unversioned directory natively. Only a successfully // decoded effective-on mode authorizes that entry; bootstrap never counts as // an own mutation and native owns all metadata/ancestor safety decisions. const modeRead = nativeRepositoryPreparationEnabled(nativeReviewCli, ctx.cwd, signal, true); startupModeReads.set(ctx.cwd, modeRead); const enabled = await modeRead.finally(() => { if (startupModeReads.get(ctx.cwd) === modeRead) startupModeReads.delete(ctx.cwd); }); if (!enabled) return undefined; try { const retainedSelections = ((key: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(key) ?? processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)(sessionKey); const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd, ...(signal === undefined ? {} : { signal }) }, retainedSelections, ctx.cwd); return negotiated.status; } catch { return undefined; } } // gentle-pi#556 / gentle-ai#4051: the mutation-gated reminder sent // through `agent_end`. It never runs START itself, so it names the one // supported continuation (gentle_review inspect) and defers the resulting // consent envelope to the human. function renderAgentEndReviewPreflightMessage(targetIdentity: string): string { return `Receipt-driven development is enabled, and this worktree holds an unreviewed candidate (target ${targetIdentity}). First determine whether the user explicitly left this exact target unreviewed. If yes, do not invoke review; report that disposition and continue. Only otherwise, call the gentle_review tool with {"operation":"inspect"} and follow the transition it returns; it currently offers review.start for this target. An eligible interactive Pi host may resolve consent directly with its own three-action UI. If gentle_review instead returns an unresolved gentle-ai.review-integration.consent/v3 envelope, relay that original two-choice provider envelope to the human losslessly. Never answer consent from model prose or tool arguments.\n\nThis extension never runs START itself. This reminder consumes only this session's observed mutation generation.`; } function canonicalReviewCaptureBinding(value: unknown): string { if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map((entry) => canonicalReviewCaptureBinding(entry)).join(",")}]`; if (!isRecord(value)) throw new Error("Review capture collectBinding must encode a JSON object"); return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalReviewCaptureBinding(value[key])}`).join(",")}}`; } function parseCanonicalReviewCaptureBinding(input: string): string { let binding: unknown; try { binding = JSON.parse(input); } catch (error) { throw new Error(`Review capture collectBinding is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); } if (!isRecord(binding)) throw new Error("Review capture collectBinding must encode exactly one collect input object"); return canonicalReviewCaptureBinding(binding); } function exactCollectArgument(input: ReviewCollectInputV3, name: string): string | undefined { const matches = input.arguments.filter((argument) => argument.name === name); return matches.length === 1 ? matches[0]!.value : undefined; } // The intended-untracked collect input arrived with status/v6 and every later // status version keeps it; matching one exact version left every workspace // with untracked files unable to start a review once gentle-ai answered v7 // (gentle-pi#610, gentle-ai#4187). const INTENDED_UNTRACKED_STATUS_SCHEMA = /^gentle-ai\.review-integration\.status\/v(\d+)$/; function statusCarriesIntendedUntrackedSelection(schema: unknown): boolean { const match = typeof schema === "string" ? INTENDED_UNTRACKED_STATUS_SCHEMA.exec(schema) : null; return match !== null && Number(match[1]) >= 6; } function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInputV3 | undefined { if (!statusCarriesIntendedUntrackedSelection(status.raw.schema) || status.nextTransition?.kind !== "collect") return undefined; const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => { const value = input.submission?.values[0]; return input.name === "intended_untracked_selection" && input.schema === "gentle-ai.review-intended-untracked-selection/v1" && input.captureOperation === "external.select_intended_untracked" && input.submission?.operationToken === "status" && input.submission.values.length === 1 && value?.slot === "intended_untracked_selection" && value.domain === "schema_bound_json"; }); return matches.length === 1 ? matches[0] : undefined; } // gentle-pi#706: the inspect stop on the intended-untracked selection carries no // continuation, so the blocked result names it exactly: the inventory digest // covers path names only, and the round trip resolves either through the select // operation or through inspect's own top-level untrackedScope. const INSPECT_UNTRACKED_SELECTION_NEXT_STEP = 'The intended-untracked selection is required before START. The expected_untracked_inventory digest covers untracked path names only (git ls-files --others --exclude-standard); nothing is read or hashed at inventory time, and file content is hashed only for selected paths at candidate freeze. Either call gentle_review with operation "select-intended-untracked" passing this selectionBinding and intendedUntracked ([] excludes every eligible path, a subset includes only those paths), or call inspect again with untrackedScope ("exclude", or "select" with intendedUntracked) to resolve the round trip in one call. To keep a path out of the inventory permanently, ignore it through .gitignore or .git/info/exclude.'; interface PublicReviewCaptureBinding { collectBinding: string; } function canOfferNativeCaptureBindings(status: ReviewStatusV3): boolean { return status.applicability === "current_target" && isCanonicalProcessString(status.authority?.lineageId) && isCanonicalProcessString(status.targetIdentity) && !isTerminalReviewAuthorityState(status.authority?.state); } function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicReviewCaptureBinding[] { if (!canOfferNativeCaptureBindings(status) || status.nextTransition?.kind !== "collect") return []; return (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation !== "external.select_intended_untracked").map((input) => ({ collectBinding: canonicalReviewCaptureBinding(input) })); } function captureBindingRejected(reason: string, group = false): Record { return { tool: group ? "gentle_review_capture_group" : "gentle_review_capture", status: "blocked", outcome: group ? "capture-group-rejected" : "capture-binding-rejected", reason, mutation_performed: false, mutation_outcome: "none", }; } interface SelectedReviewCapture { input: ReviewCollectInputV3; binding: ReviewLastEventClosureBinding; } function selectExactReviewCapture( status: ReviewStatusV3, lineageId: string, canonicalBinding: string, ): SelectedReviewCapture | Record { const statusLineageId = status.authority?.lineageId; const statusTargetIdentity = status.targetIdentity; if ( !isCanonicalProcessString(lineageId) || !isCanonicalProcessString(statusLineageId) || !isCanonicalProcessString(statusTargetIdentity) || !canOfferNativeCaptureBindings(status) || statusLineageId !== lineageId ) { return captureBindingRejected("current STATUS does not offer one non-empty matching lineage and target identity"); } if (status.nextTransition?.kind !== "collect") { return captureBindingRejected("current STATUS does not offer a collect transition"); } const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => canonicalReviewCaptureBinding(input) === canonicalBinding); if (matches.length !== 1) { return captureBindingRejected(matches.length === 0 ? "collectBinding is missing or stale for current STATUS" : "collectBinding matches more than one current STATUS input"); } const input = matches[0]!; const inputLineageId = exactCollectArgument(input, "lineage"); const inputTargetIdentity = exactCollectArgument(input, "target"); // Go's targeted-validator vector binds its capture target to the correction // target from the provider-owned validation request, rather than STATUS's // current candidate identity. All other captures remain bound to STATUS. const expectedInputTargetIdentity = input.validationRequest?.correctionTargetIdentity ?? statusTargetIdentity; if ( !isCanonicalProcessString(inputLineageId) || !isCanonicalProcessString(inputTargetIdentity) || inputLineageId !== lineageId || inputTargetIdentity !== expectedInputTargetIdentity ) { return captureBindingRejected("collectBinding does not carry one non-empty matching provider lineage and target token"); } return { input, binding: { lineageId, targetIdentity: statusTargetIdentity }, }; } function isSelectedReviewCapture(value: SelectedReviewCapture | Record): value is SelectedReviewCapture { return "input" in value && "binding" in value; } function captureGroupRejected(reason: string): Record { return captureBindingRejected(reason, true); } function captureGroupStatusFailure(error: unknown): Record { return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed", // Only project the decoded native envelope, never arbitrary error fields. // Group mutation reporting belongs to its submitted-reviewer progress. ...(error instanceof NativeReviewIntegrationError ? { native_failure: error.failureEnvelope.raw, replayability: error.failureEnvelope.replayability, next_action: error.failureEnvelope.nextAction, } : {}), }; } function hasExactReviewCaptureSuffix(status: ReviewStatusV3, expected: readonly string[]): boolean { const current = status.nextTransition?.kind === "collect" ? (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation === "review.capture-result").map(canonicalReviewCaptureBinding) : []; return current.length === expected.length && current.every((binding, index) => binding === expected[index]); } function captureGroupAuthorityDrift(status: ReviewStatusV3): Record { return { ...captureGroupRejected("authoritative STATUS does not offer exactly the unsubmitted reviewer suffix"), outcome: "capture-group-authority-drift", reconciliation: status.raw, authority_applicability: status.applicability, provider_action: status.action, next_transition: status.nextTransition }; } interface SelectedReviewCaptureGroup { selected: true; slots: readonly ReviewHostRelaySlot[]; binding: ReviewLastEventClosureBinding; } function isSelectedReviewCaptureGroup(value: SelectedReviewCaptureGroup | Record): value is SelectedReviewCaptureGroup { return value.selected === true; } function selectExactReviewCaptureGroup( status: ReviewStatusV3, lineageId: string, canonicalBindings: readonly string[], ): SelectedReviewCaptureGroup | Record { const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : []; const slots = reviewHostRelaySlots(inputs); if (inputs.length === 0 || slots.length !== inputs.length) { return captureGroupRejected("current STATUS does not offer an exclusively materialize reviewer capture group"); } const currentBindings = inputs.map((input) => canonicalReviewCaptureBinding(input)); if (new Set(currentBindings).size !== currentBindings.length || canonicalBindings.length !== currentBindings.length || canonicalBindings.some((binding, index) => binding !== currentBindings[index])) { return captureGroupRejected("collectBindings must be the complete distinct current reviewer group in exact provider order"); } const first = selectExactReviewCapture(status, lineageId, currentBindings[0]!); if (!isSelectedReviewCapture(first)) return captureGroupRejected(String(first.reason ?? "current STATUS rejected a reviewer binding")); const expectedRevision = exactCollectArgument(inputs[0]!, "expected-revision"); const repositoryContext = exactCollectArgument(inputs[0]!, "repository-context"); const statusTargetIdentity = status.targetIdentity; const currentRepositoryContext = status.repositoryContext; if (!isCanonicalProcessString(expectedRevision) || !isCanonicalProcessString(repositoryContext) || currentRepositoryContext === undefined || expectedRevision !== currentRepositoryContext.revision) { return captureGroupRejected("current STATUS does not bind one matching expected revision and repository context for the reviewer group"); } if (currentRepositoryContext.handle !== repositoryContext || currentRepositoryContext.targetIdentity !== statusTargetIdentity) { return captureGroupRejected("current STATUS repository context does not match the reviewer group binding"); } const lenses = new Set(), orders = new Set(), subjectHashes = new Set(); for (let index = 0; index < inputs.length; index += 1) { const input = inputs[index]!, slot = slots[index]!, subject = input.artifactSubject; const slotLineage = exactCollectArgument(input, "lineage"), target = exactCollectArgument(input, "target"); const revision = exactCollectArgument(input, "expected-revision"), context = exactCollectArgument(input, "repository-context"); const subjectHash = exactCollectArgument(input, "subject-hash"), order = slot.order, lens = slot.lens; if ( subject === undefined || slot.submission === undefined || slotLineage !== lineageId || target !== statusTargetIdentity || revision !== expectedRevision || context !== repositoryContext || subjectHash !== subject.subjectHash || order === undefined || lens === undefined || subject.lineageId !== lineageId || subject.authorityRevision !== expectedRevision || subject.targetIdentity !== statusTargetIdentity || lens !== subject.lens || String(subject.selectedOrder) !== order ) return captureGroupRejected("current STATUS carries an incomplete or mismatched materialize reviewer binding"); try { resolveReviewHostRelaySubmission(slot.submission); } catch { return captureGroupRejected("current STATUS carries an invalid provider reviewer submission descriptor"); } const value = slot.submission.values[0]; if (slot.submission.operationToken !== "capture-result" || value?.slot !== "reviewer_result" || value.domain !== "artifact_path_or_stdin" || lenses.has(lens) || orders.has(order) || subjectHashes.has(subject.subjectHash)) { return captureGroupRejected("current STATUS carries duplicate or invalid reviewer slot identities"); } lenses.add(lens); orders.add(order); subjectHashes.add(subject.subjectHash); } return { selected: true, slots, binding: first.binding }; } function reviewHostRelayGroupFailure( error: ReviewHostRelayError, slots: readonly ReviewHostRelaySlot[], prepared: readonly ReviewHostRelayPreparedResult[], submitted: number, ): Record { return { tool: "gentle_review_capture_group", status: "blocked", outcome: error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE ? "pi-host-relay-unavailable" : error.kind === REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED ? "pi-host-relay-handshake-refused" : error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT ? "pi-host-relay-timeout" : "pi-host-relay-transport-failure", reason: error.message, failure: reviewHostRelayFailureReport(error), ...reviewHostRelayGroupProgress(slots, prepared, submitted), next_action: error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED ? REVIEW_HOST_RELAY_REFUSED_ACTION : REVIEW_HOST_RELAY_RETRY_ACTION, }; } async function executeReviewCaptureOperation( parametersValue: unknown, sessionCwd: string, nativeReviewCli: NativeReviewCli | null, signal?: AbortSignal, candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(), retainedUntrackedSelections: Map = new Map(), requireRegisteredRoute = false, // gentle-pi#311 P2: the live model registry a lens materialize capture // resolves its in-process completion through. Appended last (rather than // inserted) so every existing positional call site — none of which pass an // eighth argument — keeps compiling unchanged. modelRegistry?: InProcessReviewerRegistry, // The caller's live session id, threaded into every relay request this // capture launches so an OpenCode-routed reviewer model carries its // x-opencode-session attribution header. Appended last for the same // positional-call-site reason as modelRegistry above. reviewerSessionId?: string, // Read effective settings only when the acknowledged capture actually runs. getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"], ): Promise> { const parameters = parseReviewCaptureParameters(parametersValue); if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) { return { tool: "gentle_review_capture", status: "blocked", outcome: "native-status-unsupported", mutation_performed: false, mutation_outcome: "none", }; } const canonicalBinding = parseCanonicalReviewCaptureBinding(parameters.collectBinding); const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId); const implicitWorkspaceRoot = candidateViews?.resolveWorkspaceRoot(parameters.lineageId) ?? sessionCwd; let route = readRetainedNativeCaptureRoute(retainedUntrackedSelections, canonicalBinding); if (requireRegisteredRoute && route !== undefined && (route.workspaceRoot !== cwd || route.lineageId !== parameters.lineageId)) { return captureBindingRejected("collectBinding belongs to a different registered route"); } const baseRef = trustedNativeCaptureBaseRef(route, candidateViews, cwd, parameters.lineageId); let status: ReviewStatusV3; try { const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }), ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId), ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, cwd); if (negotiated.transport !== undefined) return hostTransportUnavailable("gentle_review_capture", negotiated.transport); status = negotiated.status!; } catch (error) { return nativeOperationFailure("gentle_review_capture", error); } const selected = selectExactReviewCapture(status, parameters.lineageId, canonicalBinding); if (!isSelectedReviewCapture(selected)) return selected; // Exact fresh native admission validates this routing snapshot independently // of cache capacity. Carry its trusted selector into every downstream path. route = { workspaceRoot: cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }) }; // During correction the flow carries both the original authority target // identity and a distinct provider-issued correction target identity // (gentle-pi#535 row 15). Echo the correction one on the capture result so // the caller never reconstructs which is which from the opaque binding. const correctionTargetIdentity = selected.input.validationRequest?.correctionTargetIdentity ?? selected.input.artifactSubject?.correctionTargetIdentity; const withCorrectionTarget = (result: Record): Record => correctionTargetIdentity === undefined ? result : { ...result, correction_target_identity: correctionTargetIdentity }; const hostRelaySlots = reviewHostRelaySlots([selected.input]); if (hostRelaySlots.length === 1) { if (parameters.correctionLines !== undefined) return captureBindingRejected("correctionLines is valid only for a correction-plan capture"); if (parameters.reviewerRunAcknowledged !== true) { return { tool: "gentle_review_capture", status: "blocked", outcome: "reviewer-model-run-forecast", cost_forecast: { transport: "pi_host_relay", model_runs: 1, lenses: hostRelaySlots[0]!.lens === undefined ? [] : [hostRelaySlots[0]!.lens], }, mutation_performed: false, mutation_outcome: "none", }; } return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId, implicitWorkspaceRoot, getReviewerTransport)); } // gentle-pi#311 P3: gentle-ai's v9 contract renders the refuter and // targeted-validator role captures host-mediated, exactly like the lens // slot above — same relay machinery, only its fixed review-refuter / // review-validator routing key (carried on the slot) and its own schema // differ. An older gentle-ai's self-contained --execute=true vector still // falls through to reviewProviderRoleVectorSlots below unchanged. const hostMediatedRoleSlots = reviewHostMediatedRoleSlots([selected.input]); if (hostMediatedRoleSlots.length === 1) { if (parameters.correctionLines !== undefined) return captureBindingRejected("correctionLines is valid only for a correction-plan capture"); if (parameters.reviewerRunAcknowledged !== true) { return { tool: "gentle_review_capture", status: "blocked", outcome: "reviewer-model-run-forecast", cost_forecast: { transport: "pi_host_relay", model_runs: 1, roles: [hostMediatedRoleSlots[0]!.routingKey], }, mutation_performed: false, mutation_outcome: "none", }; } return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId, implicitWorkspaceRoot, getReviewerTransport)); } if (selected.input.captureOperation === "review.capture-correction-plan") { if (parameters.reviewerRunAcknowledged !== undefined) return captureBindingRejected("reviewerRunAcknowledged is valid only for a materialize reviewer capture"); const submission = selected.input.submission; const value = submission?.values.length === 1 ? submission.values[0] : undefined; if (submission === undefined || value?.slot !== "correction_lines") return captureBindingRejected("provider correction-plan capture omitted its exact correction-lines binding"); if (parameters.correctionLines === undefined) { return { tool: "gentle_review_capture", status: "blocked", outcome: "correction-lines-required", minimum: value.minimum ?? 1, maximum: value.maximum ?? 200, mutation_performed: false, mutation_outcome: "none", }; } if ((value.minimum !== undefined && parameters.correctionLines < value.minimum) || (value.maximum !== undefined && parameters.correctionLines > value.maximum)) { return captureBindingRejected("correctionLines is outside the exact provider-issued correction-plan bounds"); } if (nativeReviewCli.captureCorrectionPlan === undefined) return captureBindingRejected("native correction-plan capture is unavailable"); try { const closure = await nativeReviewCli.captureCorrectionPlan({ argumentTokens: submission.argumentTokens, correctionLines: parameters.correctionLines, cwd, ...(signal === undefined ? {} : { signal }), }); return withCorrectionTarget(mapAndClearLastEventClosure(closure, selected.binding, retainedUntrackedSelections, cwd, implicitWorkspaceRoot)); } catch (error) { return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route); } } const providerRoleSlots = reviewProviderRoleVectorSlots([selected.input]); if (providerRoleSlots.length === 1) { if (parameters.reviewerRunAcknowledged !== undefined || parameters.correctionLines !== undefined) { return captureBindingRejected("reviewerRunAcknowledged and correctionLines are not valid for a provider role capture"); } return withCorrectionTarget(await executeProviderRoleVectorCapture(providerRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, implicitWorkspaceRoot)); } return captureBindingRejected(`unsupported provider capture operation: ${selected.input.captureOperation}`); } function reviewHostRelayGroupDiagnostics(slots: readonly ReviewHostRelaySlot[], prepared: readonly ReviewHostRelayPreparedResult[], count: number): readonly Record[] { return slots.slice(0, count).map((slot, index) => ({ ...(slot.lens === undefined ? {} : { lens: slot.lens }), ...(slot.order === undefined ? {} : { order: slot.order }), ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }), prompt_bytes: prepared[index]?.promptByteLength, result_bytes: prepared[index]?.resultByteLength, })); } function reviewHostRelayGroupProgress( slots: readonly ReviewHostRelaySlot[], prepared: readonly ReviewHostRelayPreparedResult[], submitted: number, uncertain = false, ): Record { const outcome = submitted === 0 ? uncertain ? "unknown" : "none" : uncertain ? "partial_unknown" : submitted === slots.length ? "completed" : "partial"; return { prepared_reviewers: prepared.length, submitted_reviewers: submitted, host_relay: { transport: "pi_host_relay", reviewers: reviewHostRelayGroupDiagnostics(slots, prepared, submitted) }, ...(submitted === 0 && uncertain ? { mutation_outcome: outcome } : { mutation_performed: submitted > 0, mutation_outcome: outcome }), }; } async function executeReviewCaptureGroupOperation( parametersValue: unknown, sessionCwd: string, nativeReviewCli: NativeReviewCli | null, signal?: AbortSignal, candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(), retainedUntrackedSelections: Map = new Map(), requireRegisteredRoute = false, // gentle-pi#311 P2: see executeReviewCaptureOperation's matching parameter. modelRegistry?: InProcessReviewerRegistry, // The caller's live session id, set on every grouped relay request so an // OpenCode-routed reviewer model carries its x-opencode-session attribution // header. Appended last for the same positional-call-site reason as above. reviewerSessionId?: string, getReviewerTransport?: () => ReviewHostRelayRequest["reviewerTransport"], ): Promise> { const parameters = parseReviewCaptureGroupParameters(parametersValue); if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" }; const canonicalBindings = parameters.collectBindings.map((binding) => parseCanonicalReviewCaptureBinding(binding)); const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId); const implicitWorkspaceRoot = candidateViews?.resolveWorkspaceRoot(parameters.lineageId) ?? sessionCwd; const routes = canonicalBindings.map((binding) => readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding)); let route = routes.find((candidate) => candidate !== undefined); if (requireRegisteredRoute && routes.some((candidate) => candidate !== undefined && (candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route?.baseRef))) { return captureGroupRejected("collectBindings belong to different registered routes"); } const baseRef = trustedNativeCaptureBaseRef(route, candidateViews, cwd, parameters.lineageId); const freshStatus = () => negotiatedStatusForHostTransport(nativeReviewCli, { cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }), ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId), ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, cwd); let status: ReviewStatusV3; try { const negotiated = await freshStatus(); if (negotiated.transport !== undefined) return hostTransportUnavailable("gentle_review_capture_group", negotiated.transport); status = negotiated.status!; } catch (error) { return captureGroupStatusFailure(error); } const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings); if (!isSelectedReviewCaptureGroup(group)) return group; route = { workspaceRoot: cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }) }; if (parameters.reviewerRunAcknowledged !== true) { return { tool: "gentle_review_capture_group", status: "blocked", outcome: "reviewer-model-run-forecast", cost_forecast: { transport: "pi_host_relay", model_runs: group.slots.length, lenses: group.slots.map((slot) => slot.lens).filter((lens): lens is string => lens !== undefined) }, mutation_performed: false, mutation_outcome: "none", }; } // Resolve session-over-pin-over-global once for the entire reviewer group, // so every slot uses the same complete routing snapshot. const reviewerRouting = readReviewerModelConfig(cwd, reviewerSessionId); const reviewerTransport = getReviewerTransport?.(); const requests: readonly ReviewHostRelayRequest[] = group.slots.map((slot) => ({ captureArgumentTokens: slot.captureArgumentTokens, targetCwd: cwd, submission: slot.submission!, ...reviewHostRelaySelection(slot.lens, reviewerRouting), ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }), ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }), ...(reviewerTransport === undefined ? {} : { reviewerTransport }), ...(signal === undefined ? {} : { signal }), })); let prepared: readonly ReviewHostRelayPreparedResult[]; try { prepared = await activeReviewHostRelayReviewerGroupRunner(requests); if (prepared.length !== requests.length) throw new Error("Pi host relay reviewer group returned a different number of prepared results"); } catch (error) { return error instanceof ReviewHostRelayError ? reviewHostRelayGroupFailure(error, group.slots, [], 0) : { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "pi-host-relay-reviewer-group-failed" }; } for (let index = 0; index < prepared.length; index += 1) { let current: SelectedReviewCapture | Record; try { const negotiated = await freshStatus(); if (negotiated.transport !== undefined) return { ...hostTransportUnavailable("gentle_review_capture_group", negotiated.transport), ...reviewHostRelayGroupProgress(group.slots, prepared, index) }; if (!hasExactReviewCaptureSuffix(negotiated.status!, canonicalBindings.slice(index))) return { ...captureGroupAuthorityDrift(negotiated.status!), ...reviewHostRelayGroupProgress(group.slots, prepared, index) }; current = selectExactReviewCapture(negotiated.status!, parameters.lineageId, canonicalBindings[index]!); } catch (error) { return { ...captureGroupStatusFailure(error), ...reviewHostRelayGroupProgress(group.slots, prepared, index) }; } if (!isSelectedReviewCapture(current)) return { ...captureGroupRejected(String(current.reason ?? "current STATUS rejected a reviewer binding")), ...reviewHostRelayGroupProgress(group.slots, prepared, index) }; try { const result = await activeReviewHostRelaySubmissionRunner(prepared[index]!); const closure = decodeRelayLastEventClosure(result.submission); if (closure !== undefined) { const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd, implicitWorkspaceRoot); return { ...closed, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index + 1) }; } } catch (error) { if (error instanceof ReviewHostRelayError && error.mutationOutcome !== "unknown") return reviewHostRelayGroupFailure(error, group.slots, prepared, index); const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route, undefined, REVIEW_HOST_AGENT); return { ...reconciled, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), ...(error instanceof ReviewHostRelayError ? { failure: reviewHostRelayFailureReport(error), reason: error.message } : {}) }; } } const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length), REVIEW_HOST_AGENT); return { ...reconciled, tool: "gentle_review_capture_group", outcome: reconciled.outcome === "capture-group-authority-drift" ? reconciled.outcome : reconciled.status === "reconciled" ? "native-reviewer-group-status-reconciled" : "native-reviewer-group-status-reconciliation-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, prepared.length) }; } type DispatchHydrationOutcome = | { hydrated: true; lineage_id: string; lenses: readonly string[] } | { hydrated: false; lineage_id: string; reason: string; message: string } | undefined; function hydrateDispatchBindingFromStatus(candidateViews: CandidateViewRegistry | null, contributorRoot: string, status: ReviewStatusV3): DispatchHydrationOutcome { if (candidateViews === null || candidateViews.hasCurrentBinding(contributorRoot)) return undefined; const lineageId = status.authority?.lineageId; if (lineageId === undefined || status.applicability !== "current_target" || candidateViews.hasProjection(lineageId, contributorRoot)) return undefined; const lenses = pendingReviewerLenses(status); if (lenses.length === 0) return undefined; try { // STATUS proves the current target, not the historical START offer. // Resolve an existing commit-backed range by its reachable frozen tree; // never pass descriptor.baseTree as a provider-owned tree selector. candidateViews.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, status.projection, lenses); return { hydrated: true, lineage_id: lineageId, lenses }; } catch (error) { // Never fail the caller on hydration; the registry records the typed // cause so the later dispatch refusal names the attempt instead of // claiming no binding was ever available. return { hydrated: false, lineage_id: lineageId, reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid", message: error instanceof Error ? error.message : String(error), }; } } async function executeReviewControllerOperation( parametersValue: unknown, sessionCwd: string, nativeReviewCli: NativeReviewCli | null, signal?: AbortSignal, candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(), context?: ExtensionContext, retainedUntrackedSelections: Map = new Map(), pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry, pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"), intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission, ): Promise> { const parameters = parseReviewControllerParameters(parametersValue); if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT || parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) { // Retired transport needs no repository or lifecycle authority. return { operation: parameters.operation, status: "blocked", outcome: "legacy-operation-retired", reason: "Legacy review bundle transport (export/import) was retired together with the pre-integration graph/compact stores; gentle-ai v2.1.11 exposes no native bundle equivalent.", mutation_performed: false, mutation_outcome: "none", next_action: "Use the native `gentle-ai review` CLI (start/finalize/validate/status/recover) against the repository review authority; receipts and canonical artifacts live in the Git common-directory store at .git/gentle-ai/reviews and travel with the repository through normal Git replication.", }; } const allowsPreparation = parameters.lineageId === undefined && ( parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT || (parameters.operation === REVIEW_CONTROLLER_OPERATION.START && parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation).mode === REVIEW_MODE.ORDINARY) ); const workspaceResolution: { identity?: { toplevel: string; commonDir: string } } = {}; const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId, allowsPreparation, workspaceResolution); const needsPreparation = allowsPreparation && workspaceResolution.identity === undefined; const preparationCurrent = allowsPreparation && context?.sessionManager ? captureBoundSessionRepositoryAuthority(context.sessionManager, context.sessionManager.getCwd?.() ?? sessionCwd) : undefined; const assertPreparationAuthority = () => { if (allowsPreparation && signal?.aborted) throw reviewCancellation("Review controller operation was cancelled"); if (preparationCurrent && !preparationCurrent()) { throw new Error("Session Git authority or lifecycle changed; cannot prepare its review repository."); } }; assertPreparationAuthority(); const pendingReviewConsentSession = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey); const _useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd); const includeWorkspaceRoot = parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd; if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ASSESS) { // Read-only native risk assessment (gentle-ai#4295, gentle-pi#662). Never // mutates, never requires a lineageId, and never routes through // authorizeDestructiveReviewOperation (it returns early for any // operation that is neither RESET nor a maintenance operation). const input = parseReviewAssessInput(parameters.operation, parameters.input); const details = await resolveReviewAssessmentPlan(nativeReviewCli, defaultCwd, input, signal, context?.sessionManager as ReceiptSession | undefined); return { operation: parameters.operation, ...details, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS) { const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context); } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ABANDON) { const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); return await executeNativeAbandon(input, parameters.lineageId, defaultCwd, nativeReviewCli, signal, context); } const maintenance = nativeMaintenanceOperation(parameters.operation); if (maintenance !== undefined) { const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); return await executeNativeAuthorityMaintenance(parameters.operation, maintenance, input, defaultCwd, nativeReviewCli, signal); } if ( parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && nativeReviewCli !== null ) { const rawInspect = parameters.input === undefined ? undefined : parseControllerJson(parameters.input, REVIEW_CONTROLLER_OPERATION.INSPECT); const unknownField = rawInspect === undefined ? undefined : Object.keys(rawInspect).find((field) => !["baseRef", "committedOnly"].includes(field)); if (unknownField !== undefined) return nativeInspectInputRejection("unknown-field", unknownField); const baseRef = rawInspect?.baseRef; if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeInspectInputRejection("base-ref-invalid"); if (baseRef !== undefined && rawInspect?.committedOnly !== true) return nativeInspectInputRejection("committed-only-required"); if (rawInspect !== undefined && baseRef === undefined) return nativeInspectInputRejection("committed-only-invalid"); let canonicalBaseRef: string | undefined; if (typeof baseRef === "string") { try { canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit; } catch (error) { if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true })); if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeInspectInputRejection(error.reason); return nativeInspectInputRejection("base-ref-unresolvable"); } } if (needsPreparation && !await nativeRepositoryPreparationEnabled(nativeReviewCli, defaultCwd, signal)) { return nativeRepositoryPreparationBlocked(parameters.operation); } assertPreparationAuthority(); const inspectSelector = canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true as const }; // A new inspect supersedes every pre-lineage selection before its first // STATUS attempt. A failed or changed-candidate inspect cannot leave an // older selection available for a later START. clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, ""); try { if (nativeReviewCli.targetStatus !== undefined) { const negotiated = await negotiatedStatusForHostTransport( nativeReviewCli, { cwd: defaultCwd, ...inspectSelector, ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, defaultCwd, ); if (negotiated.transport !== undefined) { return { ...hostTransportUnavailable(parameters.operation, negotiated.transport), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), }; } const status = negotiated.status!; const plainMapped = mapNativeTargetStatus( parameters.operation, status, undefined, includeWorkspaceRoot ? defaultCwd : undefined, ); if (parameters.untrackedScope === undefined) { if (canonicalBaseRef !== undefined && typeof plainMapped.selectionBinding === "string") { retainNativeStatusSelection(retainedUntrackedSelections, reviewLifecycleStorageKey(defaultCwd, ""), Object.freeze({ selectionBinding: plainMapped.selectionBinding, targetIdentity: status.targetIdentity, baseRef: canonicalBaseRef, committedOnly: true as const })); } else if (canonicalBaseRef !== undefined && plainMapped.status === "ready") { // A plain inspect that resolves ready with no untracked decision still // retains its own committed-range selector, so the following plain // START replays this exact inspected range instead of adopting the // native default base-ref (gentle-pi#874). const readyCandidateIdentity = nativePreLineageCandidateIdentity(status); if (readyCandidateIdentity !== undefined) { retainNativeStatusSelection(retainedUntrackedSelections, reviewLifecycleStorageKey(defaultCwd, ""), Object.freeze({ ...readyCandidateIdentity, baseRef: canonicalBaseRef, committedOnly: true as const })); } } // gentle-pi#706: the stop alone never tells the caller what to do next. return { ...plainMapped, ...("selectionBinding" in plainMapped ? { nextStep: INSPECT_UNTRACKED_SELECTION_NEXT_STEP } : {}), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), }; } const input = reviewIntendedUntrackedInput(status); if (input === undefined) return { ...plainMapped, untracked_selection: "not-required", ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), }; const eligibleJson = exactCollectArgument(input, "eligible_paths_json"), inventory = exactCollectArgument(input, "expected_untracked_inventory"); let eligible: unknown; try { eligible = JSON.parse(eligibleJson ?? ""); } catch { eligible = undefined; } const selected = validateNativeStartUntrackedSelection({ untrackedScope: parameters.untrackedScope, expectedUntrackedInventory: inventory, intendedUntracked: parameters.intendedUntracked, }); if ( !Array.isArray(eligible) || selected.reason !== undefined || selected.intendedUntracked!.some((path) => !eligible.includes(path)) ) { return { operation: parameters.operation, status: "blocked", outcome: "inspect-untracked-scope-invalid", reason: "The requested untrackedScope/intendedUntracked combination is invalid for the current intended-untracked stop: paths must be members of the eligible inventory, exclude selects none, and select selects at least one.", mutation_performed: false, mutation_outcome: "none", }; } const submission: NativeIntendedUntrackedSelectionSubmission = { argumentTokens: input.submission!.argumentTokens, value: JSON.stringify({ schema: "gentle-ai.review-intended-untracked-selection/v1", untracked_scope: parameters.untrackedScope, expected_untracked_inventory: inventory, intended_untracked: selected.intendedUntracked, }), }; const resolved = await negotiatedStatusForHostTransport( nativeReviewCli, { cwd: defaultCwd, ...inspectSelector, untrackedScope: parameters.untrackedScope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked, intendedUntrackedSelection: submission, ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, defaultCwd, ); if (resolved.transport !== undefined) { return { ...hostTransportUnavailable(parameters.operation, resolved.transport), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), }; } const resolvedStatus = resolved.status!; const candidateIdentity = nativePreLineageCandidateIdentity(resolvedStatus); if (candidateIdentity !== undefined) { retainNativeUntrackedSelection( retainedUntrackedSelections, defaultCwd, "", Object.freeze({ untrackedScope: parameters.untrackedScope, expectedUntrackedInventory: inventory!, intendedUntracked: Object.freeze([...selected.intendedUntracked!]), submission, ...candidateIdentity, // gentle-pi#1192: remember the committed-range selector this // inspect used, so a later plain START replays it instead of // recomputing an unrelated workspace-diff candidate. ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true as const }), }), ); } const resolvedMapped = mapNativeTargetStatus( parameters.operation, resolvedStatus, undefined, includeWorkspaceRoot ? defaultCwd : undefined, ); return { ...resolvedMapped, ...("selectionBinding" in resolvedMapped ? { nextStep: INSPECT_UNTRACKED_SELECTION_NEXT_STEP } : {}), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), }; } return nativeStatusUnsupported(parameters.operation); } catch (error) { return nativeStatusFailed(parameters.operation, error); } } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT) { return nativeStatusUnsupported(parameters.operation); } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK) { const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); if (typeof input.ownerHash !== "string") throw new Error("Lock recovery requires an exact ownerHash"); // A stuck legacy mutation lock is an incomplete in-flight entry; the // audited native quarantine owns its removal. Lock recovery is not a // destructive authority reset, so pending authorizations survive. return await executeNativeRecoveryRoute(parameters.operation, "reclaim", input, defaultCwd, nativeReviewCli, signal); } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER) { const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); // The authorization binding is Pi-derived, never caller-carried. It is // recorded verbatim as a maintainer attestation, so accepting one the // caller composed would let an unapproved actor sign the recovery edge. if (input.maintainerAuthorization !== undefined) { return { operation: parameters.operation, status: "blocked", outcome: "native-recovery-caller-authorization-rejected", native_operation: "review recover", mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-without-maintainer-authorization", }; } const missing = NATIVE_RECOVERY_INPUT.recover.filter((key) => key === "disposition" ? !["scope_changed", "invalidated", "escalated"].includes(input[key] as string) : !isCanonicalProcessString(input[key]), ); if (missing.length > 0) return await executeNativeRecoveryRoute(parameters.operation, "recover", input, defaultCwd, nativeReviewCli, signal); if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); const frozenTarget = candidateViews?.hasProjection(String(input.predecessorLineage), defaultCwd) ? candidateViews.resolveProjection(String(input.predecessorLineage), defaultCwd) : undefined; const statusRequest = { cwd: defaultCwd, lineageId: String(input.predecessorLineage), ...(frozenTarget?.committedOnly === true ? { baseRef: nativeCommittedRangeSelector(frozenTarget), committedOnly: true } : {}), ...(signal === undefined ? {} : { signal }), }; let status: ReviewStatusV3; try { status = await nativeReviewCli.targetStatus(statusRequest); } catch (error) { return nativeStatusFailed(parameters.operation, error); } const pinnedRecoveryStatus = (candidate: ReviewStatusV3): boolean => candidate.action === "recover" && candidate.actionDisposition === status.actionDisposition && candidate.authority?.lineageId === input.predecessorLineage && candidate.authority?.revision === input.expectedPredecessorRevision && candidate.targetIdentity === status.targetIdentity; if (status.action !== "recover" || status.actionDisposition === undefined || status.authority?.lineageId !== input.predecessorLineage || status.authority.revision !== input.expectedPredecessorRevision || !isCanonicalProcessString(status.targetIdentity)) { return { operation: parameters.operation, status: "blocked", outcome: "native-recovery-status-mismatch", mutation_performed: false, mutation_outcome: "none", result: status.raw, next_action: "follow-provider-target-status" }; } if (input.disposition !== status.actionDisposition) { return { operation: parameters.operation, status: "blocked", outcome: "native-recovery-disposition-mismatch", mutation_performed: false, mutation_outcome: "none", provider_disposition: status.actionDisposition, next_action: "resubmit-with-provider-disposition" }; } const recoverAuthorization = nativeReviewRecoverAuthorization({ predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), targetIdentity: status.targetIdentity, actor: String(input.actor), reason: String(input.reason), }); if (context?.hasUI !== true) throw new Error("Review controller RECOVER requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed"); const approved = await context.ui.confirm( "Authorize destructive review authority RECOVER?", [ "Operation: RECOVER", `Provider-selected disposition: ${status.actionDisposition}`, "Exact published authorization binding:", recoverAuthorization, `The native command creates one auditable successor authority (${String(input.successorLineage)}) for this exact predecessor and target identity; the predecessor stays untouched.`, ].join("\n"), ); if (!approved) throw new Error("Review controller RECOVER was not explicitly authorized"); // Time-of-check to time-of-use: the human deliberates for an unbounded // interval, and the authority can advance, be recovered by someone else, or // stop being recovery-eligible while they do. The approval and the derived // binding are pinned to the pre-approval read, so the authority is read once // more and must still match it exactly before anything mutates. let confirmedStatus: ReviewStatusV3; try { confirmedStatus = await nativeReviewCli.targetStatus(statusRequest); } catch (error) { return nativeStatusFailed(parameters.operation, error); } if (!pinnedRecoveryStatus(confirmedStatus)) { return { operation: parameters.operation, status: "blocked", outcome: "native-recovery-authority-changed", native_operation: "review recover", mutation_performed: false, mutation_outcome: "none", result: confirmedStatus.raw, next_action: "reinspect-and-reauthorize-recovery", }; } return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition, maintainerAuthorization: recoverAuthorization }, defaultCwd, nativeReviewCli, signal, statusRequest.baseRef === undefined ? {} : { baseRef: statusRequest.baseRef, committedOnly: true }); } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET) { const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); return await executeNativeRecoveryRoute(parameters.operation, "reclaim", input, defaultCwd, nativeReviewCli, signal); } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR) { if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); const frozenTarget = parameters.lineageId === undefined || !candidateViews?.hasProjection(parameters.lineageId, defaultCwd) ? undefined : candidateViews.resolveProjection(parameters.lineageId, defaultCwd); let status: ReviewStatusV3; try { status = await nativeReviewCli.targetStatus({ cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(frozenTarget?.committedOnly === true ? { baseRef: nativeCommittedRangeSelector(frozenTarget), committedOnly: true } : {}), ...(signal === undefined ? {} : { signal }) }); } catch (error) { return nativeStatusFailed(parameters.operation, error); } clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state); retainNativeCaptureRoutes(retainedUntrackedSelections, defaultCwd, status, frozenTarget?.committedOnly === true ? nativeCommittedRangeSelector(frozenTarget) : undefined); if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined) }; if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined); const store = ReviewTransactionStore.forRepository(defaultCwd); store.repairCurrentAuthority(); return { operation: parameters.operation, repaired: true }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED) { const controllerOnlyInput = ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource"] .find((key) => parameters[key as keyof ReviewControllerParameters] !== undefined); if (controllerOnlyInput !== undefined || !isCanonicalProcessString(parameters.lineageId)) { const implicitRoot = resolveReviewControllerWorkspaceRoot(undefined, sessionCwd, candidateViews, parameters.lineageId); const needsExplicitWorkspaceRoot = parameters.workspaceRoot !== undefined && parameters.workspaceRoot !== implicitRoot; const nextAction = isCanonicalProcessString(parameters.lineageId) ? `gentle_review {"operation":"acknowledge-approved","lineageId":"${parameters.lineageId}"${needsExplicitWorkspaceRoot ? `,"workspaceRoot":${JSON.stringify(parameters.workspaceRoot)}` : ""}}` : "resubmit-the-exact-lineage-without-controller-only-input"; return { operation: parameters.operation, status: "blocked", outcome: "native-approved-acknowledgement-input-invalid", reason: controllerOnlyInput === undefined ? "lineage-invalid" : "controller-only-input", ...(controllerOnlyInput === undefined ? {} : { field: controllerOnlyInput }), mutation_performed: false, mutation_outcome: "none", next_action: nextAction, }; } const acknowledgementCli = nativeReviewCli as NativeReviewAcknowledgementCli | null; if (acknowledgementCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); if (acknowledgementCli.acknowledgeApproved === undefined) { return { operation: parameters.operation, status: "blocked", outcome: "native-approved-acknowledgement-unsupported", mutation_performed: false, mutation_outcome: "none", next_action: "install-native-acknowledge-approved-support", }; } const frozenTarget = candidateViews?.hasProjection(parameters.lineageId, defaultCwd) ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd) : undefined; const target = { cwd: defaultCwd, lineageId: parameters.lineageId, ...(frozenTarget?.committedOnly === true ? { baseRef: nativeCommittedRangeSelector(frozenTarget), committedOnly: true } : {}), ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId), ...(signal === undefined ? {} : { signal }), }; let status: ReviewStatusV3; try { status = await acknowledgementCli.targetStatus(target); } catch (error) { return nativeStatusFailed(parameters.operation, error); } const execute = status.nextTransition?.kind === "execute" ? status.nextTransition.execute : undefined; if ( status.applicability !== "current_target" || status.authority?.lineageId !== parameters.lineageId || status.authority.state !== "approved" || execute?.operation !== "review.acknowledge-approved" ) { return { operation: parameters.operation, status: "blocked", outcome: "native-approved-acknowledgement-not-current", result: status.raw, mutation_performed: false, mutation_outcome: "none", next_action: "follow-provider-target-status", }; } let argumentTokens: readonly string[]; try { argumentTokens = assertReviewApprovedAcknowledgementExecuteV1(execute, { cwd: defaultCwd, lineageId: parameters.lineageId, targetIdentity: status.targetIdentity, revision: status.authority.revision, }); } catch (error) { return nativeOperationFailure(parameters.operation, error); } let acknowledged: NativeReviewAcknowledgeApprovedOutcome | void; try { // gentle-ai #3947: the burn answers with one review-acknowledged/v1 // envelope bound to exactly this lineage, target, and revision, and // the burn is reported from that envelope, never from a later // STATUS. Every published release up to v2.5.0-rc.3 still burns in // silence, and that result stays byte-identical. acknowledged = await acknowledgementCli.acknowledgeApproved({ argumentTokens, cwd: defaultCwd, binding: { lineageId: parameters.lineageId, targetIdentity: status.targetIdentity, revision: status.authority.revision }, ...(signal === undefined ? {} : { signal }), }); } catch (error) { if (!nativeMutationRequiresStatus(error)) return nativeOperationFailure(parameters.operation, error); return await reconcileNativeMutationFailure(parameters.operation, error, acknowledgementCli, target, retainedUntrackedSelections); } // gentle-ai#4003: from here the native burn is the committed authority // outcome. Both Pi-side teardown steps run outside the mutation-result // try/catch and each one is guarded on its own, so a cleanup failure is // reported as deferred cleanup and never as a failed acknowledgement // that would invite a replay of a burned operation. const retainedSelectionCleanup = deferredPostBurnCleanup(POST_BURN_CLEANUP.retainedSelection, () => clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId)); // The registry owns restoring writability of its 0555 views before // removal; a terminal approved cleanup keeps the lineage projection. const candidateViewCleanup = deferredPostBurnCleanup(POST_BURN_CLEANUP.candidateView, () => candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd)); // gentle-pi#668/#1175: `closed` is never recorded here -- the burn this // acknowledgement completed makes native report candidate.consumed for // this exact candidate, and the next assess call derives closure from // that evidence alone. return { operation: parameters.operation, status: "closed", outcome: "native-approved-acknowledgement-completed", lineage_id: parameters.lineageId, target_identity: status.targetIdentity, ...(acknowledged ? { consumed_revision: acknowledged.consumedRevision } : {}), authority: "burned", ...(acknowledged ? { burn_evidence: acknowledged.schema } : {}), delivery: "ordinary-repository-policy", mutation_performed: true, mutation_outcome: "committed", ...(retainedSelectionCleanup === undefined ? {} : { retained_selection_cleanup: retainedSelectionCleanup }), ...(candidateViewCleanup === undefined ? {} : { candidate_view_cleanup: candidateViewCleanup }), }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT) { const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); if (Object.keys(input).some((key) => key !== "consentBinding" && key !== "answer") || Object.keys(input).length !== 2) throw new Error("Review controller answer-consent input must contain exactly consentBinding and answer"); if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding"); if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined"); // gentle-pi#455: resolve the binding by its opaque id alone, so a // binding one active Pi session's START created is answerable from any // active session presenting it -- not only the session that created it. const resolved = pendingReviewConsentRegistry.resolve(input.consentBinding); const pending = resolved?.pending; const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession; if (pending === undefined) { const stale = staleConsentBindingDiagnostics(input.consentBinding, pendingReviewConsentRegistry.staleDisposition(input.consentBinding)); if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); try { const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, defaultCwd); if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport); return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!, includeWorkspaceRoot ? defaultCwd : undefined); } catch (error) { return nativeStatusFailed(parameters.operation, error); } } const answeringRepositoryCwd = realpathSync(defaultCwd); if (answeringRepositoryCwd !== pending.repositoryCwd) return consentBindingRepositoryMismatchOutcome(parameters.operation, input.consentBinding, pending.repositoryCwd, answeringRepositoryCwd); if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed"); pending.verifyCandidate(); if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable"); if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession)) { const stale = staleConsentBindingDiagnostics( input.consentBinding, pendingReviewConsentRegistry.staleDisposition(input.consentBinding), ); if (nativeReviewCli.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); try { const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, defaultCwd); if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport); return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!, includeWorkspaceRoot ? defaultCwd : undefined); } catch (error) { return nativeStatusFailed(parameters.operation, error); } } try { const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal); if (gated !== undefined) { // gentle-pi#668: mode disabled for this exact candidate -- keyed by // its targetIdentity, never by repository alone. recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.UNAVAILABLE); cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession); return gated; } } catch (error) { cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession); return nativeOperationFailure(parameters.operation, error); } // The one-shot binding is consumed before the first answer-path await. Any // ambiguous provider result reconciles through STATUS and can never be replayed. let completed: Record; try { const answered = await nativeReviewCli.answerConsent({ cwd: pending.authorityCwd, consent: pending.consent, answer: input.answer, ...(signal === undefined ? {} : { signal }), }); if (answered.kind === "declined") { // gentle-pi#668: candidate-scoped decline, keyed by this exact // candidate's targetIdentity, never by repository alone. recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.DECLINED); pending.cleanupCandidate(); return { operation: parameters.operation, status: "skipped", outcome: "consent-declined-this-candidate", consent: answered.raw, ...nativeStartPreAuthorityRejection(), }; } retainNativeUntrackedSelection(retainedUntrackedSelections, pending.authorityCwd, answered.start.lineageId, pending.untrackedSelection); // gentle-pi#706: a START completed through answer-consent consumed the // adopted pre-lineage selection too; clear it like the direct path. clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, pending.authorityCwd, ""); completed = completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, pending.candidateViews); } catch (error) { const value = error as { mutationOutcome?: unknown }; if (value.mutationOutcome === "none") pending.cleanupCandidate(); return await reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, { cwd: pending.authorityCwd, ...(pending.candidateView.committedOnly ? { baseRef: nativeCommittedRangeSelector(pending.candidateView), committedOnly: true } : {}), projection: "workspace", }, retainedUntrackedSelections); } return completed; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) { if (nativeReviewCli?.targetStatus === undefined || nativeReviewCli.start === undefined) return nativeStatusUnsupported(parameters.operation); const canonicalBinding = parseCanonicalReviewCaptureBinding(parameters.selectionBinding!); const retainedStop = retainedUntrackedSelections.get(reviewLifecycleStorageKey(defaultCwd, "")); const stopSelector = retainedStop !== undefined && "selectionBinding" in retainedStop ? retainedStop : undefined; if (stopSelector !== undefined && stopSelector.selectionBinding !== canonicalBinding) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" }; const committedSelector = stopSelector === undefined ? {} : { baseRef: stopSelector.baseRef, committedOnly: true as const }; let status: ReviewStatusV3; try { const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...committedSelector, ...(signal === undefined ? {} : { signal }) }, retainedUntrackedSelections, defaultCwd); if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport); status = negotiated.status!; } catch (error) { return nativeStatusFailed(parameters.operation, error); } const input = reviewIntendedUntrackedInput(status), eligibleJson = input === undefined ? undefined : exactCollectArgument(input, "eligible_paths_json"), inventory = input === undefined ? undefined : exactCollectArgument(input, "expected_untracked_inventory"); let eligible: unknown; try { eligible = JSON.parse(eligibleJson ?? ""); } catch { eligible = undefined; } const scope = parameters.intendedUntracked!.length === 0 ? NATIVE_START_UNTRACKED_SCOPE.EXCLUDE : NATIVE_START_UNTRACKED_SCOPE.SELECT; const selected = validateNativeStartUntrackedSelection({ untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: parameters.intendedUntracked }); const rejected = (stopSelector !== undefined && stopSelector.targetIdentity !== status.targetIdentity) || input === undefined || canonicalReviewCaptureBinding(input) !== canonicalBinding || exactCollectArgument(input, "target_identity") !== status.targetIdentity || exactCollectArgument(input, "projection") !== status.projection.projection || exactCollectArgument(input, "base_tree") !== status.projection.baseTree || exactCollectArgument(input, "candidate_tree") !== status.projection.currentCandidateTree || !Array.isArray(eligible) || selected.reason !== undefined || selected.intendedUntracked!.some((path) => !eligible.includes(path)); if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" }; const submission = { argumentTokens: input.submission!.argumentTokens, value: JSON.stringify({ schema: "gentle-ai.review-intended-untracked-selection/v1", untracked_scope: scope, expected_untracked_inventory: inventory, intended_untracked: selected.intendedUntracked }) }; const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, ...committedSelector, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, submission); return { ...result, operation: parameters.operation }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) { const rawStart = parseControllerJson( requiredControllerString(parameters, "input"), REVIEW_CONTROLLER_OPERATION.START, ); if (rawStart.mode === REVIEW_MODE.ORDINARY) { if ("policyHash" in rawStart) return nativeStartRejection("legacy-policy-hash-unsupported"); const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath", "focus", "lenses", "lensesReason", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"].includes(field)); if (unknownField !== undefined) return nativeStartRejection("unknown-field", unknownField); const focus = rawStart.focus; if (focus !== undefined && !isNativeStartFocus(focus)) return nativeStartRejection("focus-invalid"); const lensSelection = validateNativeStartLensSelection(rawStart); if (lensSelection === undefined) return nativeStartRejection("lenses-invalid"); const policy: NativeStartPolicyValidation = rawStart.policyPath === undefined ? {} : validateNativeStartPolicyPath(defaultCwd, rawStart.policyPath); if (policy.reason !== undefined) return nativeStartRejection(policy.reason); const baseRef = rawStart.baseRef; if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStartRejection("base-ref-invalid"); if (baseRef !== undefined && rawStart.committedOnly !== true) return nativeStartRejection("committed-only-required"); if (baseRef === undefined && "committedOnly" in rawStart) return nativeStartRejection("committed-only-invalid"); const explicitUntrackedSelection = validateNativeStartUntrackedSelection(rawStart); if (explicitUntrackedSelection.reason !== undefined) return nativeStartRejection(explicitUntrackedSelection.reason); // gentle-pi#706: a plain START adopts the selection an inspect // untrackedScope round trip retained pre-lineage; explicit input or a // carried submission always wins over the retained entry. const rawRetainedPreLineageSelection = explicitUntrackedSelection.untrackedScope === undefined && intendedUntrackedSelection === undefined ? readRetainedPreLineageNativeUntrackedSelection( retainedUntrackedSelections, defaultCwd, ) : undefined; let canonicalBaseRef: string | undefined; let providerBaseTree: string | undefined; if (typeof baseRef === "string") { try { canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit; } catch (error) { if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true })); if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason); return nativeStartRejection("base-ref-unresolvable"); } } // gentle-pi#1192: an explicit baseRef always wins over a conflicting // retained one (same precedent as explicit untrackedScope/submission // above); drop the retained entry from *this* START rather than reject // outright, so a differently-scoped explicit START still proceeds. Any // STATUS/START this call still runs against the "" key clears it below // on success regardless (gentle-pi#706), same as any other START. const retainedBaseRefConflicts = canonicalBaseRef !== undefined && rawRetainedPreLineageSelection?.baseRef !== undefined && rawRetainedPreLineageSelection.baseRef !== canonicalBaseRef; const retainedPreLineageSelection = retainedBaseRefConflicts ? undefined : rawRetainedPreLineageSelection; // A plain START (no explicit baseRef) adopts the retained committed-range // selector verbatim; it was already canonicalized when the inspect stored // it, so no second resolveCanonicalCandidateBase round trip is needed. if (canonicalBaseRef === undefined && retainedPreLineageSelection?.baseRef !== undefined) canonicalBaseRef = retainedPreLineageSelection.baseRef; // A retained pre-lineage entry may carry only a committed-range selector // with no untracked decision at all (a plain inspect that resolved ready // without ever needing untrackedScope); only adopt its untracked fields // when it actually recorded one. const untrackedSelection: NativeStartUntrackedSelection = retainedPreLineageSelection === undefined || retainedPreLineageSelection.untrackedScope === undefined ? explicitUntrackedSelection : { untrackedScope: retainedPreLineageSelection.untrackedScope, expectedUntrackedInventory: retainedPreLineageSelection.expectedUntrackedInventory!, intendedUntracked: [...retainedPreLineageSelection.intendedUntracked!], }; const untrackedSubmission = intendedUntrackedSelection ?? (retainedPreLineageSelection?.untrackedScope === undefined ? undefined : retainedPreLineageSelection.submission); // The stored value must stay a plain RetainedNativeUntrackedSelection // (no baseRef/targetIdentity/candidateTree): it is re-keyed under the // lineage-scoped entry below, and readRetainedNativeUntrackedSelection // discriminates that entry from a RetainedNativeCaptureRoute by the // absence of a baseRef field. const retainedUntrackedSelection: RetainedNativeUntrackedSelection | undefined = retainedPreLineageSelection === undefined || retainedPreLineageSelection.untrackedScope === undefined ? cloneRetainedNativeUntrackedSelection(explicitUntrackedSelection) : Object.freeze({ untrackedScope: retainedPreLineageSelection.untrackedScope, expectedUntrackedInventory: retainedPreLineageSelection.expectedUntrackedInventory, intendedUntracked: retainedPreLineageSelection.intendedUntracked, ...(retainedPreLineageSelection.submission === undefined ? {} : { submission: retainedPreLineageSelection.submission }), }); if (needsPreparation) { if (!await nativeRepositoryPreparationEnabled(nativeReviewCli, defaultCwd, signal)) return nativeRepositoryPreparationBlocked(parameters.operation); } else { try { const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal); if (gated !== undefined) return gated; } catch (error) { return nativeOperationFailure(parameters.operation, error); } } assertPreparationAuthority(); if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); let target: ReviewStatusV3; try { const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }), ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection), ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }), ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, defaultCwd); if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport); target = negotiated.status!; // gentle-pi#874: the STATUS this START already fetched can itself // offer the committed-range START for an empty workspace candidate. // Adopt its own base selector *here*, before the candidate view and the // native START are resolved, and re-derive the target for that range, // so all three agree on one base-diff identity. Adopting the offer // later left the workspace target and the base-diff candidate view // disagreeing, and START failed with identity-mismatch. An explicit // caller baseRef still wins (it already is the adopted range), but an // in-play untracked selection now also pays this second read-only // STATUS: the renegotiated target is a base-diff projection, so the // candidate view must be materialized WITH the offered base instead of // the base-less view that tripped candidate-target-projection-drift. if (canonicalBaseRef === undefined) { const offeredBaseRef = offeredCommittedRangeBaseRef(target); if (offeredBaseRef === undefined && target.nextTransition?.kind === "execute" && target.nextTransition.execute.operation === "review.start" && target.nextTransition.execute.arguments.some((argument) => argument.name === "base-ref")) return nativeStartRejection("provider-base-offer-invalid"); if (offeredBaseRef !== undefined) { const renegotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), baseRef: offeredBaseRef, committedOnly: true, ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection), ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }), ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, defaultCwd); if (renegotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, renegotiated.transport); if (isProviderCandidateBaseTree(defaultCwd, offeredBaseRef)) { if (renegotiated.status!.projection.baseTree !== offeredBaseRef) return nativeStartRejection("provider-base-tree-mismatch"); providerBaseTree = offeredBaseRef; } else { canonicalBaseRef = offeredBaseRef; } target = renegotiated.status!; } } if ( retainedPreLineageSelection !== undefined && !sameNativePreLineageCandidate(retainedPreLineageSelection, target) ) { clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, ""); return { operation: parameters.operation, status: "blocked", outcome: "native-start-retained-selection-candidate-mismatch", mutation_performed: false, mutation_outcome: "none", next_action: "inspect-and-resolve-the-current-intended-untracked-selection", }; } if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined); } catch (error) { return nativeOperationFailure(parameters.operation, error); } // gentle-pi#323: the replay key must fold in the current candidate // content identity. Without it, a second START with identical // {cwd, lineageId, input, inputPath} reuses a still-live (never // lineage-bound) frozen candidate view retained for pending consent // even after the live candidate content changed underneath // it, and dead-ends at candidate-target-projection-drift with no // recovery. Folding in currentCandidateTree makes a content change // mint a fresh replay key -- and therefore a fresh candidate view -- // instead of reusing the stale one. const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree, providerBaseTree: providerBaseTree ?? null }); const candidateIntendedUntracked = target.projection.intendedUntracked; let candidateView: ReturnType | undefined; let nativeStartAttempted = false; try { const candidateRequest = { contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }), ...(providerBaseTree === undefined ? {} : { providerBaseTree, committedOnly: true }) }; candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: candidateIntendedUntracked }); if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target); let result: NativeStartResult; try { nativeStartAttempted = true; result = await nativeReviewCli.start({ cwd: defaultCwd, ...(canonicalBaseRef === undefined ? {} : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }), ...(providerBaseTree === undefined ? {} : { baseRef: providerBaseTree, committedOnly: true }), targetIdentity: target.targetIdentity, projection: target.projection.projection, ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection), ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }), ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }), ...(isNativeStartFocus(focus) ? { focus } : {}), ...lensSelection, ...(signal === undefined ? {} : { signal }), }); } catch (error) { if (!(error instanceof NativeReviewConsentRequiredError)) throw error; if (candidateView === undefined) throw new CandidateViewError("native consent requires a frozen candidate view"); const consentCandidateView = candidateView; const repositoryCwd = realpathSync(defaultCwd); const consentDigest = reviewConsentDigest(error.consent); const pendingReviewConsents = pendingReviewConsentRegistry.get(pendingReviewConsentSession); const existing = [...(pendingReviewConsents?.values() ?? [])].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest); if (existing === undefined) { for (const pending of [...(pendingReviewConsents?.values() ?? [])]) { if (pending.candidateView.token === consentCandidateView.token) { discardPendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession); } } } const id = existing?.id ?? randomUUID(); if (existing === undefined) { let candidateCleaned = false; const pending: PendingReviewConsent = { id, repositoryCwd, authorityCwd: defaultCwd, candidateView: consentCandidateView, candidateViews, verifyCandidate: () => consentCandidateView.verify(), cleanupCandidate: () => { if (candidateCleaned) return; candidateCleaned = true; try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent teardown still completes. */ } }, ...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }), consent: error.consent, consentDigest, }; pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending); } return { operation: parameters.operation, status: "blocked", outcome: "native-review-consent-required", consent: error.consent.raw, consent_binding: id, ...nativeStartPreAuthorityRejection(), }; } retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, result.lineageId, retainedUntrackedSelection); // gentle-pi#706: the adopted pre-lineage selection dies with the START // that consumed it; it must never leak to the next candidate. clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, ""); return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews); } catch (error) { if (!nativeStartAttempted && error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true })); if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason); const value = error as { mutationOutcome?: unknown; nextAction?: unknown }; const provenNoMutation = value.mutationOutcome === "none"; const preNativeFailure = !nativeStartAttempted; if (candidateView && candidateViews && (provenNoMutation || preNativeFailure)) candidateViews.cleanup(candidateView.token); const nativeCliError = asNativeReviewCliError(error); const failure = provenNoMutation ? error : preNativeFailure ? error instanceof CandidateViewError ? Object.assign(error, { candidateViewPreNative: true }) : error : Object.assign( error instanceof Error ? error : nativeCliError === undefined ? new Error(String(error)) : { name: "NativeReviewCliError", code: nativeCliError.code, diagnostics: nativeCliError.diagnostics }, { mutationOutcome: "unknown", nextAction: "review.status" }, ); return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, { cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(canonicalBaseRef === undefined ? {} : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }), ...(providerBaseTree === undefined ? {} : { baseRef: providerBaseTree, committedOnly: true }), ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection), projection: "workspace", }, retainedUntrackedSelections); } } if (rawStart.mode === REVIEW_MODE.ORDINARY) { return nativeStatusUnsupported(parameters.operation); } const idempotencyKey = requiredControllerString(parameters, "idempotencyKey"); if (typeof parameters.lineageId !== "string" || parameters.lineageId.trim().length === 0) { throw new Error("Judgment Day graph-v1 START requires lineageId"); } const input = parseStartInput(rawStart); const snapshot = captureReviewSnapshot({ cwd: defaultCwd, mode: input.mode, projection: input.projection, policyHash: input.policyHash, }); const stateInput = { lineageId: parameters.lineageId, mode: input.mode, snapshot, evidenceHash: input.evidenceHash, budget: input.budget, }; const state = createReviewState( input.parentLineageId === undefined ? stateInput : { ...stateInput, parentLineageId: input.parentLineageId }, ); const store = ReviewTransactionStore.forRepository(defaultCwd); let result: StartOperationResultV1; try { result = store.create(state, idempotencyKey); } catch (error) { if (!(error instanceof Error) || error.message !== "Graph lineage already exists") throw error; const current = store.read(parameters.lineageId!); const existing = current.request_journal.find((entry) => entry.idempotency_key === idempotencyKey); if ( existing?.operation !== REVIEW_OPERATION.START || existing.request_hash !== canonicalHash(state) || existing.status !== JOURNAL_STATUS.COMPLETED ) { throw new Error("Idempotency key was reused with a different START request; replay requires the same lineageId, idempotencyKey, and exact request"); } result = existing.canonical_result as StartOperationResultV1; } return { operation: parameters.operation, result, state }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ADVANCE) { const idempotencyKey = requiredControllerString(parameters, "idempotencyKey"); const transitionValue = requiredControllerString(parameters, "transition"); if (!isReviewTransition(transitionValue)) { throw new Error(`Review controller transition is unsupported: ${transitionValue}`); } const hasInput = parameters.input !== undefined; const hasInputPath = parameters.inputPath !== undefined; if (hasInput === hasInputPath) { throw new Error("Review controller advance requires exactly one of input or inputPath"); } const rawInput = parseControllerJson( hasInput ? requiredControllerString(parameters, "input") : readRepositoryControllerInput(requiredControllerString(parameters, "inputPath"), defaultCwd), REVIEW_CONTROLLER_OPERATION.ADVANCE, ); const store = ReviewTransactionStore.forRepository(defaultCwd); if (store.read(parameters.lineageId!).mode === REVIEW_MODE.ORDINARY) { throw new Error(GRAPH_V1_ORDINARY_READ_ONLY); } const result = store.runReducerOperation({ lineageId: parameters.lineageId, transition: transitionValue, idempotencyKey, input: rawInput as unknown as ReviewReducerInput, }); return { operation: parameters.operation, result, state: store.read(parameters.lineageId), }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.STATUS) { const rawStatus = parameters.input === undefined ? undefined : parseControllerJson(parameters.input, REVIEW_CONTROLLER_OPERATION.STATUS); const unknownField = rawStatus === undefined ? undefined : Object.keys(rawStatus).find((field) => !["baseRef", "committedOnly", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"].includes(field)); if (unknownField !== undefined) return nativeStatusInputRejection("unknown-field", unknownField); const baseRef = rawStatus?.baseRef; if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStatusInputRejection("base-ref-invalid"); if (baseRef !== undefined && rawStatus?.committedOnly !== true) return nativeStatusInputRejection("committed-only-required"); if (baseRef === undefined && rawStatus !== undefined && "committedOnly" in rawStatus) return nativeStatusInputRejection("committed-only-invalid"); const untrackedSelection = rawStatus === undefined ? {} : validateNativeStartUntrackedSelection(rawStatus); if ( rawStatus !== undefined && (untrackedSelection.reason !== undefined || (baseRef === undefined && untrackedSelection.untrackedScope === undefined)) ) return nativeStatusInputRejection(untrackedSelection.reason ?? "untracked-selection-invalid"); const retainedUntrackedSelection = cloneRetainedNativeUntrackedSelection(untrackedSelection); const effectiveUntrackedSelection = rawStatus === undefined && parameters.lineageId !== undefined ? readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId) : untrackedSelection; const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd) ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd) : undefined; const effectiveBaseRef = typeof baseRef === "string" ? baseRef : (retainedCommittedTarget?.committedOnly === true ? nativeCommittedRangeSelector(retainedCommittedTarget) : undefined); if (nativeReviewCli?.targetStatus !== undefined) { try { const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(effectiveBaseRef === undefined ? {} : { baseRef: effectiveBaseRef, committedOnly: true }), ...(effectiveUntrackedSelection.untrackedScope === undefined ? {} : effectiveUntrackedSelection), ...(signal === undefined ? {} : { signal }), }, retainedUntrackedSelections, defaultCwd); if (negotiated.transport !== undefined) { return { ...hostTransportUnavailable(parameters.operation, negotiated.transport), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), }; } const status = negotiated.status!; if ( retainedUntrackedSelection !== undefined && parameters.lineageId !== undefined && status.applicability === "current_target" && status.authority?.lineageId === parameters.lineageId ) retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId, retainedUntrackedSelection); clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state); hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status); return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, includeWorkspaceRoot ? defaultCwd : undefined), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) }; } catch (error) { return nativeOperationFailure(parameters.operation, error); } } return nativeStatusUnsupported(parameters.operation); } throw new Error(`Review controller operation is unsupported: ${parameters.operation}`); } /** @internal */ export const __testing = { parseReviewControllerParameters, parseReviewCaptureParameters, parseReviewCaptureGroupParameters, runProfilesPanelAction, openProfilesPanelLoop, notifySessionProfileProblem, resolveReviewModeGate, readEffectiveModelConfig, readReviewerModelConfig, readEffectiveModelConfigAsync, followRenamedPin, profilePinScopeNote, listAgentsFromDir, listAgentsFromDirAsync, listDiscoverableAgents, orderDiscoverableAgents, classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle, loadRuntimeGuardrailsConfig, isReversibleYoloCommand, evaluateSuperYoloCommand, yoloConfiguredRestriction, buildGentlePrompt, nativeStatusUnsupported, nativeStartRejection, nativeStatusInputRejection, executeReviewControllerOperation, executeReviewCaptureOperation, executeReviewCaptureGroupOperation, setReviewHostRelayRunnerForTesting, setReviewHostRelayGroupRunnersForTesting, clearReviewTransportProbeForTesting, routingTablePanel: renderRoutingTableForTesting, piThinkingSupport: getPiThinkingSupport, getOrchestratorPrompt, renderOrchestratorPrompt, loadReviewContractPromptFragment, readMirroredReviewContractFragment, readMirroredOddProtocolSteps, loadBackgroundSubagentsPolicy, resolveBackgroundSubagentsPolicy, renderBackgroundSubagentsReport, writeGlobalBackgroundSubagentsPolicy, parseBackgroundSubagentsPolicyFile, resolveBackgroundSubagentsCapability, readActiveToolNames, renderBackgroundSubagentsStatusLine, renderRddStatusLine, pinRddStatusLine, isValidRddModeStatus, resolveRddModeStatus, resolveRddStatusLine, RDD_STATUS_TIMEOUT_MS, RDD_STATUS_MEMO_TTL_MS, clearRddStatusMemoForTesting, async startupNegotiationSettled(): Promise { await Promise.all([...startupNegotiations]); }, readNativeReviewOutcome, recordNativeReviewOutcome, clearNativeReviewOutcomeMemoForTesting, resetTelemetryTriggerGuardForTesting, createGentleAiExtension: createGentleAiExtensionForTesting, getPiModelOptions, MODEL_CONTROL_OPTIONS, switchLiveOrchestrator, }; export interface GentleAiRuntimeDependencies { nativeReviewCli?: NativeReviewCli | null; candidateViews?: CandidateViewRegistry | null; // An injected registry gives tests and host integrations explicit ownership; // normal package registrations share the module-local process-memory registry. pendingReviewConsentRegistry?: PendingReviewConsentRegistry; // The environment the session's child processes inherit; tests inject a // plain object so the handshake declaration is observable without // touching the test runner's own process.env. processEnv?: NodeJS.ProcessEnv; // Package-owned children use this parent-bound channel only to ask whether // their own pending ordinary START may replay a grant locally. childStandingReviewPermissionClient?: Pick; // gentle-pi#677: test-only seams for the telemetry trigger. Production // leaves both undefined: the real package-local resolveGentleAiBinary() // and the real detached child_process spawn run. resolveTelemetryTriggerBinary?: () => string; telemetryTriggerSpawn?: TelemetryTriggerSpawn; // Test-only seam for the foreground `/gentle:telemetry` slash command's // bounded exec; production leaves this undefined and uses the real node // exec-file adapter shared with the rest of the extension. telemetryExecFileAdapter?: ExecFileAdapter; /** Test-only inference seam; production always uses the active session model. */ autoPermissionClassifier?: AutoRuntimeClassifier; // The backstop samples the measured context at most once per window (its measure rebuilds the whole // session projection). Tests inject a clock or a zero window; production resolves the env and default. contextBackstopMeasure?: { now?: () => number; minIntervalMs?: number }; } export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void { return createGentleAiExtensionForTesting(dependencies); } function createGentleAiExtensionForTesting( dependencies: GentleAiRuntimeDependencies = {}, ): (pi: ExtensionAPI) => void { const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli; const childStandingReviewPermissionLease = dependencies.childStandingReviewPermissionClient === undefined ? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env) : undefined; const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client; const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry; const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary; const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter(); return function gentleAi(pi: ExtensionAPI): void { declareReviewRelayHandshake(dependencies.processEnv ?? process.env); const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback"); const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews; const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events); const permissionEnvironment = dependencies.processEnv ?? process.env; let autoPermissions: AutoPermissionRuntime | undefined; const yolo = registerYoloSessionPolicy(pi, permissionEnvironment, { onChange: () => autoPermissions?.cancelPending() }); let classifier: AutoPermissionClassifier | undefined; autoPermissions = new AutoPermissionRuntime(pi, yolo, dependencies.autoPermissionClassifier ?? { run: request => { classifier ??= new AutoPermissionClassifier(request.modelRegistry); return classifier.run(request); }, cancel: () => classifier?.cancel(), }, { configHome: gentlePiConfigHome(permissionEnvironment) }); const auto = autoPermissions; pi.on("tool_result", (event, ctx) => { if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.toolResult(event, ctx); }); pi.on("input", (event, ctx) => { if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") auto.input(event, ctx); return { action: "continue" }; }); const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => { try { (context.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus?.( REVIEW_SESSION_PERMISSION_STATUS_KEY, active ? REVIEW_SESSION_PERMISSION_STATUS_TEXT : undefined, ); } catch { /* Status is nonblocking and never permission authority. */ } }; const capturePermissionIdentity = (context: ExtensionContext, cwd: string = context.cwd): Promise => captureReviewSessionIdentity({ ...context, cwd }, permissionEnvironment); const refreshReviewSessionPermissionStatus = async (context: ExtensionContext): Promise => { const identity = await capturePermissionIdentity(context); setReviewSessionPermissionStatus(context, identity !== undefined && hasReviewSessionPermission(identity)); return identity; }; const revokeCurrentReviewSessionPermission = (context: ExtensionContext): boolean => { const coordinates = reviewSessionManagerAndId(context); const revoked = coordinates === undefined ? false : revokeReviewSessionPermissionsForSession(coordinates.manager, coordinates.sessionId); setReviewSessionPermissionStatus(context, false); return revoked; }; const revokeCurrentRepositoryReviewSessionPermission = async (context: ExtensionContext): Promise => { const identity = await capturePermissionIdentity(context); const revoked = identity === undefined ? false : revokeReviewSessionPermission(identity); setReviewSessionPermissionStatus(context, false); return revoked; }; const statusTiming = new StatusTimingDiagnostics(); const reviewSidebar = createReviewSidebarPublisher(pi); const sessionProfiles = createSessionProfileIntegration(); pi.on("session_before_fork", async (event, ctx) => { await waitCurrentSessionProfileSelection(ctx.sessionManager); sessionProfiles.beforeFork(ctx.sessionManager, { entryId: event.entryId, position: event.position }); }); pi.on("session_before_tree", async (_event, ctx) => { await waitCurrentSessionProfileSelection(ctx.sessionManager); }); const observedReviewTool: typeof reviewSidebar.tool = (definition) => statusTiming.tool(reviewSidebar.tool(definition)); pi.on("session_tree", (_event, ctx) => { auto.reset(); reviewSidebar.reset(ctx); statusTiming.reset(ctx); readSessionProfileAuthority(ctx.sessionManager); if (ctx.hasUI) notifySessionProfileProblem(ctx); }); // Lean RDD-off review loadout (applyRddOffReviewPrompt): every // before_agent_start sets it from the pinned line before Pi prepares that // run's tool loadout, so gentle_review's prepareLoadout reads this run's value. let rddOffReviewLoadout = false; let reminderSessionActive = true; let reminderEpoch = 0; let reminderManager: ExtensionContext["sessionManager"] | undefined; let unbindPreparation: (() => void) | undefined; pi.on("session_shutdown", (event, context) => { // A pending Enter selection settles before the profile owner shuts down; // without one the whole shutdown stays synchronous, so terminal cleanup // (child permission transport, sidebars) is never deferred a turn. if (hasPendingSessionProfileSelection(context.sessionManager)) { return waitCurrentSessionProfileSelection(context.sessionManager).then(() => shutdownSession(event, context)); } shutdownSession(event, context); }); function shutdownSession(event: SessionShutdownEvent, context: ExtensionContext): void { if (sessionProfiles.isCurrent(context.sessionManager)) { clearSessionProfileBinding(context.sessionManager.getSessionId()); sessionProfiles.shutdown(context.sessionManager, event); } pinnedRddStatusLines.delete(pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey)); rddOffReviewLoadout = false; auto.reset(); yolo.reset(context); reviewSidebar.reset(); statusTiming.reset(); reminderSessionActive = false; reminderEpoch += 1; unbindPreparation?.(); unbindPreparation = undefined; reminderManager = undefined; // Pi tears down this registry on reload as well as session replacement/quit. try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ } const reason = (event as { reason?: unknown }).reason; if (reason !== "reload") { if (childStandingReviewPermissionLease !== undefined) childStandingReviewPermissionLease.closeIfCurrent(); else childStandingReviewPermission?.close(); revokeCurrentReviewSessionPermission(context); } const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey); cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey); processRetainedNativeStatusSelections.delete(sessionKey); processAgentEndSubagentDepth.delete(sessionKey); } // gentle-pi ODD input phase labels: the explicit half of the bounded ODD // phase signal for the Gentle prompt's working label. Gentle Shell infers // the phase from the primary session's tool activity // (lib/odd-phase-inference.ts); this tool lets the orchestrator refine it // at ODD protocol transitions tools cannot show, and its report is // "explicit" so a following read-only tool call never downgrades it. Never // inferred from prose. It is session-scoped in lib/odd-phase.ts: a // background/child agent runs as its own OS process with its own module // state, so it can never see or override the primary session's phase. const hiddenOddPhaseToolComponent = { render: (_width: number): string[] => [], invalidate() {} }; pi.registerTool({ name: "gentle_odd_phase", renderShell: "self", label: "Gentle ODD Phase", description: "Refine the primary session's current ODD phase for the Gentle prompt's working label, which is otherwise inferred automatically from tool activity. Best-effort UI only; never a source of truth for orchestration logic.", promptSnippet: "Refine the automatically inferred working label with authorizing/exploring/researching/deciding/planning/implementing/checking/closing only at real ODD phase transitions of the primary turn; never poll or report per tool call.", promptGuidelines: [ `phase must be exactly one of ${ODD_PHASES.join(", ")}, or "clear" to leave the current phase before its turn ends. Call this only when the ODD phase actually changes for the primary session's active turn, not on every tool call or thought.`, "Never call this from a subagent or background/child task; it reports only the primary orchestrator's own phase, and a child's session id can never override the parent's label.", ], parameters: { type: "object", additionalProperties: false, required: ["phase"], properties: { phase: { type: "string", enum: [...ODD_PHASES, "clear"], description: "The reported ODD phase, or 'clear' to leave the current phase." }, }, } as const, executionMode: "parallel", // The prompt editor owns the success indicator. An empty self-rendered // call avoids Pi's default transcript card while preserving error output. renderCall() { return hiddenOddPhaseToolComponent; }, renderResult(result, _options, theme, context) { if (!context.isError) return hiddenOddPhaseToolComponent; const message = result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n"); return new Text(theme.fg("error", sanitizeTerminalText(message || "ODD phase report failed.")), 0, 0); }, async execute(_toolCallId, parameters, _signal, _onUpdate, ctx) { const phase = (parameters as { phase?: unknown }).phase; const sessionId = ctx.sessionManager.getSessionId(); // Pi's own contract (docs/extensions.md, Signaling errors): throw to // mark a tool execution as failed (sets isError: true on the result); // returning a value -- including an isError property on it -- never // sets that flag. if (!sessionId) throw new Error("No active session to report an ODD phase for; nothing changed."); // An invalid token never resets state: only the explicit "clear" token // leaves the current phase. This keeps a malformed report from wiping // out an otherwise-accurate label for the rest of the turn. if (phase === "clear") { oddPhaseRegistry.clear(sessionId); return { content: [{ type: "text", text: "ODD phase cleared." }], details: { phase: undefined } }; } if (!isOddPhase(phase)) { throw new Error(`Invalid ODD phase; use one of ${ODD_PHASES.join(", ")}, or "clear". The previously reported phase, if any, is unchanged.`); } const reported = oddPhaseRegistry.report(sessionId, phase, "explicit"); return { content: [{ type: "text", text: `ODD phase reported: ${reported}` }], details: { phase: reported } }; }, }); const GENTLE_TIMED_TOOLS = new Set(["gentle_review_scope", "gentle_review_capture_group", "gentle_review_capture", "gentle_review"]); let elapsedTiming: GentleAiElapsedTimingLedger | undefined; const recordReviewTiming = (event: { toolCallId: string; toolName: string }, endedAt?: number): void => { const ledger = elapsedTiming; if (!ledger || !GENTLE_TIMED_TOOLS.has(event.toolName)) return; try { if (endedAt === undefined) ledger.recordStart(event.toolCallId, Date.now()); else ledger.recordEnd(event.toolCallId, endedAt); } catch { /* Timing persistence is best-effort and never breaks the tool event. */ } }; pi.on("tool_execution_start", (event) => recordReviewTiming(event)); pi.on("tool_execution_end", (event) => recordReviewTiming(event, Date.now())); const timingContext = (context: GentleAiRenderContext | undefined): GentleAiRenderContext | undefined => elapsedTiming && context ? { ...context, elapsedTiming } : context; pi.registerTool({ name: "gentle_review_scope", renderShell: "self", label: "Gentle Review Scope", description: "Read one bounded, integrity-checked page of the controller-owned frozen changed scope. This read-only tool never inspects the ambient or candidate tree.", parameters: REVIEW_SCOPE_PARAMETERS, executionMode: "parallel", renderCall(_args, theme, context) { return renderGentleAiLifecycleCall( "review scope", theme, timingContext(context as GentleAiRenderContext | undefined), ); }, renderResult(result, options, theme, context) { return renderGentleAiResult(result, options, theme, timingContext(context as GentleAiRenderContext | undefined)); }, async execute(_toolCallId, parameters) { const input = parameters as ReviewScopeParameters; const details = readCandidateContextManifestPage(input.manifest, input.sha256, input.cursor ?? 0); return { content: [{ type: "text", text: JSON.stringify(details) }], details }; }, }); // The lens a reviewer capture runs is inside its collect binding, so the // card can say "review capture · risk" instead of a bare operation name. const lensLabel = (lens: unknown): string | undefined => typeof lens === "string" && lens.length > 0 ? lens.replace(/^review-/, "") : undefined; const collectBindingLens = (binding: unknown): string | undefined => { if (typeof binding !== "string") return undefined; try { const parsed = JSON.parse(binding) as Record; const subject = (parsed.artifactSubject ?? parsed.artifact_subject) as Record | undefined; return lensLabel(subject?.lens); } catch { return undefined; } }; const withLenses = (operation: string, lenses: readonly (string | undefined)[]): string => { const named = lenses.filter((lens): lens is string => lens !== undefined); return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`; }; pi.registerTool(observedReviewTool({ name: "gentle_review_capture_group", renderShell: "self", label: "Gentle Review Capture Group", description: "Capture one complete provider-issued materialize reviewer group. It validates the exact ordered current collect set, forecasts its bounded model cost, runs reviewers concurrently, and admits outputs one at a time in provider order.", promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.", promptGuidelines: [ "Pass only lineageId, the complete ordered collectBindings array from one current STATUS result, and reviewerRunAcknowledged after its forecast. Never mix, reorder, duplicate, or partially select bindings.", "The group materializes and runs independent reviewers concurrently, but rechecks STATUS before every provider-ordered submission. It stops on a closure, correction, drift, or uncertain capture outcome; it never follows another transition or replays a prepared output.", ], parameters: REVIEW_CAPTURE_GROUP_PARAMETERS, executionMode: "sequential", renderCall(args, theme, context) { const bindings = (args as { collectBindings?: unknown }).collectBindings; const lenses = Array.isArray(bindings) ? bindings.map(collectBindingLens) : []; return renderGentleAiLifecycleCall(withLenses("review capture group", lenses), theme, timingContext(context as GentleAiRenderContext | undefined)); }, renderResult(result, options, theme, context) { return renderGentleAiResult(result, options, theme, timingContext(context as GentleAiRenderContext | undefined)); }, async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { if (signal?.aborted) throw reviewCancellation("Review capture group was cancelled"); const details = await executeReviewCaptureGroupOperation( parameters, ctx.cwd, nativeReviewCli, signal, candidateViews, ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)), true, ctx.modelRegistry, // The live session id rides into the reviewer side-call so an // OpenCode-routed completion carries its attribution headers // (pi adds those inside the main agent loop; this is not that loop). reviewSessionManagerAndId(ctx)?.sessionId, () => pi.getSettings?.().transport, ); return { content: [{ type: "text", text: JSON.stringify(details) }], details }; }, })); pi.registerTool(observedReviewTool({ name: "gentle_review_capture", renderShell: "self", label: "Gentle Review Capture", description: "Capture exactly one provider-issued ordinary native review collect slot. This is not a controller operation: it validates one opaque collect binding against current target-scoped STATUS, executes at most one capture, and never follows a transition.", promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.", promptGuidelines: [ "Pass only lineageId, the JSON-serialized exact collectBinding from current STATUS, and the route-specific optional acknowledgement or correctionLines value. Never compose provider argument tokens, prompts, results, verdicts, or lens arrays.", "A materialize reviewer slot first forecasts one model run; re-submit that same exact binding with reviewerRunAcknowledged: true to authorize one host relay. Correction-plan slots require correctionLines inside the provider-issued bounds, counted in diff lines (one replaced source line is one deletion plus one addition) — a different unit from the frozen logical correction budget. A refuter or targeted-validator slot forecasts and runs the same way when the provider renders it host-mediated; an older provider's self-contained refuter/validation vector still executes exactly once as provider-rendered.", "A native terminal closure or nonterminal capture returns directly. Do not expect automatic STATUS, FINALIZE, receipt, delivery, or another capture; call fresh STATUS before any next capture.", ], parameters: REVIEW_CAPTURE_PARAMETERS, executionMode: "sequential", renderCall(args, theme, context) { return renderGentleAiLifecycleCall( withLenses("review capture", [collectBindingLens((args as { collectBinding?: unknown }).collectBinding)]), theme, timingContext(context as GentleAiRenderContext | undefined), ); }, renderResult(result, options, theme, context) { return renderGentleAiResult(result, options, theme, timingContext(context as GentleAiRenderContext | undefined)); }, async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { if (signal?.aborted) throw reviewCancellation("Review capture was cancelled"); const details = await executeReviewCaptureOperation( parameters, ctx.cwd, nativeReviewCli, signal, candidateViews, ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)), true, ctx.modelRegistry, // The live session id rides into the reviewer side-call so an // OpenCode-routed completion carries its attribution headers // (pi adds those inside the main agent loop; this is not that loop). reviewSessionManagerAndId(ctx)?.sessionId, () => pi.getSettings?.().transport, ); return { content: [{ type: "text", text: JSON.stringify(details) }], details, }; }, })); pi.registerTool(observedReviewTool({ name: "gentle_review", renderShell: "self", label: "Gentle Review Controller", description: "Inspect and recover review authority and start native ordinary review. Ordinary capture is available only through the separate gentle_review_capture tool. Review outcomes never authorize delivery: commit, push, pull-request, and release commands follow ordinary repository policy. RESET/RECOVER remain destructive and are executed by the audited native CLI.", promptSnippet: "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot", promptGuidelines: [ 'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\",\\"lenses\\":[\\"reliability\\"],\\"lensesReason\\":\\"\\"}": pick every pertinent 4R lens (risk, resilience, readability, reliability) for what you touched and how; an explicit baseRef must be paired with committedOnly: true to request a committed range, while policyPath remains repository-local. policyHash is legacy compact-only. The controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget; the frozen correction budget counts logical corrections, while correction-plan correctionLines count diff lines (one replaced source line is one deletion plus one addition).', 'An inspect blocked on the intended-untracked selection returns nextStep naming the exact continuation: call select-intended-untracked with the returned selectionBinding, or call inspect again with top-level untrackedScope ("exclude", or "select" with intendedUntracked) to resolve the round trip in one call; the retained selection is adopted by the next plain START.', "Use RECONCILE_AUTHORITY only to quarantine one invalid native recovery successor. Supply exact predecessorLineage, expectedPredecessorRevision, successorLineage, expectedSuccessorRevision, actor, and reason values; Pi derives and displays the seven-line native authorization binding for fresh UI approval. The predecessor stays untouched, native returns the durable audit record, and Pi never falls back to RESET or RECOVER.", "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision. ABANDON accepts only lineage, actor, and reason (reason must be the native enum `operator_disposition` or `retired_schema`): Pi freshly reads the native authority inventory, derives the lineage's revision, snapshot identity, and discarded-work summary, and renders the exact eight-line binding before interactive approval; caller-supplied inventory-derived fields are rejected. QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.", "Lens, refuter, and validator verdicts are admitted natively, never Pi-authored. Use gentle_review_capture with exactly one current provider-owned collectBinding for ordinary native capture; it never follows another transition.", "For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim`; only RESET carries the legacy repositoryId, commonDirHash, inventoryHash, and confirmation challenge. RECOVER routes to native `gentle-ai review recover` with exactly six inputs: predecessorLineage, expectedPredecessorRevision, successorLineage, disposition, actor, and reason. Never send RECOVER the reset challenge and never send it a maintainerAuthorization: Pi reads fresh native target status, pins the predecessor lineage, revision, provider-selected disposition, and target identity, derives the exact six-line native authorization binding, displays it for fresh UI approval, and re-reads status before mutating. Negotiated target status supplies the sole accepted recovery disposition, and a caller-supplied substitute is rejected. Treat a native-input-required envelope as a request for exact values, never as permission to invent them. After a committed native recovery record, INSPECT before any fresh ordinary START.", "A consent-required START may be resolved inside the eligible interactive Pi host. Its third UI action is host-owned: it runs this envelope's exact provider grant once and allows later fresh validated envelopes only for the same live SessionManager, nonempty session ID, and canonical Git common-directory identity, including sibling worktrees; an unrelated repository requires a new explicit human grant. Revoke removes the current repository grant, while nonreload replacement, quit, and process exit remove all session grants; reload preserves them. It grants no provider mode, verdict, acknowledgement, maintenance, delivery, or cross-repository authority. A package-owned child may ask its parent only with the canonical digest of its exact pending target; the parent binds that digest to the task repository and fails closed otherwise. If the tool returns an unresolved envelope, present the original two provider choices without changing machine tokens, commands, target IDs, or invocations; never add the host action to the decoded provider envelope. After one explicit relayed human answer, call answer-consent exactly once with only consentBinding and answer (`granted` or `declined`). Never create host permission from tool arguments, model prose, child/headless responses, or an uncertain native result. A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.", "Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.", 'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"","committedOnly":true}), to supply a fallback writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state that the native review was declined or unavailable for this candidate ({"nativeReviewOutcome":"declined|unavailable|unknown"}), or to raise a candidate you know is high risk ({"escalate":{"item":1-6,"reason":""}}, item from the Task Size high-risk list; it is the only way to get an independent verifier, keeps nativeRisk, returns agentEscalation, and can never lower a tier). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); caller writerModelId/writerEffort are only a fallback when no runtime evidence exists (caller), otherwise the profile is small (fallback). The writer profile is reported but never adds a verifier. The plan follows its risk tier whatever RDD reads and whatever the native review outcome: the native review is an additional outside view and never removes a verifier the tier requires (verify-always-rdd-high S1). ASSESS derives closed only from the native candidate.consumed fact for this exact candidate, written natively when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown; the outcome only explains the plan. A decline or unavailable review recorded by this process is bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state, and wins over closure. The result\'s outcome_source (explicit|derived|unknown) and writerProfileSource (runtime|caller|fallback) state which evidence produced each value. When native reports them, ASSESS projects reviewDue, reviewDueReason, candidate.consumed, and the native continuation verbatim; older binaries omit them and nothing is invented. Relay that continuation unchanged; never rebuild it. A native code review is not a substitute for applicable functional checks: tests, builds, and functional verification such as browser checks for UI changes still run when applicable. A failed or unavailable native assessment (nativeRisk "unassessable"), or a native high, informs only: the plan adds the independent verifier only when escalate names a Task Size high-risk item. This never mutates review authority state.', ], parameters: REVIEW_CONTROLLER_PARAMETERS, executionMode: "sequential", // Hidden declarations stay active and callable; Pi leaves their // declarations and guidelines out of the request. prepareLoadout(loadout) { if (!rddOffReviewLoadout) return undefined; const declared = new Set(loadout.declared.map((tool) => tool.name)); return { descriptions: { gentle_review: RDD_OFF_REVIEW_DESCRIPTION }, hiddenDeclarations: RDD_OFF_HIDDEN_REVIEW_TOOLS.filter((name) => declared.has(name)), }; }, renderCall(args, theme, context) { return renderGentleAiLifecycleCall( reviewToolOperationPath(args), theme, timingContext(context as GentleAiRenderContext | undefined), ); }, renderResult(result, options, theme, context) { return renderGentleAiResult(result, options, theme, timingContext(context as GentleAiRenderContext | undefined)); }, async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { if (signal?.aborted) throw reviewCancellation("Review controller operation was cancelled"); await authorizeDestructiveReviewOperation(parameters, ctx); const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!; // Snapshot before native awaits: a concurrent own write is a new generation. const acknowledgementEpoch = reminderEpoch; let acknowledgementRoot: string | undefined; let acknowledgementMutation: string | undefined; try { const parsed = parseReviewControllerParameters(parameters); if (parsed.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED) { acknowledgementRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId); acknowledgementMutation = pendingReviewMutation(ctx.sessionManager, acknowledgementRoot); } } catch { /* Controller validation owns invalid parameters and unavailable roots. */ } let details = await executeReviewControllerOperation( parameters, ctx.cwd, nativeReviewCli, signal, candidateViews, ctx, retainedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, ); if (details.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED && details.outcome === "native-approved-acknowledgement-completed" && details.status === "closed" && details.authority === "burned" && typeof details.target_identity === "string") { try { if (reminderSessionActive && acknowledgementEpoch === reminderEpoch && acknowledgementRoot && pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) === sessionKey) { consumeReviewMutation(pi, ctx.sessionManager, acknowledgementRoot, acknowledgementMutation, "acknowledged", details.target_identity); } } catch { /* Bookkeeping cannot hide a confirmed native burn. */ } } if ( isHostReviewConsentEligibleOperation(parameters) && details.outcome === "native-review-consent-required" && typeof details.consent_binding === "string" ) { const resolved = pendingReviewConsentRegistry.resolve(details.consent_binding); const pending = resolved?.pending; const eligiblePending = pending !== undefined && isPiConsentV3(pending.consent) ? pending : undefined; const answerPendingConsent = async (answer: "granted" | "declined") => executeReviewControllerOperation( { operation: REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, input: JSON.stringify({ consentBinding: eligiblePending!.id, answer }), workspaceRoot: eligiblePending!.authorityCwd, }, ctx.cwd, nativeReviewCli, signal, candidateViews, ctx, retainedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, ); let permissionWorkspaceRoot: string | undefined; try { const parsed = parseReviewControllerParameters(parameters); permissionWorkspaceRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId); } catch { // The successful native operation above remains authoritative; an // unresolvable local binding simply cannot consume host permission. } const initialIdentity = permissionWorkspaceRoot === undefined ? undefined : await capturePermissionIdentity(ctx, permissionWorkspaceRoot); if (eligiblePending !== undefined && initialIdentity === undefined) { // A package child has no local standing grant. It may ask its // inherited parent only for the canonical repository identity of // this exact pending target, then replay this local binding once. const repositoryIdentity = permissionWorkspaceRoot === undefined ? undefined : await resolveCanonicalGitRepositoryIdentity(permissionWorkspaceRoot); if (repositoryIdentity !== undefined && await childStandingReviewPermission?.requestAuthorization(repositoryIdentity) === true) details = await answerPendingConsent("granted"); } else if (eligiblePending !== undefined && initialIdentity !== undefined) { const initialEpoch = reviewSessionPermissionEpoch(initialIdentity); const permissionAlreadyActive = hasReviewSessionPermission(initialIdentity); const selection = initialEpoch === undefined ? undefined : permissionAlreadyActive ? { kind: "host-session" as const } : await presentReviewConsentUi(ctx, eligiblePending.consent); if (selection !== undefined) { const confirmedIdentity = await capturePermissionIdentity(ctx, permissionWorkspaceRoot); if (initialEpoch !== undefined && confirmedIdentity !== undefined && sameReviewSessionIdentity(initialIdentity, confirmedIdentity) && reviewSessionPermissionEpoch(confirmedIdentity) === initialEpoch) { const answer = selection.kind === "provider" ? selection.answer : "granted"; details = await answerPendingConsent(answer); if (!permissionAlreadyActive && selection.kind === "host-session" && completedGrantedReviewConsent(details)) { if (grantReviewSessionPermission(confirmedIdentity, initialEpoch)) { setReviewSessionPermissionStatus(ctx, true); try { ctx.ui.notify("Reviews are allowed for this Pi session and this Git repository.", "info"); } catch { /* Nonblocking indication only. */ } } else { try { ctx.ui.notify("This review started, but the in-memory session permission registry was incompatible, so later candidates will ask again.", "warning"); } catch { /* Best effort. */ } } } } } } } return { content: [{ type: "text", text: JSON.stringify(details) }], details, }; }, })); pi.on("session_start", async (event, ctx) => { const profileSessionId = ctx.sessionManager?.getSessionId?.(); if (profileSessionId) clearSessionProfileBinding(profileSessionId); sessionProfiles.start(ctx.sessionManager, (type, data) => pi.appendEntry(type, data), event); readSessionProfileAuthority(ctx.sessionManager); if (ctx.hasUI) notifySessionProfileProblem(ctx); auto.reset(); yolo.reset(ctx); // Package-owned children reset local state but never initialize primary defaults. if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") await yolo.mode(ctx); reviewSidebar.reset(ctx); statusTiming.reset(ctx); elapsedTiming = new GentleAiElapsedTimingLedger(ctx.sessionManager, pi); reminderSessionActive = true; reminderEpoch += 1; unbindPreparation?.(); reminderManager = ctx.sessionManager; // gentle-shell#1690: a delegated child runs in the parent's resolved // worktree. Repository preparation, review negotiation, asset install and // model config belong to the parent session and write shared state. The // standing review grant is host-only (a child never captures an identity), // so revoke/refresh have nothing to act on; the child relay is load-time. // Only that parent-owned work is skipped: the session-local resets above, // the dev-binary notice, and any step added after this call, still run in // children. if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1") await startParentSession(event, ctx); else await surfaceDevBinaryOverride(ctx); }); // Loud, every session: an active dev-binary override means this session // runs an unpinned gentle-ai. One visible startup notice: the gentle-shell // 🌹 card owns the announcement when it can render (shell enabled with UI); // this toast is only the fallback for when the card is unavailable. The // hasUI guard stays: headless contexts have no toast to show. const surfaceDevBinaryOverride = async (ctx: ExtensionContext): Promise => { const devBinaryToastFallback = ctx.hasUI && !shellEnabled(); try { const devBinary = await describeDevBinaryOverride(); if (devBinaryToastFallback && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning"); if (devBinaryToastFallback && 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"); } }; const startParentSession = async (event: unknown, ctx: ExtensionContext): Promise => { const epoch = reminderEpoch; const manager = ctx.sessionManager; const originalCwd = manager?.getCwd?.() ?? ctx.cwd; unbindPreparation = manager ? bindSessionRepositoryPreparation(manager, originalCwd, async (root, current, signal) => { if (!current() || nativeReviewCli?.targetStatus === undefined || safeBootstrapDirectory(originalCwd) !== root) return false; if (!await nativeRepositoryPreparationEnabled(nativeReviewCli, root, signal) || !current() || safeBootstrapDirectory(originalCwd) !== root) return false; try { const existing = resolveSessionWorktree(originalCwd, originalCwd); if (existing) return existing.root === root; const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); const retained = processRetainedNativeStatusSelections.get(sessionKey) ?? new Map(); const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: root, signal }, retained, root); const identity = resolveSessionWorktree(originalCwd, originalCwd); return negotiated.status !== undefined && current() && safeBootstrapDirectory(originalCwd) === root && identity?.root === root; } catch { return false; } }, () => reminderSessionActive && reminderEpoch === epoch && reminderManager === manager) : undefined; const homeRoot = homeRootedRepository(ctx.cwd); if (homeRoot !== undefined && ctx.hasUI && !homeRepositoryWarnings.has(homeRoot)) { homeRepositoryWarnings.add(homeRoot); try { ctx.ui.notify(HOME_REPOSITORY_WARNING, "warning"); } catch { /* Nonblocking notice only. */ } } try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ } const reason = (event as { reason?: unknown }).reason; if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx); await refreshReviewSessionPermissionStatus(ctx); await surfaceDevBinaryOverride(ctx); try { const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]); migrateLegacyProjectModelOverrides(ctx.cwd); const modelResult = await applySavedModelConfig(ctx); if (ctx.hasUI && modelResult.invalidPath) { ctx.ui.notify( `el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:profiles again.`, "warning", ); return; } if (ctx.hasUI && modelResult.updated > 0) { ctx.ui.notify( `el Gentleman applied saved model config to ${modelResult.updated} agent(s). Global delegation/review assets ready: ${installResult.agents} new agent(s), ${installResult.chains} new chain(s), ${installResult.support} new support file(s).`, "info", ); } } catch (error) { if (ctx.hasUI) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify( `el Gentleman model config sweep failed: ${message}`, "warning", ); } } // Keep the startup transport negotiation, but do not treat its target as // an ownership baseline: reload may have outstanding durable receipts. // A home-rooted repository skips it: native STATUS would scan the whole home. if (homeRoot !== undefined) return; // Pi awaits session_start handlers serially, so this runs after startup // returns: two native spawns no longer hold the shell's first render. const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); const negotiation = resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey, AbortSignal.timeout(STARTUP_NEGOTIATION_TIMEOUT_MS)) .then(() => undefined, () => undefined) // Best-effort only; never surface or throw. .finally(() => { startupNegotiations.delete(negotiation); }); startupNegotiations.add(negotiation); }; pi.on("before_agent_start", async (event, ctx) => { const isNamedAgent = isNamedAgentStartEvent(event); const isChildSession = permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1"; const isPrimarySession = !isNamedAgent && !isChildSession; const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); if (isNamedAgent || isChildSession) { processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1); } else { processAgentEndSubagentDepth.set(subagentDepthKey, 0); } // gentle-pi#677: nudge gentle-ai's own telemetry trigger for a primary // session only, reusing the exact isNamedAgent predicate that // decides the orchestrator prompt below. At most one attempt per // process regardless of how many primary-session before_agent_start // events this process observes; a missing/old binary or a spawn error // must never affect activation, so every failure is swallowed silently. if (isPrimarySession && !processTelemetryTriggerAttempted) { processTelemetryTriggerAttempted = true; try { const executable = resolveTelemetryTriggerBinary(); spawnTelemetryTrigger({ executable, cwd: ctx.cwd, env: dependencies.processEnv ?? process.env, spawn: dependencies.telemetryTriggerSpawn, }); } catch { // Best-effort only; never surfaced and never affects activation. } } // gentle-pi#661: the RDD status line (and the rest of the gentle prompt) // is built only for the primary session, mirrored on the // reviewContractPrompt condition below -- named agents never reach // this branch, so no line is resolved or computed for them. // resolveRddStatusLine never throws and never hangs past // RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native // binary keeps the last known line for this cwd, or renders the // fail-closed "unknown" line when none was ever read (gentle-shell#1029). const rddStatusSignal = AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS); // A startup read still in flight fills the memo: wait for it within the // same deadline instead of spawning the same STATUS a second time. const inFlightModeRead = isPrimarySession ? startupModeReads.get(ctx.cwd) : undefined; if (inFlightModeRead !== undefined) await Promise.race([inFlightModeRead, rddAbortRejection(rddStatusSignal)]).catch(() => undefined); const rddStatusLine = !isPrimarySession ? undefined : pinRddStatusLine( pinnedRddStatusLines.get(subagentDepthKey), await resolveRddStatusLine(nativeReviewCli, ctx.cwd, rddStatusSignal, undefined, ctx), ); if (rddStatusLine !== undefined) pinnedRddStatusLines.set(subagentDepthKey, rddStatusLine); rddOffReviewLoadout = isPrimarySession && isRddStatusLineOff(rddStatusLine); if (rddOffReviewLoadout) applyRddOffReviewPrompt(event.systemPromptOptions as ReviewToolPromptOptions | undefined); const gentlePrompt = rddStatusLine === undefined ? "" : `\n\n${buildGentlePrompt( readPersonaMode(ctx.cwd), ctx.cwd, readActiveToolNames(pi), rddStatusLine, ctx.mode, )}`; // gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider // contract bundle's review execution contract for the primary session // only, and only when a native review CLI is actually present. // gentle-shell#1494: skip it while RDD reads off, since no review can // start; on and unknown keep it so the reviewed path never loses it. const reviewContractPrompt = isPrimarySession && nativeReviewCli !== null && !isRddStatusLineOff(rddStatusLine) ? (() => { const fragment = loadReviewContractPromptFragment(ctx); return fragment === null ? "" : `\n\n${fragment}`; })() : ""; // gentle-shell#1485: pi-claude-bridge drops a handler-returned systemPrompt // and forwards only systemPromptOptions, so the harness is delivered // through the mutable appendSystemPrompt section instead of a replacement. appendSystemPromptOnce(event.systemPromptOptions, `${gentlePrompt}${reviewContractPrompt}`); updateYoloPrompt(event.systemPromptOptions, isPrimarySession && await yolo.active(ctx), isPrimarySession && await yolo.superActive(ctx)); updateAutoPrompt(event.systemPromptOptions, isPrimarySession && await yolo.mode(ctx) === "auto"); return undefined; }); // gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent could finish // an authorized implementation and report completion without ever running // the review STATUS preflight or offering the consent question. This // handler is read-only and idempotent: it never runs START, never answers // consent, or chooses a partial candidate. Durable own-mutation receipts // gate STATUS and consume only the generation captured before that await. pi.on("agent_end", async (_event, ctx) => { if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1") return; if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return; if (ctx.hasUI !== true || !reminderSessionActive) return; const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0; if (subagentDepth > 0) { processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1); return; } const root = resolveSessionWorktree(ctx.cwd, ctx.cwd)?.root; if (!root) return; let mutation: string | undefined; try { mutation = pendingReviewMutation(ctx.sessionManager, root); } catch { return; } if (!mutation) return; const epoch = reminderEpoch; const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey); if (status === undefined || !reminderSessionActive || epoch !== reminderEpoch || pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) !== sessionKey) return; // Another concurrent end or ACK may already have consumed this prefix. if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return; if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return; // verify-always-rdd-high S2: automatic review is for high risk only, so a // candidate the native assessment reports not review_due earns no nudge. // An assessment that cannot answer, or an older binary without // review_due, keeps the nudge. if (nativeReviewCli.assess !== undefined) { let reviewDue: boolean | undefined; try { reviewDue = (await nativeReviewCli.assess({ cwd: root })).reviewDue; } catch { reviewDue = undefined; } if (reviewDue === false) return; if (!reminderSessionActive || epoch !== reminderEpoch || pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) !== sessionKey) return; if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return; } const targetIdentity = status.targetIdentity; pi.sendMessage( { customType: "gentle-pi.review-preflight", content: renderAgentEndReviewPreflightMessage(targetIdentity), display: true, }, { triggerTurn: true, deliverAs: "followUp" }, ); consumeReviewMutation(pi, ctx.sessionManager, root, mutation, "nudged", targetIdentity); }); const recordWriteResult = async (event: ToolResultEvent, ctx: ExtensionContext): Promise => { if (!reminderSessionActive || (reminderManager && reminderManager !== ctx.sessionManager) || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return; if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return; try { const identity = resolveSessionWorktree(event.input.path, ctx.cwd); const prospectiveRoot = identity ? undefined : sourcePathWithinProject(event.input.path, ctx.cwd); const root = identity?.root ?? prospectiveRoot; // Persist the observed own write before any await. Preparation is not // mutation evidence, and cannot invent a pre-write Changes baseline. if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId, ...directWriterProfile(pi, ctx) }); if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && prospectiveRoot && !resolveSessionWorktree(ctx.cwd, ctx.cwd)) await prepareBoundSessionRepository(ctx.sessionManager, ctx.sessionManager.getCwd?.() ?? ctx.cwd, ctx.signal); } catch { /* Preparation and receipt persistence cannot change a successful tool result. */ } }; const contextBackstopSignal = createContextBackstopSignal(permissionEnvironment); // One projection rebuild per session per window, not one per tool result: the measured context is // sampled (see createThrottledContextMeasure), so long sessions stop paying an O(session) scan on // every tool result while the backstop keeps its once-per-crossing notice semantics. const contextBackstopMeasure = createThrottledContextMeasure({ now: dependencies.contextBackstopMeasure?.now, minIntervalMs: dependencies.contextBackstopMeasure?.minIntervalMs, env: permissionEnvironment, }); pi.on("tool_result", async (event, ctx) => { // Harness roadmap R1: the primary session's measured context is sampled against the backstop; the // first sampled result at or past it carries one notice, after any other patch below. The sample // runs at most once per window per session (default 5 s, GENTLE_PI_CONTEXT_BACKSTOP_MEASURE_MS), // so a crossing is announced on the next sampled result — never later than one window past it. const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); const primary = permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && (processAgentEndSubagentDepth.get(sessionKey) ?? 0) === 0; let notice: string | undefined; try { notice = primary ? contextBackstopSignal(sessionKey, contextBackstopMeasure(sessionKey, () => ctx.getContextUsage?.())) : undefined; } catch { notice = undefined; } const withNotice = (patch: ToolResultEventResult | undefined) => notice ? appendContextBackstopNotice(event, patch, notice) : patch; // Harness roadmap T4: a parent commit reports the branch's authored // changed lines so delivery slicing reads a number, not an estimate. if (SHELL_COMMAND_TOOLS.has(event.toolName)) { if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1") return undefined; try { return withNotice(await commitSliceToolResult(event, ctx.cwd)); } catch { return withNotice(undefined); } } await recordWriteResult(event, ctx); return withNotice(undefined); }); pi.on("tool_call", async (event, ctx) => { // Native review tools stalled the session when a codemode script called them // (a nested call, which carries parentToolCallId); direct calls return at once. if (event.toolName.startsWith("gentle_review") && typeof (event as { parentToolCallId?: unknown }).parentToolCallId === "string") { return { block: true, reason: `${event.toolName} runs only as a direct tool call: call it directly, never from codemode or another tool.` }; } const primaryToolCall = permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && (processAgentEndSubagentDepth.get(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)) ?? 0) === 0; // One early mode read settles the session binding (and its one-time epoch // bump) before the guarded region below captures its own epoch. const settledMode = primaryToolCall ? await yolo.mode(ctx, { toolCallId: event.toolCallId }) : "normal"; const yoloActive = primaryToolCall && settledMode === "yolo"; const superYoloActive = primaryToolCall && settledMode === "super-yolo"; const sensitivePathDenied = evaluateSensitivePathTool( event.toolName, event.input, ); if (sensitivePathDenied) return sensitivePathDenied; const deliveredTestDenied = evaluateDeliveredTestTool(event.toolName, event.input, ctx.cwd); if (deliveredTestDenied) return deliveredTestDenied; if (event.toolName === "subagent_run") { const judgmentDayFixDenied = rejectInvalidJudgmentDayFixDispatch(event.input); if (judgmentDayFixDenied) return judgmentDayFixDenied; const writerScopeDenied = rejectUnscopedBoundedWriterDispatch(event.input); if (writerScopeDenied) return writerScopeDenied; if (primaryToolCall) { const launch = event.input as Record; const selected = [launch.workspace_root, launch.repository_root].find((value): value is string => typeof value === "string" && value.trim() !== ""); const root = selected ? resolve(ctx.cwd, selected) : ctx.cwd; const undocumentedWriter = rejectUndocumentedWriterLaunch(event.input, { root, contextUsage: ctx.getContextUsage?.(), env: permissionEnvironment }); if (undocumentedWriter) return undocumentedWriter; const unriskedVerifier = rejectUnriskedVerifierLaunch(event.input, { root, contextUsage: ctx.getContextUsage?.(), env: permissionEnvironment }); if (unriskedVerifier) return unriskedVerifier; } try { injectReviewCandidateView(event.input, candidateViews); return undefined; } catch (error) { return { block: true, reason: error instanceof Error ? error.message : "review subagent dispatch is invalid", }; } } const modeConfigDenied = auto.guard(event, ctx); if (modeConfigDenied) return modeConfigDenied; const gate = async (): Promise => { try { return await auto.check(event, ctx, primaryToolCall); } catch { return { block: true, reason: "Auto permission checking failed. This action was not run; do not bypass the check." }; } }; if (!SHELL_COMMAND_TOOLS.has(event.toolName) || !isRecord(event.input)) return gate(); const command = (event.input as { command?: unknown }).command; if (typeof command !== "string") return gate(); if (permissionEnvironment.GENTLE_PI_AGENTS_CHILD === "1") { const childDenied = blockChildDestructiveCommand(command); if (childDenied) return childDenied; } const modeEpoch = yolo.epoch(); // The admission's authoritative mode read is always a fresh capture, never // the admission slot: a reselection while this lookup is in flight must // invalidate it (mode() answers Normal, the epoch check below blocks), and // tests/auto-mode-invariants.test.ts holds exactly this lookup to lock it. const permissionMode = primaryToolCall ? await yolo.mode(ctx) : "normal"; // A superseded lookup returns Normal for display, not permission to skip Auto. if (primaryToolCall && modeEpoch !== yolo.epoch()) { return { block: true, reason: "Permission mode changed during tool admission. This action was not run; retry under the current mode." }; } const autoGate = permissionMode === "auto" ? gate : undefined; return await confirmCommand(command, ctx, pi.events, herdrLifecycle, yoloActive, autoGate, superYoloActive); }); for (const owner of ["delegation", "review"] as const) { const label = owner; pi.registerCommand(`gentle:install-${owner}`, { description: `Repair or refresh only global Gentle AI ${label} assets.`, handler: async (args, ctx) => { const force = args.includes("--force"); const result = installPackageAssets(ctx.cwd, force, [owner]); ctx.ui.notify( `Global Gentle AI ${label} assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`, "info", ); }, }); } pi.registerCommand("gentle:profiles", { description: "Open the models screen (profiles as tabs). Subcommands: export , import, publish , export-routing, restore-routing, clear.", handler: async (args, ctx) => { await handleProfilesCommand(ctx, pi, typeof args === "string" ? args : ""); }, }); pi.registerCommand("gentle:persona", { description: "Switch el Gentleman persona between gentleman and neutral.", handler: async (_args, ctx) => { await handlePersonaCommand(ctx); }, }); // 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) => { const assetLines = packageAssetDiagnosticLines(ctx.cwd); const skillRegistryPresent = existsSync( join(ctx.cwd, ".atl", "skill-registry.md"), ); const modelConfig = await readSavedModelConfigAsync(ctx.cwd); const engramActive = hasWritableEngramTool(pi); const devBinary = await describeDevBinaryOverride(); const lines = [ "el Gentleman doctor", ...assetLines, "pass: Organic Driven Development (ODD): active", `${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`, `${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 (modelConfig.status === "invalid") { lines.push(`remedy: fix or remove ${modelConfig.path}`); } lines.push(...herdrSetupHint(process.env, { interactive: ctx.mode === "tui" && ctx.hasUI, isSocket: (path) => { try { return statSync(path).isSocket(); } catch { return false; } }, })); ctx.ui.notify( lines.join("\n"), lines.some((line) => line.startsWith("fail:")) || assetLines.some((line) => line.startsWith("warn:")) ? "warning" : "info", ); }, }); pi.registerCommand("gentle:review-session-permission", { description: "Show or revoke the process-memory review permission for this exact Pi session and Git repository (status|revoke).", handler: async (args, ctx) => { const subAction = args.trim().length === 0 ? "status" : args.trim(); if (subAction !== "status" && subAction !== "revoke") { ctx.ui.notify(`Unknown /gentle:review-session-permission sub-action "${subAction}". Use status or revoke.`, "warning"); return; } if (subAction === "revoke") { const revoked = await revokeCurrentRepositoryReviewSessionPermission(ctx); ctx.ui.notify(revoked ? "Review permission revoked for this Git repository in this Pi session. Provider review mode and authority were not changed." : "No review permission is active for this Git repository in this Pi session. Provider review mode and authority were not changed.", "info"); return; } const identity = await refreshReviewSessionPermissionStatus(ctx); if (identity === undefined) { ctx.ui.notify("Review session permission is unavailable: it requires the interactive Pi TUI, a non-child session, a nonempty session ID, and a canonical Git worktree.", "info"); return; } ctx.ui.notify(hasReviewSessionPermission(identity) ? "Reviews are allowed for this Pi session and Git repository. Use /gentle:review-session-permission revoke to ask again." : "Reviews are not pre-authorized for this Pi session; each medium- or high-risk candidate asks normally.", "info"); }, }); pi.registerCommand("gentle:status-timing", { description: "Arm session-only timing for the next authorized STATUS-bearing tool call, clear it, or show the last summary (enable|disable|show). Never invokes or retries STATUS.", handler: async (args, ctx) => { statusTiming.command(args, ctx); }, }); pi.registerCommand("gentle:review-mode", { description: "Show or set the Gentle AI receipt-driven development kill switch (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.", handler: async (args, ctx) => { const subAction = args.trim().length === 0 ? NATIVE_REVIEW_MODE_OPERATION.STATUS : args.trim(); if (subAction !== NATIVE_REVIEW_MODE_OPERATION.STATUS && subAction !== NATIVE_REVIEW_MODE_OPERATION.ENABLE && subAction !== NATIVE_REVIEW_MODE_OPERATION.DISABLE) { ctx.ui.notify(`Unknown /gentle:review-mode sub-action "${subAction}". Use status, disable, or enable.`, "warning"); return; } if (nativeReviewCli?.reviewMode === undefined) { ctx.ui.notify("Gentle AI review mode is not available with the currently negotiated native version.", "info"); return; } try { const result = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: subAction as NativeReviewModeOperation }); if (subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE && result.status.effective === "off") { cleanupAllPendingReviewConsents( pendingReviewConsentRegistry, pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey), ); } const report = `receipt-driven development: ${result.status.effective} (decided by ${result.status.source})`; // A mutating sub-action that left the effective mode unchanged did // not do what the user asked, and reporting only the resulting // status reads as if it had. This is reachable for exactly one // shape: `enable` against a global off. Pi always passes // `--scope clone` (Design Decision #7), which only clears a // clone-local override and cannot enable global RDD. The native call // exits 0, reports operation "enable", and changes nothing. Say // that, and name the global-scope command that resolves it. const requested = subAction === NATIVE_REVIEW_MODE_OPERATION.ENABLE ? "on" : subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE ? "off" : result.status.effective; if (result.status.effective !== requested) { ctx.ui.notify(`${report}\nThat did not turn reviews back on: /gentle:review-mode enable only clears a clone-local override, which cannot override a global off. Run \`gentle-ai review mode enable --scope=global\` to turn them back on.`, "warning"); return; } ctx.ui.notify(report, "info"); } catch (error) { if (asNativeReviewCliError(error)?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) { ctx.ui.notify("Gentle AI review mode is not available with the currently negotiated native version.", "info"); return; } ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); // gentle-pi#677: gentle-ai owns telemetry end to end (status, the opt-out // switches, and rate limiting); this command only runs the corresponding // `gentle-ai telemetry --json` in the foreground and relays its // output, so a Pi user never has to leave Pi to check or change it. pi.registerCommand("gentle:telemetry", { description: "Show or change the local Gentle AI telemetry trigger (status|enable|disable|preview); gentle-ai owns the data and the opt-out.", handler: async (args, ctx) => { const subAction = args.trim().length === 0 ? "status" : args.trim(); if (subAction !== "status" && subAction !== "enable" && subAction !== "disable" && subAction !== "preview") { ctx.ui.notify(`Unknown /gentle:telemetry sub-action "${subAction}". Use status, enable, disable, or preview.`, "warning"); return; } let executable: string; try { executable = resolveTelemetryTriggerBinary(); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return; } let result: ExecFileResult; try { result = await telemetryExecFileAdapter({ file: executable, arguments: ["telemetry", subAction, "--json"], cwd: ctx.cwd, timeoutMs: 5_000, maxBufferBytes: 1024 * 1024, }); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); return; } if (result.exitCode !== 0) { ctx.ui.notify(`gentle-ai telemetry ${subAction} failed (exit ${result.exitCode}): ${(result.stderr || result.stdout || "no output").trim()}`, "error"); return; } let relayed: string; try { relayed = JSON.stringify(JSON.parse(result.stdout), null, 2); } catch { relayed = result.stdout.trim(); } if (subAction === "disable") { ctx.ui.notify("Gentle AI telemetry disabled.", "info"); return; } ctx.ui.notify(relayed, "info"); }, }); // Mirrors gentle:review-mode: a user-owned switch, never an automated one. // It matters more here than there, because this policy governs whether // background subagents may be launched at all, so nothing in Pi may write // it. The only writer is this handler, reached only by explicit invocation. pi.registerCommand("gentle:background-subagents", { description: "Show or set the managed background-subagents policy; no argument opens a selectable menu (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.", // No argument opens a selectable menu when an interactive UI is present; // headless callers and fakes without ui.select keep the status fallback. handler: async (args, ctx) => { let subAction = args.trim().length === 0 ? "status" : args.trim(); if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") { const selected = await ctx.ui.select("Background subagents policy", ["status", "enable", "disable"]); if (selected === undefined) return; subAction = selected; } if (subAction !== "status" && subAction !== "enable" && subAction !== "disable") { ctx.ui.notify(`Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, "warning"); return; } try { const wrote: BackgroundSubagentsPolicy | undefined = subAction === "enable" ? "on" : subAction === "disable" ? "off" : undefined; if (wrote !== undefined) writeGlobalBackgroundSubagentsPolicy(wrote); const resolution = resolveBackgroundSubagentsPolicy(ctx.cwd); const capability = resolveBackgroundSubagentsCapability(ctx.cwd, readActiveToolNames(pi)); const report = renderBackgroundSubagentsReport(resolution, capability, wrote); ctx.ui.notify(report.message, report.type); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerCommand("gentle:status", { description: "Show Gentle AI package status for this project.", handler: async (_args, ctx) => { const assetLines = packageAssetDiagnosticLines(ctx.cwd); const savedConfig = await readModelRoutingAuthorityAsync( modelConfigPath(ctx.cwd), legacyProjectModelConfigPath(ctx.cwd), ); const devBinary = await describeDevBinaryOverride(); ctx.ui.notify( [ "el Gentleman package is active.", ...(devBinary.state === "inactive" ? [] : [devBinary.line]), `Persona: ${readPersonaMode(ctx.cwd)}`, ...assetLines, "Organic Driven Development (ODD): active", `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`, `Saved model routing: ${savedConfig.status}${savedConfig.status === "invalid" ? ` (${savedConfig.path})` : ""}`, ...(savedConfig.status === "invalid" ? [] : describeModelConfig(ctx.cwd, savedConfig.status === "valid" ? savedConfig.config : {})), ].join("\n"), savedConfig.status === "invalid" || assetLines.some((line) => line.startsWith("warn:")) || devBinary.state !== "inactive" ? "warning" : "info", ); }, }); }; } export default function gentleAi(pi: ExtensionAPI): void { return createGentleAiExtension()(pi); }