diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index ff9a25837..94103293a 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -209,7 +209,7 @@ Gentle Shell ships its own interactive tools instead of depending on third-party ### Gentle Agents -The current package requires Pi 0.99.1 or newer and Node >=22.19.0. Development tests resolve Pi through the open `>=1.0.0` development range. The private Vim editor adapter admits only the audited Pi `0.99.1`, `0.99.2`, and `1.0.0` releases; any other release keeps ordinary prompt editing until its editor is audited. Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. +The current package requires Pi 0.99.1 or newer and Node >=22.19.0. Development tests resolve Pi through the open `>=1.0.0` development range. The private Vim editor adapter admits only the audited Pi `0.99.1`, `0.99.2`, `1.0.0`, and `1.1.0` releases; any other release keeps ordinary prompt editing until its editor is audited. Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. The `subagent_*` tools and the agents card replace the third-party subagents package (remove `npm:pi-subagents-j0k3r` from your pi packages; while it is still installed the tools stay unregistered and a warning says so at startup). Agent definitions and settings are the ones you already have: markdown agents in `~/.pi/agent/agents/`, `~/.pi/agent/subagents/`, `/.pi/agents/`, `/.pi/subagents/` (project beats global, `subagents/` beats `agents/`), and `subagents.json` at the global and project level (`default_model`, `default_effort`, `default_mode`, `model_profiles`, `stall_timeout_ms`, `tool_stall_timeout_ms`, `max_concurrency`, `history_max_tasks`). diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 0c2098659..a84177847 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -215,7 +215,7 @@ This checkout declares `gentle-pi` `4.0.0` with a package-local Gentle AI `v4.0. ### Pi compatibility -The current package requires Pi 0.99.1 or newer and Node >=22.19.0. Development tests resolve Pi through the open `>=1.0.0` development range. The private Vim editor adapter admits only the audited Pi `0.99.1`, `0.99.2`, and `1.0.0` releases; any other release keeps ordinary prompt editing until its editor is audited. Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. +The current package requires Pi 0.99.1 or newer and Node >=22.19.0. Development tests resolve Pi through the open `>=1.0.0` development range. The private Vim editor adapter admits only the audited Pi `0.99.1`, `0.99.2`, `1.0.0`, and `1.1.0` releases; any other release keeps ordinary prompt editing until its editor is audited. Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow. The [`v2.6.0` release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v2.6.0) added persistent registered worktrees and grouped `/gentle:changes` views; fuller workspace interaction details are in the [Gentle Shell reference](gentle-shell.md). It also adds named atomic `/gentle:profiles`, native review intended-untracked selection and provider continuations, and opt-in custom ask responses. Pi recognizes its global Git-managed package path; subsystems install with explicit recovery guidance when npm lifecycle work was skipped. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed. @@ -1099,7 +1099,7 @@ The frame labels INSERT, NORMAL, VISUAL (characterwise), or VISUAL LINE (linewis **Deliberate `/` divergence from Claude Code:** NORMAL `/` hands off to **Pi's native slash commands and skills**, enters INSERT, and inserts `/` at the existing cursor. Pi offers slash completion only at the start of the first line; elsewhere it inserts a literal slash without moving or replacing the draft. There is **no reverse prompt-history search**. Pi's explicit history shortcuts still work, transferring to INSERT first. Unknown NORMAL printable input, encoded text and bracketed paste do not silently insert; application shortcuts can transfer to INSERT before acting. -This is a bounded command subset, not full Claude Code/Vim parity. The private editor adapter supports only the audited Pi coding-agent/TUI `0.99.1`, `0.99.2`, and `1.0.0` package pairs. The two 0.99 releases have byte-identical editor and undo-stack sources. Pi 1.0.0 is separately audited against the touched state, paste/history, cursor/layout, autocomplete and undo-snapshot contracts; actual bundled and unbundled 1.0.0 tests prove identity, edit/undo, paste, selection, wrapping/scroll and autocomplete behavior, not old/new byte identity. Fabricated metadata tests preserve exact-version admission coverage for the older audited releases. Version metadata must come from a canonical candidate host package root whose actual `CustomEditor` and `Editor` classes match the loaded classes, never from the extension's local metadata or CLI path alone. Unknown versions, mismatched prototypes, or invalid layouts fail closed: a single compatibility warning is shown and the prompt continues with ordinary editing instead of silently entering inert NORMAL mode. Operations that would cross a registered collapsed paste marker, or encounter duplicate occurrences of a registered marker ID, are rejected without editing it. Visual highlighting relies on Pi's render layout and may be omitted if its geometry cannot be validated. No live-terminal proof of every layout or complete parity is claimed. +This is a bounded command subset, not full Claude Code/Vim parity. The private editor adapter supports only the audited Pi coding-agent/TUI `0.99.1`, `0.99.2`, `1.0.0`, and `1.1.0` package pairs. The two 0.99 releases have byte-identical editor and undo-stack sources. Pi 1.0.0 and 1.1.0 are separately audited against the touched state, paste/history, cursor/layout, autocomplete and undo-snapshot contracts; current bundled and unbundled 1.1.0 tests prove identity, edit/undo, paste, selection, wrapping/scroll and autocomplete behavior, not old/new byte identity. Fabricated metadata tests preserve exact-version admission coverage for the older audited releases. Version metadata must come from a canonical candidate host package root whose actual `CustomEditor` and `Editor` classes match the loaded classes, never from the extension's local metadata or CLI path alone. Unknown versions, mismatched prototypes, or invalid layouts fail closed: a single compatibility warning is shown and the prompt continues with ordinary editing instead of silently entering inert NORMAL mode. Operations that would cross a registered collapsed paste marker, or encounter duplicate occurrences of a registered marker ID, are rejected without editing it. Visual highlighting relies on Pi's render layout and may be omitted if its geometry cannot be validated. No live-terminal proof of every layout or complete parity is claimed. Startup banner settings remain global in `banner.json` under `GENTLE_PI_CONFIG_HOME` (default `~/.pi/gentle-ai`). Existing `showRose` and `showTextLogo` opt-outs independently control the main startup artwork; both default to enabled. Changes apply on the next session or `/reload`. Color presets are `pink` (default), `cyan`, `yellow`, and `green`. The static sidebar heading is independent of these preferences and follows the active theme. diff --git a/extensions/codegraph-tools.ts b/extensions/codegraph-tools.ts index 39f21cca7..4936c2ad3 100644 --- a/extensions/codegraph-tools.ts +++ b/extensions/codegraph-tools.ts @@ -230,7 +230,7 @@ export function codeGraphNodeScript(cmdPath: string): string | undefined { } } -function* codeGraphNodeScriptsOnPath(): Iterable { +function* codeGraphNodeScriptsOnPath(): Generator { for (const cmdPath of codeGraphCmdPathsOnPath()) { const script = codeGraphNodeScript(cmdPath); if (script) yield script; @@ -297,7 +297,7 @@ export function createCodeGraphTool(runner: CodeGraphRunner = runCodeGraphComman signal: AbortSignal | undefined, _onUpdate: undefined, ctx: ExtensionContext, - ) { + ): Promise<{ content: { type: "text"; text: string }[]; details: { operation: CodeGraphOperation; cwd: string; args: string[] } | CodeGraphFallbackDetails }> { const cwd = resolveWorkspaceCwd(ctx.cwd); assertSafeIndexDirectory(cwd); const args = commandArguments(parameters, cwd); diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index d8ba3a54f..c05ec6159 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -316,7 +316,7 @@ function registerChildMessaging(pi: ExtensionAPI, ipc: IpcEndpoint): void { label: "Agent parent message", description: "Send a bounded notification or correlated query to this subagent's parent.", parameters: { type: "object", additionalProperties: false, required: ["message"], properties: { kind: { type: "string", enum: ["notification", "query"] }, message: { type: "string" } } } as never, - async execute(_id, params) { + async execute(_id, params): Promise { const input = params as { kind?: unknown; message?: unknown }; if (typeof input.message !== "string") throw new Error("parent messages require text"); if (input.kind === undefined || input.kind === "notification") { diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index b620815ca..1fef42a39 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -5284,15 +5284,15 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame } } - 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].includes(value.operation as ReviewControllerOperation); + 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 !== undefined ? { untrackedScope: value.untrackedScope } : {}), - ...(value.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && value.intendedUntracked !== undefined ? { intendedUntracked: [...value.intendedUntracked] } : {}), + ...(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]; @@ -5314,17 +5314,18 @@ function parseReviewCaptureParameters(value: unknown): ReviewCaptureParameters { 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 && (!Number.isSafeInteger(value.correctionLines) || value.correctionLines < 1)) throw new Error("Review capture correctionLines must be a positive integer"); + 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, - ...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }), - ...(value.correctionLines === undefined ? {} : { correctionLines: value.correctionLines }), - ...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }), + ...(typeof reviewerRunAcknowledged === "boolean" ? { reviewerRunAcknowledged } : {}), + ...(typeof correctionLines === "number" ? { correctionLines } : {}), + ...(typeof workspaceRoot === "string" ? { workspaceRoot } : {}), }; } @@ -5335,6 +5336,7 @@ function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupPa 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"); @@ -5342,8 +5344,8 @@ function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupPa return { lineageId: value.lineageId, collectBindings, - ...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }), - ...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }), + ...(typeof reviewerRunAcknowledged === "boolean" ? { reviewerRunAcknowledged } : {}), + ...(typeof workspaceRoot === "string" ? { workspaceRoot } : {}), }; } @@ -5638,7 +5640,7 @@ function asNativeReviewConsentBindingError(error: unknown): { reason: string; me return typeof reason !== "string" || reason.length === 0 ? undefined : { reason, message: error.message }; } -function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation, diagnostics: NativeReviewProcessDiagnostics): Record { +function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation | "gentle_review_capture", diagnostics: NativeReviewProcessDiagnostics): Record { return { operation, status: "blocked", @@ -6302,12 +6304,13 @@ function validateNativeStartUntrackedSelection(value: Record): !isCanonicalProcessString(expectedUntrackedInventory) || (intendedUntracked !== undefined && (!Array.isArray(intendedUntracked) || intendedUntracked.some((path) => !isNativeStartUntrackedPath(path) || intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path)))) ) return { reason: "untracked-selection-invalid" }; - if (scope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) return { reason: "untracked-selection-invalid" }; - if (scope === NATIVE_START_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) 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: intendedUntracked === undefined ? [] : [...intendedUntracked], + intendedUntracked: [...paths], }; } @@ -6809,10 +6812,10 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r const nativeCliError = asNativeReviewCliError(error); if (nativeCliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, nativeCliError.diagnostics); const nativeDiagnostics = nativeCliError?.diagnostics; - // A target-status probe verifies `version` before it invokes `review/status`. - // Preserve either already-sanitized diagnostic on every controller route rather - // than relabeling an actionable failure as an opaque controller failure. - const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.VERSION || nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS; + // 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" } @@ -7012,7 +7015,7 @@ function retainNativeUntrackedSelection(selections: Map, workspaceRoot: string, lineageId: string): NativeStartUntrackedSelection { const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, lineageId)); - return selection === undefined || "baseRef" in selection || "selectionBinding" in selection + return selection === undefined || !("untrackedScope" in selection) || "targetIdentity" in selection ? {} : { untrackedScope: selection.untrackedScope, @@ -7862,10 +7865,15 @@ function captureGroupAuthorityDrift(status: ReviewStatusV3): Record): value is SelectedReviewCaptureGroup { + return value.selected === true; +} + function selectExactReviewCaptureGroup( status: ReviewStatusV3, lineageId: string, @@ -7911,7 +7919,7 @@ function selectExactReviewCaptureGroup( } lenses.add(lens); orders.add(order); subjectHashes.add(subject.subjectHash); } - return { slots, binding: first.binding }; + return { selected: true, slots, binding: first.binding }; } function reviewHostRelayGroupFailure( @@ -8151,7 +8159,7 @@ async function executeReviewCaptureGroupOperation( return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed" }; } const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings); - if (!("slots" in group && "binding" in group)) return group; + if (!isSelectedReviewCaptureGroup(group)) return group; route = { workspaceRoot: cwd, lineageId: parameters.lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true }) }; if (parameters.reviewerRunAcknowledged !== true) { return { @@ -8734,9 +8742,9 @@ async function executeReviewControllerOperation( outcome: "native-approved-acknowledgement-completed", lineage_id: parameters.lineageId, target_identity: status.targetIdentity, - ...(acknowledged === undefined ? {} : { consumed_revision: acknowledged.consumedRevision }), + ...(acknowledged ? { consumed_revision: acknowledged.consumedRevision } : {}), authority: "burned", - ...(acknowledged === undefined ? {} : { burn_evidence: acknowledged.schema }), + ...(acknowledged ? { burn_evidence: acknowledged.schema } : {}), delivery: "ordinary-repository-policy", mutation_performed: true, mutation_outcome: "committed", @@ -8905,7 +8913,7 @@ async function executeReviewControllerOperation( : undefined; let canonicalBaseRef: string | undefined; let providerBaseTree: string | undefined; - if (baseRef !== undefined) { + if (typeof baseRef === "string") { try { canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit; } catch (error) { @@ -9068,7 +9076,7 @@ async function executeReviewControllerOperation( ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }), ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }), - ...(focus === undefined ? {} : { focus }), + ...(isNativeStartFocus(focus) ? { focus } : {}), ...lensSelection, ...(signal === undefined ? {} : { signal }), }); @@ -9254,7 +9262,7 @@ async function executeReviewControllerOperation( const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd) ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd) : undefined; - const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? nativeCommittedRangeSelector(retainedCommittedTarget) : undefined); + const effectiveBaseRef = typeof baseRef === "string" ? baseRef : (retainedCommittedTarget?.committedOnly === true ? nativeCommittedRangeSelector(retainedCommittedTarget) : undefined); if (nativeReviewCli?.targetStatus !== undefined) { try { const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { diff --git a/extensions/quiet-tools.ts b/extensions/quiet-tools.ts index 96778db41..6dcf47e8c 100644 --- a/extensions/quiet-tools.ts +++ b/extensions/quiet-tools.ts @@ -480,7 +480,9 @@ interface ToolRenderContextLike { lastComponent?: unknown; state?: unknown; cwd?: string; - [key: string]: unknown; + expanded?: boolean; + toolCallId?: string; + durationMs?: number; } function formatToolCall(toolName: QuietToolName, args: Record, theme: ThemeLike): string { @@ -680,11 +682,15 @@ function hasImageContent(result: AgentToolResult): boolean { return result.content.some((content) => content.type === "image"); } -function sanitizedRenderContext(context: ToolRenderContextLike | undefined): ToolRenderContextLike { +function isRenderArgs(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sanitizedRenderContext(context: (Omit & { args?: unknown }) | undefined): ToolRenderContextLike { if (!context) return { args: {} }; return { ...context, - args: sanitizedArgs(context.args), + args: sanitizedArgs(isRenderArgs(context.args) ? context.args : undefined), cwd: typeof context.cwd === "string" ? safeText(context.cwd) : context.cwd, }; } @@ -733,7 +739,7 @@ export function createQuietToolRenderer( renderShell: "self", renderCall(args, theme, context) { const callArgs = args as Record; - const renderContext = sanitizedRenderContext(context as ToolRenderContextLike | undefined); + const renderContext = sanitizedRenderContext(context); const operationPath = toolName === "bash" ? gentleAiRenderTransition(callArgs, renderContext, commandArguments()).operationPath : undefined; @@ -749,12 +755,15 @@ export function createQuietToolRenderer( }, /** Builds the card component for this render pass; collapsed cards delegate to the wrapped-line cache keyed by the tool result object. */ renderResult(result, options, theme, context) { - const renderContext = context as ToolRenderContextLike | undefined; + const renderContext = context ? sanitizedRenderContext(context) : undefined; markCardResult(renderContext?.state); const cacheKey = typeof result === "object" && result !== null ? result : undefined; const safeResult = sanitizedResult(result); const text = safeText(extractTextContent(safeResult)); - const isError = renderContext?.isError ?? options.isError ?? false; + // Current Pi carries errors on the context. Older renderer callers + // supplied them on options; keep that structural fallback for those hosts. + const legacyError = "isError" in options && options.isError === true; + const isError = renderContext?.isError ?? legacyError; const directResult = toolName === "bash" && gentleAiRenderTransition( renderContext?.args, renderContext, diff --git a/extensions/skill-registry.ts b/extensions/skill-registry.ts index 330a81b1d..767fb2580 100644 --- a/extensions/skill-registry.ts +++ b/extensions/skill-registry.ts @@ -531,7 +531,7 @@ function extensionSourcePath(source: string): string | undefined { function shouldSkipDuplicateExtensionLoad( source = import.meta.url, cwd = process.cwd(), - state = globalThis as typeof globalThis & SkillRegistryExtensionGlobal, + state: SkillRegistryExtensionGlobal = globalThis as typeof globalThis & SkillRegistryExtensionGlobal, ): boolean { const currentPath = extensionSourcePath(source); const projectLocalPath = comparablePath(join(cwd, "extensions", "skill-registry.ts")); diff --git a/lib/agents-messaging.ts b/lib/agents-messaging.ts index cf0e048e2..0b3195ebf 100644 --- a/lib/agents-messaging.ts +++ b/lib/agents-messaging.ts @@ -95,7 +95,7 @@ function parseAck(value: unknown): AckFrame | undefined { if (!record(value) || !exact(value, ["id", "kind", "accepted", "error"]) || !validChildId(value.id) || value.kind !== "ack" || typeof value.accepted !== "boolean") return undefined; if (value.accepted && value.error !== undefined) return undefined; if (value.error !== undefined && !boundedText(value.error, CHILD_ACK_ERROR_MAX_BYTES)) return undefined; - return { id: value.id, accepted: value.accepted, ...(value.error === undefined ? {} : { error: value.error }) }; + return { id: value.id, accepted: value.accepted, ...(typeof value.error === "string" ? { error: value.error } : {}) }; } function parseReply(value: unknown): ReplyFrame | undefined { diff --git a/lib/agents-runner.ts b/lib/agents-runner.ts index 988db9068..7b28b0077 100644 --- a/lib/agents-runner.ts +++ b/lib/agents-runner.ts @@ -2,7 +2,7 @@ import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-c import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { Duplex, Readable, Writable } from "node:stream"; +import { Duplex, type Readable, type Writable } from "node:stream"; import { stripVTControlCharacters } from "node:util"; import { withoutInteractiveHost } from "./rpc-host.ts"; import { childPackageExtensionArgs } from "./child-package-injection.ts"; @@ -18,12 +18,12 @@ import { WriterSurfaceRegistry, writerSurfaceConflictMessage } from "./writer-su // and enforces an inactivity watchdog per task. export interface ChildLike { - pid: number | undefined; + pid?: number; connected?: boolean; - stdin: Writable; - stdout: Readable; + stdin: Writable | null; + stdout: Readable | null; stderr: Readable | null | undefined; - stdio?: Array; + stdio?: Array; kill(signal?: NodeJS.Signals): boolean; send?(message: Record, callback?: (error: Error | null) => void): boolean; disconnect?(): void; @@ -37,6 +37,7 @@ export interface SpawnOptions { cwd: string; env: NodeJS.ProcessEnv; detached?: boolean; + windowsHide?: boolean; stdio?: Array<"pipe" | "ignore" | "inherit" | "ipc" | "overlapped">; } @@ -534,6 +535,7 @@ export class AgentRunner { cwd: request.cwd, env, detached, + windowsHide: true, stdio: hasParentPermissionChannel ? ["pipe", "pipe", "pipe", permissionChannelStdio, "ipc"] : ["pipe", "pipe", "pipe", "ipc"], }); } catch (error) { @@ -559,7 +561,7 @@ export class AgentRunner { } this.live.set(id, live); const permissionPipe = child.stdio?.[3]; - if (hasParentPermissionChannel && permissionPipe !== undefined && permissionPipe !== null) { + if (hasParentPermissionChannel && permissionPipe instanceof Duplex) { live.permissionBroker = new ParentStandingReviewPermissionBroker( { readable: permissionPipe, writable: permissionPipe }, (repositoryIdentity) => this.live.get(id) === live && !live.terminal && request.authorizeParentStandingReviewPermission?.(repositoryIdentity) === true, @@ -577,6 +579,11 @@ export class AgentRunner { try { request.onLaunch?.(); } catch (error) { this.requestStop(id, TASK_STATUS.FAILED, `could not register launched worktree: ${error instanceof Error ? error.message : String(error)}`); } }); + child.on("exit", (code, signal) => this.exited(id, code, signal)); + if (!child.stdin || !child.stdout) { + this.requestStop(id, TASK_STATUS.FAILED, "could not start pi: missing RPC streams"); + return; + } child.stdin.on("error", () => {}); this.armStall(id, live); const lines = new JsonLines((value) => this.receive(id, request, value)); @@ -587,7 +594,6 @@ export class AgentRunner { const tail = live.stderrTail + chunk; live.stderrTail = tail.length > STDERR_TAIL_MAX ? tail.slice(-STDERR_TAIL_MAX) : tail; }); - child.on("exit", (code, signal) => this.exited(id, code, signal)); void this.send(id, { type: "get_state" }).then((response) => { const data = response.data as { sessionFile?: unknown; model?: { provider?: unknown; id?: unknown } | null; thinkingLevel?: unknown } | undefined; if (response.success !== true || live.terminal || this.live.get(id) !== live || !data) return; @@ -757,7 +763,7 @@ export class AgentRunner { private write(live: LiveTask, payload: Record): void { try { - live.child.stdin.write(`${JSON.stringify(payload)}\n`); + live.child.stdin?.write(`${JSON.stringify(payload)}\n`); } catch { // the child is gone; the exit handler settles the task } diff --git a/lib/native-review-cli.ts b/lib/native-review-cli.ts index dcb53c9a0..a94a4749e 100644 --- a/lib/native-review-cli.ts +++ b/lib/native-review-cli.ts @@ -1267,50 +1267,6 @@ function decodeReleaseEvidence(value: unknown): void { for (const field of ["release_tree", "configuration_hash", "generated_artifact_hash", "provenance_hash", "publication_boundary_hash", "evidence_freshness_hash"]) requiredString(release[field]); if (release.publication_state !== "sealed" || release.evidence_freshness_state !== "current") throw new Error("invalid release evidence"); } -function decodeNonDecidingGateContext(value: unknown, expectedGate: string): NativeGateContext { - const context = exactObject(value, ["gate"]); - const gate = enumString(context.gate, NATIVE_GATE); - if (gate !== expectedGate) throw new Error("native non-deciding gate context does not match the requested gate"); - return { lineageId: "", storeRevision: "", raw: context }; -} -function decodeGateContext(value: unknown): NativeGateContext { - const context = exactObject( - value, - ["gate", "lineage_id", "generation", "base_tree", "candidate_tree", "paths_digest", "fix_delta_hash", "policy_hash", "ledger_hash", "evidence_hash", "base_relationship_valid"], - ["store_revision", "genesis_revision", "chain_identity", "bundle_digest", "external_evidence", "base_advanced_compatible", "release", "pre_pr_boundary", "denial"], - ); - const gate = stringValue(context.gate); - if (gate !== "" && !(NATIVE_GATE as readonly string[]).includes(gate)) throw new Error("invalid gate context gate"); - for (const field of ["lineage_id", "base_tree", "candidate_tree", "paths_digest", "fix_delta_hash", "policy_hash", "ledger_hash", "evidence_hash"]) stringValue(context[field]); - for (const field of ["store_revision", "genesis_revision", "chain_identity", "bundle_digest"]) if (context[field] !== undefined) stringValue(context[field]); - nonNegativeInteger(context.generation); - booleanValue(context.base_relationship_valid); - if (context.external_evidence !== undefined) enumString(context.external_evidence, ["invalidating", "escalating"]); - let sanitizedContext = context; - if (context.denial !== undefined) { - const denial = exactObject(context.denial, ["stage", "code"]); - const stage = sanitizeNativeDiagnosticText(requiredString(denial.stage), NATIVE_REVIEW_DENIAL_TEXT_LIMIT); - const code = sanitizeNativeDiagnosticText(requiredString(denial.code), NATIVE_REVIEW_DENIAL_TEXT_LIMIT); - if (!isCanonicalProcessString(stage) || !isCanonicalProcessString(code)) throw new Error("non-canonical denial evidence"); - sanitizedContext = { ...context, denial: { stage, code } }; - } - if (context.pre_pr_boundary !== undefined) { - const boundary = exactObject(context.pre_pr_boundary, ["source", "selector", "commit"], ["remote", "remote_ref", "remote_identity"]); - enumString(boundary.source, ["explicit", "publication-default"]); requiredString(boundary.selector); stringValue(boundary.commit); - for (const field of ["remote", "remote_ref", "remote_identity"]) if (boundary[field] !== undefined) requiredString(boundary[field]); - } - if (context.base_advanced_compatible !== undefined) { - const proof = exactObject(context.base_advanced_compatible, ["status", "compatible", "old_base_tree", "new_base_tree", "original_patch_identity", "delivered_patch_identity", "delivered_paths_digest", "base_advance_paths_digest", "paths_disjoint", "merged_result_tree", "ci_attestation_artifact_hash", "ci_attestation_issuer", "ci_status"]); - for (const field of ["status", "old_base_tree", "new_base_tree", "original_patch_identity", "delivered_patch_identity", "delivered_paths_digest", "base_advance_paths_digest", "merged_result_tree", "ci_attestation_artifact_hash", "ci_attestation_issuer", "ci_status"]) requiredString(proof[field]); - booleanValue(proof.compatible); booleanValue(proof.paths_disjoint); - } - if (context.release !== undefined) decodeReleaseEvidence(context.release); - return { - lineageId: stringValue(context.lineage_id), - storeRevision: context.store_revision === undefined ? "" : stringValue(context.store_revision), - raw: sanitizedContext, - }; -} function decodeNativeReviewRecovery(value: unknown): NativeReviewRecovery { const recovery = exactObject(value, ["predecessor_lineage_id", "predecessor_revision", "disposition", "reason", "actor", "recovered_at"], ["maintainer_authorization"]); return { @@ -1401,7 +1357,7 @@ function decodeNativeReviewStatus(value: unknown): NativeReviewStatusResult { const complete = booleanValue(body.complete); const authoritative = booleanValue(body.authoritative); if (authoritative && !complete) throw new Error("incomplete inventory cannot be authoritative"); - if (!Array.isArray(body.entries) || !Array.isArray(body.locks)) throw new Error("invalid native status inventory"); + if (!Array.isArray(body.entries) || !Array.isArray(body.locks) || !Array.isArray(body.diagnostics)) throw new Error("invalid native status inventory"); return { repository: requiredString(body.repository), complete, @@ -1586,7 +1542,7 @@ class NativeReviewPlainCli { if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result, true, undefined, this.maxBufferBytes); if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, operation, mutating, "native process timed out", result); if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result); - const maintenancePartialFailure = [NATIVE_REVIEW_OPERATION.ABANDON, NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS].includes(operation) && result.exitCode !== 0; + const maintenancePartialFailure = new Set([NATIVE_REVIEW_OPERATION.ABANDON, NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS]).has(operation) && result.exitCode !== 0; const toleratedNotice = stderrIsTolerated(result.stderr, toleratedStderr); if (result.exitCode !== 0 && !maintenancePartialFailure) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native process failed", result); if (result.stderr.trim().length > 0 && !maintenancePartialFailure && !toleratedNotice) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); diff --git a/lib/review-candidate-view.ts b/lib/review-candidate-view.ts index f3dece308..0d6c74b8e 100644 --- a/lib/review-candidate-view.ts +++ b/lib/review-candidate-view.ts @@ -1861,7 +1861,7 @@ export function decodeCandidateContextManifest(encoded: string, sha256: string): if (actualSha256 !== sha256) throw new CandidateViewError("candidate context manifest integrity check failed", "candidate-context-manifest-integrity"); const text = bytes.toString("utf8"); if (!Buffer.from(text, "utf8").equals(bytes)) return invalidCandidateContextManifest("candidate context manifest is not valid UTF-8"); - if (gzipSync(bytes, { mtime: 0 }).toString("base64url") !== encoded) { + if (gzipSync(bytes).toString("base64url") !== encoded) { return invalidCandidateContextManifest("candidate context manifest transport is not canonical"); } let value: unknown; @@ -1923,7 +1923,8 @@ function compactCandidateContextBlock(lineageId: string, agents: readonly Review const bytes = Buffer.from(JSON.stringify(manifest), "utf8"); if (bytes.length > MAX_CANDIDATE_CONTEXT_MANIFEST_BYTES) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract"); const sha256 = createHash("sha256").update(bytes).digest("hex"); - const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url"); + // Node emits a zero gzip timestamp by default; no unsupported mtime option is needed. + const encoded = gzipSync(bytes).toString("base64url"); const block = `${candidateContextPreamble(lineageId, agents, view, scopeSemantics)}\nFrozen changed scope manifest (gzip+base64url): \`${encoded}\`.\nFrozen changed scope manifest SHA-256: \`${sha256}\`.\nCall \`gentle_review_scope\` with exactly this manifest, SHA-256, and cursor 0; continue with each returned \`nextCursor\` until absent. It is the only authorized scope enumerator: do not infer scope by traversing the candidate or ambient tree. Gitlinks are metadata-only and MUST NOT be traversed.\nThe ambient contributor working directory is out of scope. This controller-owned context is immutable; you are read-only and your output is untrusted.`; if (Buffer.byteLength(block, "utf8") > MAX_CANDIDATE_CONTEXT_LENGTH) throw new CandidateViewError("candidate view context exceeds the bounded dispatch contract"); return block; diff --git a/lib/review-compact-contract.ts b/lib/review-compact-contract.ts index eaeba6bbe..a9d5da9a7 100644 --- a/lib/review-compact-contract.ts +++ b/lib/review-compact-contract.ts @@ -136,7 +136,7 @@ function parseCompactFinalizeInputValue(value: unknown): CompactFinalizeContract if ((input.final_evidence === undefined && outcomeFields !== 0) || (input.final_evidence !== undefined && outcomeFields !== 1)) fail("review/finalize", "field-pair", "final evidence requires exactly one verification result or outcome"); let correction_line_forecast: number | undefined; if (input.correction_line_forecast !== undefined) { - if (!Number.isSafeInteger(input.correction_line_forecast) || input.correction_line_forecast <= 0) fail("review/finalize.correction_line_forecast", "range", "must be a positive safe integer"); + if (typeof input.correction_line_forecast !== "number" || !Number.isSafeInteger(input.correction_line_forecast) || input.correction_line_forecast <= 0) fail("review/finalize.correction_line_forecast", "range", "must be a positive safe integer"); correction_line_forecast = input.correction_line_forecast; } if (input.final_verification_passed !== undefined && typeof input.final_verification_passed !== "boolean") fail("review/finalize.final_verification_passed", "type", "must be boolean"); @@ -154,7 +154,7 @@ function parseCompactFinalizeInputValue(value: unknown): CompactFinalizeContract if (typeof input.final_evidence !== "string" || input.final_evidence.length === 0) fail("review/finalize.final_evidence", "empty", "must contain at least one byte"); final_evidence = input.final_evidence; } - return { cwd: string(input.cwd, "review/finalize.cwd"), ...(optionalLineage(input.lineageId, "review/finalize.lineageId") === undefined ? {} : { lineageId: optionalLineage(input.lineageId, "review/finalize.lineageId")! }), ...(correction_line_forecast === undefined ? {} : { correction_line_forecast }), ...(input.validation === undefined ? {} : { validation: parseValidation(input.validation, "review/finalize.validation") }), ...(final_evidence === undefined ? {} : { final_evidence }), ...(input.final_verification_passed === undefined ? {} : { final_verification_passed: input.final_verification_passed }), ...(final_verification_outcome === undefined ? {} : { final_verification_outcome }), ...(input.reviewer_run_acknowledged === undefined ? {} : { reviewer_run_acknowledged: input.reviewer_run_acknowledged as boolean }) }; + return { cwd: string(input.cwd, "review/finalize.cwd"), ...(optionalLineage(input.lineageId, "review/finalize.lineageId") === undefined ? {} : { lineageId: optionalLineage(input.lineageId, "review/finalize.lineageId")! }), ...(correction_line_forecast === undefined ? {} : { correction_line_forecast }), ...(input.validation === undefined ? {} : { validation: parseValidation(input.validation, "review/finalize.validation") }), ...(final_evidence === undefined ? {} : { final_evidence }), ...(typeof input.final_verification_passed !== "boolean" ? {} : { final_verification_passed: input.final_verification_passed }), ...(final_verification_outcome === undefined ? {} : { final_verification_outcome }), ...(typeof input.reviewer_run_acknowledged !== "boolean" ? {} : { reviewer_run_acknowledged: input.reviewer_run_acknowledged }) }; } export function parseNativeCompactFinalizeInput(value: unknown): CompactFinalizeContractInput { diff --git a/lib/review-integration-v2.ts b/lib/review-integration-v2.ts index 0401caa6c..0fe7c6c92 100644 --- a/lib/review-integration-v2.ts +++ b/lib/review-integration-v2.ts @@ -2586,7 +2586,11 @@ function assertReviewApprovedAcknowledgementExecuteShapeV1(execute: ReviewNextTr if (execute.arguments.length !== REVIEW_APPROVED_ACKNOWLEDGEMENT_ARGUMENTS.length) throw new TypeError(`acknowledgement.execute.arguments must carry exactly ${REVIEW_APPROVED_ACKNOWLEDGEMENT_ARGUMENTS.length} provider-issued arguments`); const values = execute.arguments.map((argument, index) => { const name = REVIEW_APPROVED_ACKNOWLEDGEMENT_ARGUMENTS[index]!; if (argument.name !== name) throw new TypeError(`acknowledgement.execute.arguments[${index}].name must be ${name}`); const value = nonempty(argument.value, `acknowledgement.execute.arguments[${index}].value`); if (nonempty(argument.token, `acknowledgement.execute.arguments[${index}].token`) !== `--${name}=${value}`) throw new TypeError(`acknowledgement.execute.arguments[${index}].token must exactly match ${name}`); return value; }); if (execute.preconditions.length !== 1 || execute.preconditions[0]?.name !== "state" || execute.preconditions[0]?.value !== "approved") throw new TypeError("acknowledgement.execute.preconditions must be the single approved state precondition"); - return { tokens: execute.arguments.map((argument) => argument.token!) as ReviewApprovedAcknowledgementExecuteTokensV1, values: values as readonly [string, string, string, string, string], lineageId: lineage(execute.binding.lineageId, "acknowledgement.execute.binding.lineage_id"), targetIdentity: sha256(execute.binding.targetIdentity, "acknowledgement.execute.binding.target_identity"), revision: sha256(execute.binding.revision, "acknowledgement.execute.binding.revision") }; + const tokens = execute.arguments.map((argument) => argument.token!); + // The exact five-argument check above validates both vectors before tuple construction. + const tokenTuple: ReviewApprovedAcknowledgementExecuteTokensV1 = [tokens[0]!, tokens[1]!, tokens[2]!, tokens[3]!, tokens[4]!]; + const valueTuple: readonly [string, string, string, string, string] = [values[0]!, values[1]!, values[2]!, values[3]!, values[4]!]; + return { tokens: tokenTuple, values: valueTuple, lineageId: lineage(execute.binding.lineageId, "acknowledgement.execute.binding.lineage_id"), targetIdentity: sha256(execute.binding.targetIdentity, "acknowledgement.execute.binding.target_identity"), revision: sha256(execute.binding.revision, "acknowledgement.execute.binding.revision") }; } /** @@ -2920,7 +2924,7 @@ export function decodeReviewLastEventClosureV1(value: unknown): ReviewLastEventC storeRevision: sha256(body.store_revision, "last_event_closure.store_revision"), ...(escalation === undefined ? {} : { escalation }), ...(targetedValidatorEvidence === undefined ? {} : { targetedValidatorEvidence }), - }; + } as const; if (operation === REVIEW_LAST_EVENT_CLOSURE_OPERATION.CAPTURE_CORRECTION_PLAN) { if (body.reviewer_results !== undefined) throw new TypeError("last_event_closure reviewer_results requires approved state"); if (body.action !== undefined || body.advisory_findings !== undefined || body.status_continuation !== undefined) throw new TypeError("last_event_closure correction-plan cannot carry action, advisory_findings, or status_continuation"); diff --git a/lib/review-publication-gate.ts b/lib/review-publication-gate.ts index 8650cc69d..335934994 100644 --- a/lib/review-publication-gate.ts +++ b/lib/review-publication-gate.ts @@ -393,7 +393,7 @@ function deriveReleaseCiStatusForShaV1(options: { } catch { return { proven: false, status: null }; } - if (!isRecord(summary) || !Number.isSafeInteger(summary.total_count) || !Number.isSafeInteger(summary.returned) || !Array.isArray(summary.checks) || summary.total_count < 0 || summary.returned < 0 || summary.returned !== summary.checks.length || summary.total_count !== summary.checks.length) { + if (!isRecord(summary) || typeof summary.total_count !== "number" || typeof summary.returned !== "number" || !Number.isSafeInteger(summary.total_count) || !Number.isSafeInteger(summary.returned) || !Array.isArray(summary.checks) || summary.total_count < 0 || summary.returned < 0 || summary.returned !== summary.checks.length || summary.total_count !== summary.checks.length) { return { proven: false, status: null }; } if (summary.total_count > 0) { diff --git a/lib/review-risk-assessment.ts b/lib/review-risk-assessment.ts index f0c729deb..b46921aa4 100644 --- a/lib/review-risk-assessment.ts +++ b/lib/review-risk-assessment.ts @@ -139,18 +139,23 @@ const DUE_REVIEW_REASONS: readonly ReviewDueReason[] = [REVIEW_DUE_REASON.HIGH_R /** * Rejects a review_due/review_due_reason/consumed combination the native - * schema can never produce. `already_reviewed` takes precedence exactly when - * the candidate is consumed, so the two must agree in both directions. A - * contradictory envelope is never trusted, least of all as closure evidence. - * Envelopes without the pair (older binaries) are not checked here. + * schema can never produce. A consumed candidate must report + * `already_reviewed`, but that reason can also describe an acknowledged + * committed predecessor followed by a passive delta (#1954). It does not + * prove this exact candidate was consumed; preserve native's explicit false. + * Native v2 requires consumed, so missing evidence with already_reviewed is + * still rejected. Envelopes without the pair (older binaries) are unchecked. */ function validateReviewDueConsistency(reviewDue: boolean, reviewDueReason: ReviewDueReason, candidate: ReviewAssessmentCandidate): void { if (reviewDue !== DUE_REVIEW_REASONS.includes(reviewDueReason)) { throw new TypeError(`review assessment review_due ${reviewDue} contradicts review_due_reason ${reviewDueReason}`); } const alreadyReviewed = reviewDueReason === REVIEW_DUE_REASON.ALREADY_REVIEWED; - if (alreadyReviewed !== (candidate.consumed === true)) { - throw new TypeError("review assessment review_due_reason already_reviewed must be reported exactly when candidate.consumed is true"); + if (candidate.consumed === true && !alreadyReviewed) { + throw new TypeError("review assessment candidate.consumed true requires review_due_reason already_reviewed"); + } + if (alreadyReviewed && candidate.consumed === undefined) { + throw new TypeError("review assessment review_due_reason already_reviewed requires explicit candidate.consumed evidence"); } } diff --git a/lib/review-transaction.ts b/lib/review-transaction.ts index c1b2da06f..fcec08a2a 100644 --- a/lib/review-transaction.ts +++ b/lib/review-transaction.ts @@ -994,11 +994,80 @@ function operationForTransition(transition: ReviewTransition): ReviewOperation { throw new ReviewIntegrityError(`Unsupported reducer transition: ${transition}`); } +function assertReducerInput(transition: ReviewTransition, input: unknown): asserts input is ReviewReducerInput { + const fail = (field: string): never => { throw new ReviewIntegrityError(`Graph reducer input ${field} is invalid`); }; + const object = (value: unknown, field: string): Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return fail(field); + return value as Record; + }; + const string = (value: unknown, field: string): void => { if (typeof value !== "string") fail(field); }; + const boolean = (value: unknown, field: string): void => { if (typeof value !== "boolean") fail(field); }; + const strings = (value: unknown, field: string): void => { if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) fail(field); }; + const rows = (value: unknown, field: string): void => { + if (!Array.isArray(value)) return fail(field); + // The canonical row decoder checks every field, enum and duplicate ID. + createFrozenLedger(value); + }; + const results = (value: unknown, field: string): void => { + if (!Array.isArray(value)) return fail(field); + for (const item of value) { + const result = object(item, field); + string(result.id, `${field}.id`); + if (!Object.values(RESOLUTION_OUTCOME).some((outcome) => outcome === result.outcome)) fail(`${field}.outcome`); + } + }; + const body = object(input, "object"); + switch (transition) { + case REVIEW_TRANSITION.ORDINARY_DISCOVERY: rows(body.rows, "rows"); break; + case REVIEW_TRANSITION.JUDGMENT_DAY_DISCOVERY: rows(body.judgeA, "judgeA"); rows(body.judgeB, "judgeB"); break; + case REVIEW_TRANSITION.ORDINARY_EVIDENCE: + results(body.deterministicResults, "deterministicResults"); + if (body.refuterResults !== undefined) results(body.refuterResults, "refuterResults"); + break; + case REVIEW_TRANSITION.ORDINARY_FIX: + case REVIEW_TRANSITION.JUDGMENT_DAY_FIX: + string(body.candidateTree, "candidateTree"); strings(body.fixedIds, "fixedIds"); string(body.fixDiff, "fixDiff"); + if (body.changedPaths !== undefined) strings(body.changedPaths, "changedPaths"); + break; + case REVIEW_TRANSITION.ORDINARY_NO_FIX: string(body.reason, "reason"); break; + case REVIEW_TRANSITION.ORDINARY_FINAL_VERIFICATION: + case REVIEW_TRANSITION.JUDGMENT_DAY_FINAL_VERIFICATION: + boolean(body.passed, "passed"); if (body.reason !== undefined) string(body.reason, "reason"); break; + case REVIEW_TRANSITION.ORDINARY_VALIDATION: + case REVIEW_TRANSITION.JUDGMENT_DAY_REJUDGMENT: { + const request = object(body.request, "request"); + strings(request.requested_ids, "request.requested_ids"); rows(request.frozen_rows, "request.frozen_rows"); string(request.frozen_ledger_hash, "request.frozen_ledger_hash"); + if (transition === REVIEW_TRANSITION.JUDGMENT_DAY_REJUDGMENT) { + for (const field of ["fix_diff", "fix_diff_hash", "candidate_tree"]) string(request[field], `request.${field}`); + if (typeof request.round !== "number" || !Number.isSafeInteger(request.round) || request.round < 1) fail("request.round"); + results(body.judgeAResults, "judgeAResults"); results(body.judgeBResults, "judgeBResults"); + } else { + results(body.results, "results"); + const acceptance = object(request.original_acceptance_tests, "request.original_acceptance_tests"); + boolean(acceptance.passed, "request.original_acceptance_tests.passed"); string(acceptance.evidence_hash, "request.original_acceptance_tests.evidence_hash"); + strings(request.original_criterion_regressions, "request.original_criterion_regressions"); + if (!Array.isArray(request.correction_regressions) || !Array.isArray(request.follow_ups)) return fail("request.validation arrays"); + for (const value of request.correction_regressions) { + const regression = object(value, "request.correction_regressions"); + string(regression.finding_id, "regression.finding_id"); string(regression.evidence_hash, "regression.evidence_hash"); boolean(regression.passed, "regression.passed"); + } + for (const value of request.follow_ups) { + const followUp = object(value, "request.follow_ups"); + for (const field of ["id", "location", "summary", "evidence_hash"]) string(followUp[field], `follow_up.${field}`); + } + } + break; + } + default: fail("transition"); + } +} + function reduceReviewState( state: ReviewStateV1, transition: ReviewTransition, - input: ReviewReducerInput, + input: unknown, ): ReviewStateV1 { + assertReducerInput(transition, input); switch (transition) { case REVIEW_TRANSITION.ORDINARY_DISCOVERY: return recordOrdinaryDiscovery(state, input as OrdinaryDiscoveryInput); @@ -1456,8 +1525,8 @@ export class ReviewTransactionStore { const existing = current ? (current.body.lineages as Array>).find((value) => value.lineage_id === next.lineage_id && value.mode === "graph") : undefined; if (previous && !existing) throw new ReviewIntegrityError("Graph predecessor is missing"); if (!previous && existing) throw new ReviewIntegrityError("Graph lineage already exists"); - const predecessor = existing?.head_event_id; - if (predecessor !== undefined && typeof predecessor !== "string") throw new ReviewIntegrityError("Graph head is invalid"); + if (existing?.head_event_id !== undefined && typeof existing.head_event_id !== "string") throw new ReviewIntegrityError("Graph head is invalid"); + const predecessor = typeof existing?.head_event_id === "string" ? existing.head_event_id : undefined; const last = next.request_journal.at(-1); const descriptor = (() => { try { return graph.readStoreDescriptor(); } catch { return undefined; } })(); const reducerTransition = eventContext?.transition ?? (predecessor === undefined ? "start" : last?.operation ?? "state-update"); diff --git a/lib/runtime-metrics-children.ts b/lib/runtime-metrics-children.ts index af5a725ba..366488669 100644 --- a/lib/runtime-metrics-children.ts +++ b/lib/runtime-metrics-children.ts @@ -18,7 +18,7 @@ const tokenFields = ["input", "output", "cacheRead", "cacheWrite", "reasoning", * Only the fixed package catalog is cached; runtime instructions are not retained. */ let definitions: Array<{ name: string; fingerprint: string; fingerprintClass?: AgentClass }> | undefined; -const packagedAgentClassAliases = new Map([["sdd-proposal", "sdd-propose"]] as const); +const packagedAgentClassAliases = new Map([["sdd-proposal", "sdd-propose"]]); function fingerprintAgentClassName(name: string): string { const compatibilityName = name.startsWith("gentle-ai-") ? name.slice("gentle-ai-".length) : name; return packagedAgentClassAliases.get(compatibilityName) ?? compatibilityName; diff --git a/lib/shell-sidebar-layout.ts b/lib/shell-sidebar-layout.ts index e65c56d60..23d47bd1b 100644 --- a/lib/shell-sidebar-layout.ts +++ b/lib/shell-sidebar-layout.ts @@ -379,7 +379,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () => stopped = true; state.active = false; clearInterval(timer); - scroll.hideTransientScrollbar(); + scroll.setScrollbar("hidden"); for (const cleanup of cleanups.reverse()) cleanup(); tui.requestRender(); }; diff --git a/lib/vim-editor-adapter.ts b/lib/vim-editor-adapter.ts index b8197e5cc..a4cbeba6e 100644 --- a/lib/vim-editor-adapter.ts +++ b/lib/vim-editor-adapter.ts @@ -1,12 +1,16 @@ import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui"; import { createRequire } from "node:module"; -// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2 and 1.0.0: +// Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0 and 1.1.0: // editor.js state/pastes/history, sticky cursor reset, layoutText/render geometry, // autocomplete cancellation and { state, pastes, pasteCounter } undo restoration; // undo-stack.js clone-on-push/pop snapshots. The two 0.99 releases ship identical // editor/undo-stack sources; 1.0.0 is a touched-contract audit, not byte identity. -// Actual 1.0.0 bundled and unbundled tests exercise identity, edit/undo, paste, +// The 1.1.0 touched-contract audit retains clone-on-push snapshots with the +// paste registry, cursor resets, layoutText/render geometry and history exit. +// cancelAutocomplete aborts/debounces requests before clearing its UI; +// the adapter still calls that host-owned method, never mutating its request state. +// Actual installed bundled/unbundled tests exercise identity, edit/undo, paste, // selection, wrapping/scroll and autocomplete; fabricated metadata tests only // prove exact-version admission for the older audited releases. // Never silently adapt another build: undo also owns the paste registry. @@ -47,7 +51,7 @@ interface PrivateEditor { // The single audited-release source for the adapter and Gentle Shell's runtime // identity gates. Add a release only after re-auditing the files named above. -export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0"] as const); +export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0"] as const); export type AuditedPiEditorVersion = (typeof AUDITED_PI_EDITOR_VERSIONS)[number]; export function isAuditedPiEditorVersion(version: unknown): version is AuditedPiEditorVersion { diff --git a/runtime/native-review-cli.mjs b/runtime/native-review-cli.mjs index 7aa7c589e..df367376a 100644 --- a/runtime/native-review-cli.mjs +++ b/runtime/native-review-cli.mjs @@ -1268,50 +1268,6 @@ function decodeReleaseEvidence(value ) { for (const field of ["release_tree", "configuration_hash", "generated_artifact_hash", "provenance_hash", "publication_boundary_hash", "evidence_freshness_hash"]) requiredString(release[field]); if (release.publication_state !== "sealed" || release.evidence_freshness_state !== "current") throw new Error("invalid release evidence"); } -function decodeNonDecidingGateContext(value , expectedGate ) { - const context = exactObject(value, ["gate"]); - const gate = enumString(context.gate, NATIVE_GATE); - if (gate !== expectedGate) throw new Error("native non-deciding gate context does not match the requested gate"); - return { lineageId: "", storeRevision: "", raw: context }; -} -function decodeGateContext(value ) { - const context = exactObject( - value, - ["gate", "lineage_id", "generation", "base_tree", "candidate_tree", "paths_digest", "fix_delta_hash", "policy_hash", "ledger_hash", "evidence_hash", "base_relationship_valid"], - ["store_revision", "genesis_revision", "chain_identity", "bundle_digest", "external_evidence", "base_advanced_compatible", "release", "pre_pr_boundary", "denial"], - ); - const gate = stringValue(context.gate); - if (gate !== "" && !(NATIVE_GATE ).includes(gate)) throw new Error("invalid gate context gate"); - for (const field of ["lineage_id", "base_tree", "candidate_tree", "paths_digest", "fix_delta_hash", "policy_hash", "ledger_hash", "evidence_hash"]) stringValue(context[field]); - for (const field of ["store_revision", "genesis_revision", "chain_identity", "bundle_digest"]) if (context[field] !== undefined) stringValue(context[field]); - nonNegativeInteger(context.generation); - booleanValue(context.base_relationship_valid); - if (context.external_evidence !== undefined) enumString(context.external_evidence, ["invalidating", "escalating"]); - let sanitizedContext = context; - if (context.denial !== undefined) { - const denial = exactObject(context.denial, ["stage", "code"]); - const stage = sanitizeNativeDiagnosticText(requiredString(denial.stage), NATIVE_REVIEW_DENIAL_TEXT_LIMIT); - const code = sanitizeNativeDiagnosticText(requiredString(denial.code), NATIVE_REVIEW_DENIAL_TEXT_LIMIT); - if (!isCanonicalProcessString(stage) || !isCanonicalProcessString(code)) throw new Error("non-canonical denial evidence"); - sanitizedContext = { ...context, denial: { stage, code } }; - } - if (context.pre_pr_boundary !== undefined) { - const boundary = exactObject(context.pre_pr_boundary, ["source", "selector", "commit"], ["remote", "remote_ref", "remote_identity"]); - enumString(boundary.source, ["explicit", "publication-default"]); requiredString(boundary.selector); stringValue(boundary.commit); - for (const field of ["remote", "remote_ref", "remote_identity"]) if (boundary[field] !== undefined) requiredString(boundary[field]); - } - if (context.base_advanced_compatible !== undefined) { - const proof = exactObject(context.base_advanced_compatible, ["status", "compatible", "old_base_tree", "new_base_tree", "original_patch_identity", "delivered_patch_identity", "delivered_paths_digest", "base_advance_paths_digest", "paths_disjoint", "merged_result_tree", "ci_attestation_artifact_hash", "ci_attestation_issuer", "ci_status"]); - for (const field of ["status", "old_base_tree", "new_base_tree", "original_patch_identity", "delivered_patch_identity", "delivered_paths_digest", "base_advance_paths_digest", "merged_result_tree", "ci_attestation_artifact_hash", "ci_attestation_issuer", "ci_status"]) requiredString(proof[field]); - booleanValue(proof.compatible); booleanValue(proof.paths_disjoint); - } - if (context.release !== undefined) decodeReleaseEvidence(context.release); - return { - lineageId: stringValue(context.lineage_id), - storeRevision: context.store_revision === undefined ? "" : stringValue(context.store_revision), - raw: sanitizedContext, - }; -} function decodeNativeReviewRecovery(value ) { const recovery = exactObject(value, ["predecessor_lineage_id", "predecessor_revision", "disposition", "reason", "actor", "recovered_at"], ["maintainer_authorization"]); return { @@ -1402,7 +1358,7 @@ function decodeNativeReviewStatus(value ) { const complete = booleanValue(body.complete); const authoritative = booleanValue(body.authoritative); if (authoritative && !complete) throw new Error("incomplete inventory cannot be authoritative"); - if (!Array.isArray(body.entries) || !Array.isArray(body.locks)) throw new Error("invalid native status inventory"); + if (!Array.isArray(body.entries) || !Array.isArray(body.locks) || !Array.isArray(body.diagnostics)) throw new Error("invalid native status inventory"); return { repository: requiredString(body.repository), complete, @@ -1587,7 +1543,7 @@ class NativeReviewPlainCli { if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result, true, undefined, this.maxBufferBytes); if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, operation, mutating, "native process timed out", result); if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result); - const maintenancePartialFailure = [NATIVE_REVIEW_OPERATION.ABANDON, NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS].includes(operation) && result.exitCode !== 0; + const maintenancePartialFailure = new Set ([NATIVE_REVIEW_OPERATION.ABANDON, NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS]).has(operation) && result.exitCode !== 0; const toleratedNotice = stderrIsTolerated(result.stderr, toleratedStderr); if (result.exitCode !== 0 && !maintenancePartialFailure) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native process failed", result); if (result.stderr.trim().length > 0 && !maintenancePartialFailure && !toleratedNotice) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); diff --git a/runtime/review-integration-v2.mjs b/runtime/review-integration-v2.mjs index 127e144cc..f6ef5cd7c 100644 --- a/runtime/review-integration-v2.mjs +++ b/runtime/review-integration-v2.mjs @@ -2587,7 +2587,11 @@ function assertReviewApprovedAcknowledgementExecuteShapeV1(execute if (execute.arguments.length !== REVIEW_APPROVED_ACKNOWLEDGEMENT_ARGUMENTS.length) throw new TypeError(`acknowledgement.execute.arguments must carry exactly ${REVIEW_APPROVED_ACKNOWLEDGEMENT_ARGUMENTS.length} provider-issued arguments`); const values = execute.arguments.map((argument, index) => { const name = REVIEW_APPROVED_ACKNOWLEDGEMENT_ARGUMENTS[index] ; if (argument.name !== name) throw new TypeError(`acknowledgement.execute.arguments[${index}].name must be ${name}`); const value = nonempty(argument.value, `acknowledgement.execute.arguments[${index}].value`); if (nonempty(argument.token, `acknowledgement.execute.arguments[${index}].token`) !== `--${name}=${value}`) throw new TypeError(`acknowledgement.execute.arguments[${index}].token must exactly match ${name}`); return value; }); if (execute.preconditions.length !== 1 || execute.preconditions[0]?.name !== "state" || execute.preconditions[0]?.value !== "approved") throw new TypeError("acknowledgement.execute.preconditions must be the single approved state precondition"); - return { tokens: execute.arguments.map((argument) => argument.token ) , values: values , lineageId: lineage(execute.binding.lineageId, "acknowledgement.execute.binding.lineage_id"), targetIdentity: sha256(execute.binding.targetIdentity, "acknowledgement.execute.binding.target_identity"), revision: sha256(execute.binding.revision, "acknowledgement.execute.binding.revision") }; + const tokens = execute.arguments.map((argument) => argument.token ); + // The exact five-argument check above validates both vectors before tuple construction. + const tokenTuple = [tokens[0] , tokens[1] , tokens[2] , tokens[3] , tokens[4] ]; + const valueTuple = [values[0] , values[1] , values[2] , values[3] , values[4] ]; + return { tokens: tokenTuple, values: valueTuple, lineageId: lineage(execute.binding.lineageId, "acknowledgement.execute.binding.lineage_id"), targetIdentity: sha256(execute.binding.targetIdentity, "acknowledgement.execute.binding.target_identity"), revision: sha256(execute.binding.revision, "acknowledgement.execute.binding.revision") }; } /** @@ -2921,7 +2925,7 @@ export function decodeReviewLastEventClosureV1(value ) storeRevision: sha256(body.store_revision, "last_event_closure.store_revision"), ...(escalation === undefined ? {} : { escalation }), ...(targetedValidatorEvidence === undefined ? {} : { targetedValidatorEvidence }), - }; + } ; if (operation === REVIEW_LAST_EVENT_CLOSURE_OPERATION.CAPTURE_CORRECTION_PLAN) { if (body.reviewer_results !== undefined) throw new TypeError("last_event_closure reviewer_results requires approved state"); if (body.action !== undefined || body.advisory_findings !== undefined || body.status_continuation !== undefined) throw new TypeError("last_event_closure correction-plan cannot carry action, advisory_findings, or status_continuation"); diff --git a/runtime/review-risk-assessment.mjs b/runtime/review-risk-assessment.mjs index 55b26f82f..4a49f2af1 100644 --- a/runtime/review-risk-assessment.mjs +++ b/runtime/review-risk-assessment.mjs @@ -140,18 +140,23 @@ const DUE_REVIEW_REASONS = [REVIEW_DUE_REASON.HIGH_R /** * Rejects a review_due/review_due_reason/consumed combination the native - * schema can never produce. `already_reviewed` takes precedence exactly when - * the candidate is consumed, so the two must agree in both directions. A - * contradictory envelope is never trusted, least of all as closure evidence. - * Envelopes without the pair (older binaries) are not checked here. + * schema can never produce. A consumed candidate must report + * `already_reviewed`, but that reason can also describe an acknowledged + * committed predecessor followed by a passive delta (#1954). It does not + * prove this exact candidate was consumed; preserve native's explicit false. + * Native v2 requires consumed, so missing evidence with already_reviewed is + * still rejected. Envelopes without the pair (older binaries) are unchecked. */ function validateReviewDueConsistency(reviewDue , reviewDueReason , candidate ) { if (reviewDue !== DUE_REVIEW_REASONS.includes(reviewDueReason)) { throw new TypeError(`review assessment review_due ${reviewDue} contradicts review_due_reason ${reviewDueReason}`); } const alreadyReviewed = reviewDueReason === REVIEW_DUE_REASON.ALREADY_REVIEWED; - if (alreadyReviewed !== (candidate.consumed === true)) { - throw new TypeError("review assessment review_due_reason already_reviewed must be reported exactly when candidate.consumed is true"); + if (candidate.consumed === true && !alreadyReviewed) { + throw new TypeError("review assessment candidate.consumed true requires review_due_reason already_reviewed"); + } + if (alreadyReviewed && candidate.consumed === undefined) { + throw new TypeError("review assessment review_due_reason already_reviewed requires explicit candidate.consumed evidence"); } } diff --git a/scripts/check-types.mjs b/scripts/check-types.mjs index 9b8303c2b..5d61cd603 100644 --- a/scripts/check-types.mjs +++ b/scripts/check-types.mjs @@ -1,11 +1,9 @@ #!/usr/bin/env node -// Type gate for a project that does not compile cleanly yet. +// TypeScript diagnostic ratchet; the committed baseline is now clean. // -// `tsc --noEmit` reports a fixed set of diagnostics on this repository today, so -// the gate is a ratchet rather than a clean pass: it fails when the number of -// diagnostics grows for any (file, error code) pair, or when the total grows. -// Fixing diagnostics and refreshing the baseline is the intended way to shrink -// it. +// With a zero baseline, every diagnostic fails the gate. If a nonzero baseline +// is explicitly accepted, increases for any (file, error code) pair or the +// total fail. Fixing diagnostics and refreshing the baseline strengthens it. // // Known blind spot, stated rather than hidden: keying on (file, code) counts is // what keeps the baseline stable while files are edited, because line numbers diff --git a/scripts/types-baseline.json b/scripts/types-baseline.json index e68a3f024..99851a98a 100644 --- a/scripts/types-baseline.json +++ b/scripts/types-baseline.json @@ -1,95 +1,5 @@ { "note": "Recorded TypeScript diagnostics. Regenerate with `node scripts/check-types.mjs --update` after fixing or deliberately accepting diagnostics. This file may only shrink without a review decision.", - "total": 200, - "byFileAndCode": { - "extensions/codegraph-tools.ts TS2339": 1, - "extensions/codegraph-tools.ts TS2345": 1, - "extensions/gentle-agents.ts TS2322": 3, - "extensions/gentle-ai.ts TS2322": 5, - "extensions/gentle-ai.ts TS2339": 11, - "extensions/gentle-ai.ts TS2345": 15, - "extensions/gentle-ai.ts TS2365": 1, - "extensions/gentle-ai.ts TS2488": 2, - "extensions/gentle-ai.ts TS2554": 1, - "extensions/gentle-ai.ts TS2740": 1, - "extensions/gentle-shell.ts TS2556": 1, - "extensions/quiet-tools.ts TS2339": 1, - "extensions/quiet-tools.ts TS2352": 2, - "lib/agents-messaging.ts TS2322": 1, - "lib/native-review-cli.ts TS2304": 6, - "lib/native-review-cli.ts TS2339": 1, - "lib/native-review-cli.ts TS2345": 1, - "lib/review-candidate-view.ts TS2353": 2, - "lib/review-compact-contract.ts TS2322": 2, - "lib/review-compact-contract.ts TS2365": 1, - "lib/review-integration-v2.ts TS2322": 2, - "lib/review-integration-v2.ts TS2352": 2, - "lib/review-publication-gate.ts TS2365": 2, - "lib/review-transaction.ts TS2322": 1, - "lib/review-transaction.ts TS2345": 1, - "lib/runtime-metrics-children.ts TS2345": 2, - "lib/sdd-preflight.ts TS2352": 1, - "lib/sdd-preflight.ts TS2365": 1, - "lib/shell-sidebar-layout.ts TS2341": 1, - "tests/agents-queries.test.ts TS2322": 1, - "tests/agents-queries.test.ts TS2345": 3, - "tests/agents-runner-process.test.ts TS2322": 1, - "tests/devbinary/native-review-parity.devtest.ts TS2339": 1, - "tests/devbinary/pi-host-relay.devtest.ts TS2339": 3, - "tests/gentle-ai-binary.test.ts TS2339": 2, - "tests/gentle-ai-installer.test.ts TS2339": 7, - "tests/gentle-ai-installer.test.ts TS2556": 1, - "tests/gentle-ai-renderer.test.ts TS2345": 6, - "tests/gentle-ai.test.ts TS2339": 1, - "tests/gentle-ai.test.ts TS2352": 8, - "tests/gentle-ai.test.ts TS2353": 1, - "tests/gentle-shell.test.ts TS2352": 1, - "tests/gentle-shell.test.ts TS2540": 1, - "tests/gentle-shell.test.ts TS2554": 1, - "tests/maintainer/provider-relay.maintest.ts TS2339": 17, - "tests/maintainer/provider-relay.maintest.ts TS2345": 1, - "tests/maintainer/provider-relay.maintest.ts TS2540": 2, - "tests/native-binary-gate.test.ts TS2339": 2, - "tests/native-review-consent.test.ts TS2554": 1, - "tests/native-review-parity-runtime.test.ts TS2339": 1, - "tests/quiet-tool-rendering.test.ts TS2339": 1, - "tests/quiet-tool-rendering.test.ts TS2345": 1, - "tests/review-agent-end-preflight.test.ts TS2339": 2, - "tests/review-agent-end-preflight.test.ts TS2345": 1, - "tests/review-candidate-view.test.ts TS2353": 4, - "tests/review-candidate-view.test.ts TS2540": 2, - "tests/review-candidate-view.test.ts TS2554": 2, - "tests/review-controller-native-recovery.test.ts TS2345": 1, - "tests/review-controller-native-routing.test.ts TS2339": 2, - "tests/review-controller-workspace-root.test.ts TS2322": 2, - "tests/review-correction-lifecycle.test.ts TS2339": 4, - "tests/review-gate.test.ts TS2322": 5, - "tests/review-gate.test.ts TS2345": 4, - "tests/review-gate.test.ts TS2740": 1, - "tests/review-graph-schema.test.ts TS2352": 2, - "tests/review-host-relay-routing.test.ts TS2345": 1, - "tests/review-host-relay-routing.test.ts TS2739": 1, - "tests/review-integration-v2-forward.test.ts TS2322": 1, - "tests/review-last-event-closure.test.ts TS2322": 1, - "tests/review-policy-judgment-day.test.ts TS2345": 1, - "tests/review-policy-ordinary.test.ts TS2345": 1, - "tests/review-policy-ordinary.test.ts TS2554": 1, - "tests/review-risk-assessment.test.ts TS2345": 1, - "tests/review-risk-assessment.test.ts TS2352": 1, - "tests/review-session-standing-permission-controller.test.ts TS2339": 1, - "tests/review-session-standing-permission-controller.test.ts TS2345": 1, - "tests/review-session-standing-permission-controller.test.ts TS2352": 1, - "tests/review-session-standing-permission-ipc.test.ts TS2322": 4, - "tests/review-test-fixtures.ts TS2739": 1, - "tests/review-transaction.test.ts TS2339": 1, - "tests/runtime-metrics-extension.test.ts TS2345": 1, - "tests/sdd-preflight.test.ts TS2352": 1, - "tests/sdd-preflight.test.ts TS2554": 2, - "tests/sdd-research-live.test.ts TS2322": 1, - "tests/shell-sidebar-layout.test.ts TS2339": 1, - "tests/shell-sidebar-layout.test.ts TS2345": 1, - "tests/shell-usage-view.test.ts TS2322": 1, - "tests/skill-registry.test.ts TS2345": 5, - "tests/writer-edit-surface-scope.test.ts TS2352": 1 - } + "total": 0, + "byFileAndCode": {} } diff --git a/tests/agents-queries.test.ts b/tests/agents-queries.test.ts index 6b39c4876..482ccf95a 100644 --- a/tests/agents-queries.test.ts +++ b/tests/agents-queries.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { AGENT_MODE, type AgentDefinition } from "../lib/agents-config.ts"; +import { AGENT_MODE, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts"; import { CHILD_MESSAGE_MAX_BYTES, ChildMessenger } from "../lib/agents-messaging.ts"; import { TaskStore } from "../lib/agents-protocol.ts"; import { AgentRunner } from "../lib/agents-runner.ts"; @@ -8,7 +8,7 @@ import { fakeChild } from "./agents-fake-child.ts"; const tick = () => new Promise((resolve) => setImmediate(resolve)); const agent: AgentDefinition = { name: "worker", description: "", filePath: "/a.md", scope: "global", instructions: "", model: undefined, thinking: undefined, mode: undefined, tools: ["read"] }; -const request = (mode = AGENT_MODE.BACKGROUND) => ({ agent, prompt: "work", label: undefined, context: undefined, mode, cwd: "/repo", parentSessionId: "s1", model: undefined, thinking: undefined, sessionDir: "/sessions", resumeSessionPath: undefined, env: {} }); +const request = (mode: AgentMode = AGENT_MODE.BACKGROUND) => ({ agent, prompt: "work", label: undefined, context: undefined, mode, cwd: "/repo", parentSessionId: "s1", model: undefined, thinking: undefined, sessionDir: "/sessions", resumeSessionPath: undefined, env: {} }); test("ChildMessenger validates query replies and settles timeout, callback failure, and disconnect once", async () => { const listeners = new Map) => void>>(); @@ -74,7 +74,7 @@ test("ChildMessenger rejects an empty reply error and ignores mixed reply fields test("runner sends bounded static query rejection frames that settle the child before timeout", async () => { const child = fakeChild(); - const listeners = new Map) => void>>(); + const listeners = new Map void>>(); const timers: Array<{ cancelled: boolean; fn: () => void }> = []; child.child.send = (frame, done) => { child.sent.push(frame); @@ -83,8 +83,8 @@ test("runner sends bounded static query rejection frames that settle the child b return true; }; const messenger = new ChildMessenger({ - send: (frame) => { child.message(frame); return true; }, - on: (event, listener) => listeners.set(event, [...(listeners.get(event) ?? []), listener as (value: Record) => void]), + send: (frame: Record) => { child.message(frame); return true; }, + on: (event, listener) => listeners.set(event, [...(listeners.get(event) ?? []), listener]), }, (fn) => { const timer = { cancelled: false, fn }; timers.push(timer); @@ -102,7 +102,7 @@ test("runner sends bounded static query rejection frames that settle the child b test("a malformed query with a valid q ID gets a reply that clears the matching child query", async () => { const child = fakeChild(); - const listeners = new Map) => void>>(); + const listeners = new Map void>>(); const timers: Array<{ cancelled: boolean; fn: () => void }> = []; child.child.send = (frame, done) => { child.sent.push(frame); @@ -111,8 +111,8 @@ test("a malformed query with a valid q ID gets a reply that clears the matching return true; }; const messenger = new ChildMessenger({ - send: (frame) => { child.message(frame); return true; }, - on: (event, listener) => listeners.set(event, [...(listeners.get(event) ?? []), listener as (value: Record) => void]), + send: (frame: Record) => { child.message(frame); return true; }, + on: (event, listener) => listeners.set(event, [...(listeners.get(event) ?? []), listener]), }, (fn) => { const timer = { cancelled: false, fn }; timers.push(timer); @@ -133,7 +133,7 @@ test("background queries keep correlation, bounds, and ownership before replying const child = fakeChild(); const queries: Array<{ taskId: string; requestId: string; message: string }> = []; const runner = new AgentRunner(new TaskStore(), { maxConcurrency: 1, stallTimeoutMs: 10_000 }, { spawn: () => child.child, now: () => 1, schedule: () => () => {}, pi: { command: "pi", args: [] } }, { - askUser: async () => ({ cancelled: true }), onQuery: (task, requestId, message) => queries.push({ taskId: task.id, requestId, message }), + askUser: async () => ({ cancelled: true }), onQuery: (task, requestId, message) => { queries.push({ taskId: task.id, requestId, message }); }, }); const task = runner.run(request()); await tick(); diff --git a/tests/agents-runner-process.test.ts b/tests/agents-runner-process.test.ts index ea315bc3d..583f81e8a 100644 --- a/tests/agents-runner-process.test.ts +++ b/tests/agents-runner-process.test.ts @@ -5,6 +5,7 @@ import test from "node:test"; import { AGENT_MODE, type AgentDefinition } from "../lib/agents-config.ts"; import { AgentRunner, type ChildLike, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts"; import { TASK_STATUS, TaskStore } from "../lib/agents-protocol.ts"; +import { fakeChild } from "./agents-fake-child.ts"; const fixture = fileURLToPath(new URL("./fixtures/agents-process-child.mjs", import.meta.url)); const agent: AgentDefinition = { name: "process", description: "test", filePath: "/test.md", scope: "global", instructions: "", model: undefined, thinking: undefined, mode: undefined, tools: [] }; @@ -18,6 +19,36 @@ const waitFor = async (predicate: () => boolean, timeoutMs = 10_000): Promise { + const store = new TaskStore(); + const child = fakeChild(); + let launched = false; + const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 1000 }, { + spawn: () => ({ ...child.child, stdin: null, stdout: null }), now: Date.now, + schedule: (fn, ms) => { const timer = setTimeout(fn, ms); return () => clearTimeout(timer); }, + pi: { command: "pi", args: [] }, + }, { askUser: async () => ({ cancelled: true }) }); + const task = runner.run({ ...request("missing streams"), onLaunch: () => { launched = true; } }); + await waitFor(() => store.get(task.id)?.status === TASK_STATUS.FAILED); + assert.equal(launched, false); + assert.match(store.get(task.id)!.error!, /RPC streams/); + assert.ok(child.killed.length > 0, "the malformed child is still stopped"); +}); + +test("a real failed OS spawn has no pid and is retained as a launch failure", async () => { + const store = new TaskStore(); + let launched = false; + const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 1000 }, { + spawn: (_command, _args, options) => nodeSpawn("/nonexistent/gentle-pi-child", [], { ...options, stdio: ["pipe", "pipe", "pipe", "ipc"] }), + now: Date.now, schedule: (fn, ms) => { const timer = setTimeout(fn, ms); return () => clearTimeout(timer); }, + pi: { command: "unused", args: [] }, + }, { askUser: async () => ({ cancelled: true }) }); + const task = runner.run({ ...request("failed spawn"), onLaunch: () => { launched = true; } }); + await waitFor(() => store.get(task.id)?.status === TASK_STATUS.FAILED); + assert.equal(launched, false); + assert.match(store.get(task.id)!.error!, /ENOENT/); +}); + test("POSIX cleanup retains queue slots when a leader exits but its TERM-resisting descendant remains", { skip: process.platform === "win32" }, async () => { const store = new TaskStore(); let launches = 0; diff --git a/tests/agents-runner.test.ts b/tests/agents-runner.test.ts index e402e2f00..82564c6d8 100644 --- a/tests/agents-runner.test.ts +++ b/tests/agents-runner.test.ts @@ -1032,7 +1032,7 @@ for (const [platform, detached] of [["win32", false], ["linux", true]] as const) assert.deepEqual(launches, [{ command: "pi-fixture", args: ["--from-host", "--mode", "rpc", "--session-dir", "/sessions", "--model", "openai-codex/gpt-5.6-terra:high", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."], - options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }, detached, stdio: ["pipe", "pipe", "pipe", "ipc"] }, + options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc, [REQUESTED_TOOLS_ENV]: "read,grep,subagent_parent_message" }, detached, windowsHide: true, stdio: ["pipe", "pipe", "pipe", "ipc"] }, }]); child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "platform checked" }], stopReason: "stop" }] }); child.emit({ type: "agent_settled" }); @@ -1142,6 +1142,8 @@ test("AgentRunner platform matrix scopes permission fd3 transport", async () => await tick(); const launch = launches[0]; assert.ok(launch, `${platform} ${eligible ? "eligible" : "ineligible"} child launches`); + assert.equal(launch.windowsHide, true, `${platform} children hide console windows regardless of permission eligibility`); + assert.equal(launch.detached, platform !== "win32", "only non-Windows children use detached process groups"); assert.equal(launch.env.GENTLE_PI_AGENTS_PARENT_PERMISSION_FD, eligible ? "3" : undefined, "only eligible children receive the fd3 marker"); assert.deepEqual(launch.stdio, eligible ? ["pipe", "pipe", "pipe", platform === "win32" ? "overlapped" : "pipe", "ipc"] : ["pipe", "pipe", "pipe", "ipc"]); assert.equal(launch.stdio?.indexOf("ipc"), eligible ? 4 : 3, "messaging IPC follows fd3 only for eligible children"); diff --git a/tests/codemode-rendering.test.ts b/tests/codemode-rendering.test.ts index 75b325eab..bb476d143 100644 --- a/tests/codemode-rendering.test.ts +++ b/tests/codemode-rendering.test.ts @@ -42,12 +42,13 @@ function registeredCodemode() { } function context(overrides: Partial = {}): ToolRenderContext { - return { + const fixture = { args: { code: "await tools.read({path: '/private/argument'});" }, toolCallId: "code-1", invalidate() {}, lastComponent: undefined, state: {}, cwd: "/fixture", executionStarted: true, argsComplete: true, isPartial: false, expanded: false, showImages: false, - isError: false, ...overrides, + isError: false, durationMs: undefined, outputPad: 0, ...overrides, }; + return fixture; } function result(calls: unknown = [], text = "Script completed\nWall time 0.1 seconds\nOutput:\n"): AgentToolResult { @@ -108,6 +109,7 @@ test("public loadout modes preserve hidden and deferred exposure policy", async declared: [tool, direct], callable: [direct, deferred], registered: [tool, direct, deferred, hidden], getExposure: (name: string) => name === "deferred_fixture" ? "deferred" : name === "hidden_fixture" ? "hidden" : "direct", getNamespace: () => undefined, + getPromptGuidelines: () => [], } as unknown as Parameters>[0]; const changes = tool.prepareLoadout!(loadout)!; assert.deepEqual(changes.hiddenDeclarations, mode === "only" ? ["direct_fixture"] : []); diff --git a/tests/devbinary/native-review-parity.devtest.ts b/tests/devbinary/native-review-parity.devtest.ts index 8544f8d4b..d71e8b9a7 100644 --- a/tests/devbinary/native-review-parity.devtest.ts +++ b/tests/devbinary/native-review-parity.devtest.ts @@ -32,7 +32,7 @@ const devBinaryGate = requireDevBinary({ exists: typeof DEV_BINARY === "string" && DEV_BINARY.length > 0 && DEV_BINARY.startsWith("/") && existsSync(DEV_BINARY), env: process.env, }); -if (!devBinaryGate.run) console.log(`tests/devbinary/native-review-parity.devtest.ts: ${devBinaryGate.reason}`); +if (devBinaryGate.run === false) console.log(`tests/devbinary/native-review-parity.devtest.ts: ${devBinaryGate.reason}`); const RUNNABLE = devBinaryGate.run; const DEV_HOME = mkdtempSync(join(tmpdir(), "gentle-pi-dev-binary-home-")); const ORIGINAL_HOME = process.env.HOME; diff --git a/tests/devbinary/pi-host-relay.devtest.ts b/tests/devbinary/pi-host-relay.devtest.ts index 582479ff7..0bf6ab672 100644 --- a/tests/devbinary/pi-host-relay.devtest.ts +++ b/tests/devbinary/pi-host-relay.devtest.ts @@ -85,9 +85,9 @@ const relayDevBinaryGate = POSIX }) : { run: false as const, reason: "Windows is explicitly skipped until a native fake-pi.exe exists; this test never enables a shell fallback." }; const RUNNABLE = POSIX && primaryDevBinaryGate.run && relayDevBinaryGate.run; -if (!POSIX) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${relayDevBinaryGate.reason}`); -if (!primaryDevBinaryGate.run) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${primaryDevBinaryGate.reason}`); -if (!relayDevBinaryGate.run && POSIX) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${relayDevBinaryGate.reason}`); +if (relayDevBinaryGate.run === false && !POSIX) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${relayDevBinaryGate.reason}`); +if (primaryDevBinaryGate.run === false) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${primaryDevBinaryGate.reason}`); +if (relayDevBinaryGate.run === false && POSIX) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${relayDevBinaryGate.reason}`); const ZERO_FINDING_PATHS = Object.freeze([".github/workflows/relay.yml"]); diff --git a/tests/gentle-ai-binary.test.ts b/tests/gentle-ai-binary.test.ts index 120d279a2..2a2c73b60 100644 --- a/tests/gentle-ai-binary.test.ts +++ b/tests/gentle-ai-binary.test.ts @@ -32,7 +32,7 @@ const nativeBinaryGate = requireNativeBinary({ digestsPinned: releaseDigestsPinned, env: process.env, }); -if (!nativeBinaryGate.run) console.log(`gentle-ai-binary: ${nativeBinaryGate.reason}`); +if (nativeBinaryGate.run === false) console.log(`gentle-ai-binary: ${nativeBinaryGate.reason}`); const verifiedBinaryTest = nativeBinaryGate.run && process.platform !== "win32" ? test : test.skip; interface PinnedBinaryIsolation { @@ -44,6 +44,7 @@ interface PinnedBinaryIsolation { let pinnedBinaryIsolation: PinnedBinaryIsolation | undefined; test.beforeEach((t) => { + assert.ok("after" in t); const home = mkdtempSync(join(tmpdir(), "gentle-pi-pinned-binary-home-")); const environment: GentleAiDevBinaryEnvironment = { env: { ...process.env }, home }; delete environment.env.GENTLE_PI_CONFIG_HOME; diff --git a/tests/gentle-ai-installer.test.ts b/tests/gentle-ai-installer.test.ts index 5db3a3f46..181c51e76 100644 --- a/tests/gentle-ai-installer.test.ts +++ b/tests/gentle-ai-installer.test.ts @@ -26,6 +26,19 @@ import { // v4.0.0 archive digests independently match the minisign-signed release // checksums; binary digests were computed from the extracted executables. +function releaseAssets(): Record { + const assets: Record = {}; + for (const [key, value] of Object.entries(GENTLE_AI_RELEASE_ASSETS)) { + assert.ok(typeof value === "object" && value !== null); + assert.ok("name" in value && typeof value.name === "string"); + assert.ok("sha256" in value && typeof value.sha256 === "string"); + assert.ok("binarySha256" in value && typeof value.binarySha256 === "string"); + assert.ok("url" in value && typeof value.url === "string"); + assets[key] = { name: value.name, sha256: value.sha256, binarySha256: value.binarySha256, url: value.url }; + } + return assets; +} + const EXPECTED_ASSETS = { "darwin/amd64": { name: "gentle-ai_4.0.0_darwin_amd64.tar.gz", sha256: "b5b74f22b38ec3339b38e8c68f797dc76ff12ed6580826a6e425d5c718da80c1", binarySha256: "d4a5b16ff70e65331e17a62356941bb0c75ecb9dbe3a0d98a6b54cfbd76cd6b0" }, "darwin/arm64": { name: "gentle-ai_4.0.0_darwin_arm64.tar.gz", sha256: "d2159caf6d68f367b18830ece6af71ef26963d5f5320d7df6a794773f45cc7e9", binarySha256: "18a9f7fae55d85c95684b6d512a4a148d0cb24a856325f72573c34caf65159eb" }, @@ -50,12 +63,12 @@ test("default installer package root is the package containing scripts, not its test("release mapping selects only the supported official v4.0.0 assets and pinned digests", () => { assert.deepEqual( - Object.fromEntries(Object.entries(GENTLE_AI_RELEASE_ASSETS).map(([key, asset]) => [key, { name: asset.name, sha256: asset.sha256, binarySha256: asset.binarySha256 }])), + Object.fromEntries(Object.entries(releaseAssets()).map(([key, asset]) => [key, { name: asset.name, sha256: asset.sha256, binarySha256: asset.binarySha256 }])), EXPECTED_ASSETS, ); assert.equal(resolveGentleAiReleaseAsset("linux", "x64").name, "gentle-ai_4.0.0_linux_amd64.tar.gz"); assert.equal(resolveGentleAiReleaseAsset("darwin", "arm64").name, "gentle-ai_4.0.0_darwin_arm64.tar.gz"); - for (const asset of Object.values(GENTLE_AI_RELEASE_ASSETS)) { + for (const asset of Object.values(releaseAssets())) { assert.match(asset.url, /^https:\/\/github\.com\/Gentleman-Programming\/gentle-ai\/releases\/download\/v4\.0\.0\//); } }); @@ -71,13 +84,13 @@ test("raw release assets are admitted only under a prerelease pin", () => { // The current stable pin admits every pinned asset row through the same // gate the installer uses at download time (default installerVersion // argument): signed archives, never raw binaries. - for (const asset of Object.values(GENTLE_AI_RELEASE_ASSETS)) { + for (const asset of Object.values(releaseAssets())) { assert.equal(gentleAiAssetForm(asset.name), "archive"); } }); test("release digests are all-or-none and install fails closed while any digest is pending", async () => { - const digests = Object.values(GENTLE_AI_RELEASE_ASSETS).flatMap((asset) => [asset.sha256, asset.binarySha256]); + const digests = Object.values(releaseAssets()).flatMap((asset) => [asset.sha256, asset.binarySha256]); const pinned = digests.filter((digest) => /^[0-9a-f]{64}$/.test(digest)); const pending = digests.filter((digest) => digest === GENTLE_AI_PENDING_DIGEST); assert.equal(pinned.length + pending.length, digests.length, "every digest must be pinned hex or the explicit pending sentinel"); @@ -1127,7 +1140,7 @@ function pendingRequest() { } test("download bounds stalled headers and bodies with transient retry exhaustion", async (t) => { for (const [stage, request] of [ - ["headers", () => pendingRequest()], + ["headers", (_url: URL, _options: unknown, _callback: (response: PassThrough & { statusCode?: number; headers: Record }) => void) => pendingRequest()], ["body", (_url: URL, _options: unknown, callback: (response: PassThrough & { statusCode?: number; headers: Record }) => void) => { const response = Object.assign(new PassThrough(), { statusCode: 200, headers: {} }); queueMicrotask(() => callback(response)); @@ -1142,7 +1155,7 @@ test("download bounds stalled headers and bodies with transient retry exhaustion t.after(() => rm(directory, { recursive: true, force: true })); const destination = join(directory, stage); let attempts = 0; - await assert.rejects(() => downloadGentleAiAsset("https://example.invalid/archive", destination, 1024, 0, { request: (...args: never[]) => { attempts += 1; return request(...args); }, headerTimeoutMs: 1, bodyTimeoutMs: 1, maxAttempts: 2, retryDelayMs: 0 }), new RegExp(`download ${stage} timed out`)); + await assert.rejects(() => downloadGentleAiAsset("https://example.invalid/archive", destination, 1024, 0, { request: (url: URL, options: unknown, callback: (response: PassThrough & { statusCode?: number; headers: Record }) => void) => { attempts += 1; return request(url, options, callback); }, headerTimeoutMs: 1, bodyTimeoutMs: 1, maxAttempts: 2, retryDelayMs: 0 }), new RegExp(`download ${stage} timed out`)); assert.equal(attempts, 2); // A failed attempt settles only once its destination stream closed, so the retry // never races a late recreation of the path, and nothing partial survives. diff --git a/tests/gentle-ai-renderer.test.ts b/tests/gentle-ai-renderer.test.ts index 52f96c3ec..6f71b7841 100644 --- a/tests/gentle-ai-renderer.test.ts +++ b/tests/gentle-ai-renderer.test.ts @@ -68,7 +68,7 @@ test("review call and result cards have no passive background fill", (t) => { ]) { const call = new GentleAiCallCard(); call.update(options.isPartial ? "running" : "completed", "review capture", theme, "$ capture"); - const lines = [...call.render(40), ...renderGentleAiResult({ content: [{ type: "text", text: "Result" }] }, options, theme).render(40)]; + const lines = [...call.render(40), ...renderGentleAiResult({ content: [{ type: "text", text: "Result" }], details: {} }, options, theme).render(40)]; for (const [row, line] of lines.entries()) { let bg = false, column = 0; for (const token of line.match(/\x1b\[[\d;]*m|[^\x1b]/gu) ?? []) { @@ -86,14 +86,14 @@ test("review call and result cards have no passive background fill", (t) => { }); test("a partial result draws no bottom rule and a final one draws exactly one", () => { - const partial = renderGentleAiResult({ content: [{ type: "text", text: "half" }] }, { expanded: false, isPartial: true }, plainTheme).render(60).map(stripAnsi); + const partial = renderGentleAiResult({ content: [{ type: "text", text: "half" }], details: {} }, { expanded: false, isPartial: true }, plainTheme).render(60).map(stripAnsi); assert.deepEqual(partial.map((line) => line.slice(0, 1)), ["│"], "only the preview row, no closing rule"); - const final = renderGentleAiResult({ content: [{ type: "text", text: "one\ntwo" }] }, { expanded: false }, plainTheme).render(60).map(stripAnsi); + const final = renderGentleAiResult({ content: [{ type: "text", text: "one\ntwo" }], details: {} }, { expanded: false }, plainTheme).render(60).map(stripAnsi); assert.equal(final.length, 3); assert.match(final[0], /^│ one +│$/); assert.match(final[1], /^│ two +│$/); assert.match(final[2], /^╰─+╯$/); - const empty = renderGentleAiResult({ content: [] }, { expanded: false }, plainTheme).render(60).map(stripAnsi); + const empty = renderGentleAiResult({ content: [], details: {} }, { expanded: false }, plainTheme).render(60).map(stripAnsi); assert.deepEqual(empty.map((line) => line.slice(0, 1)), ["╰"]); }); @@ -101,11 +101,11 @@ test("promoting the shared state to finished invalidates after the render return const state: Record = {}; let invalidations = 0; const context = { state, invalidate: () => (invalidations += 1) }; - renderGentleAiResult({ content: [{ type: "text", text: "done" }] }, { expanded: false }, plainTheme, context as never); + renderGentleAiResult({ content: [{ type: "text", text: "done" }], details: {} }, { expanded: false }, plainTheme, context as never); assert.equal(invalidations, 0, "no reentrant invalidate while rendering"); await new Promise((resolve) => queueMicrotask(() => resolve(undefined))); assert.equal(invalidations, 1); - renderGentleAiResult({ content: [{ type: "text", text: "done" }] }, { expanded: false }, plainTheme, context as never); + renderGentleAiResult({ content: [{ type: "text", text: "done" }], details: {} }, { expanded: false }, plainTheme, context as never); await new Promise((resolve) => queueMicrotask(() => resolve(undefined))); assert.equal(invalidations, 1, "an unchanged state does not invalidate again"); }); diff --git a/tests/gentle-ai.test.ts b/tests/gentle-ai.test.ts index ae5a328aa..3cca03f67 100644 --- a/tests/gentle-ai.test.ts +++ b/tests/gentle-ai.test.ts @@ -296,7 +296,7 @@ test("registered Gentle Review tools preserve result envelopes and preview usefu { manifest: encoded, sha256, cursor: 0 }, undefined, undefined, - { cwd: process.cwd() } as ExtensionContext, + { cwd: process.cwd() } as unknown as ExtensionContext, ); const visibleEnvelope = JSON.parse(result.content[0].text); assert.deepEqual(visibleEnvelope, { @@ -729,7 +729,7 @@ test("session startup reports invalid project routing without mutating the profi registerCommand() {}, registerTool() {}, } as unknown as ExtensionAPI; - createGentleAiExtension({ nativeReviewCli: null })(pi); + createGentleAiExtension({ nativeReviewCli: null, processEnv: { GENTLE_PI_AGENTS_CHILD: "0" } })(pi); const sessionStart = handlers.get("session_start"); assert.equal(typeof sessionStart, "function"); const notifications: Array<{ message: string; severity: string }> = []; @@ -1338,7 +1338,7 @@ test("delivery commands bypass RDD under every mode outcome while command safety cwd: process.cwd(), hasUI: true, ui: { confirm: async () => true }, - } as ExtensionContext; + } as unknown as ExtensionContext; for (const command of commands) { const result = await toolCall!({ toolName: "bash", input: { command } }, ctx); @@ -1380,7 +1380,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc sequence.push( channel === "herdr:blocked" ? `herdr:${"active" in data && data.active ? "active" : "inactive"}` - : `event:${data.state}`, + : `event:${"state" in data ? data.state : "unknown"}`, ); emitted.push({ channel, data } as EmittedEvent); }, @@ -1402,7 +1402,7 @@ test("guarded command confirmation emits a generic correlated permission lifecyc return confirm(); }, }, - } as ExtensionContext; + } as unknown as ExtensionContext; let resolveConfirmation!: (approved: boolean) => void; confirm = () => new Promise((resolve) => { resolveConfirmation = resolve; }); const denied = toolCall!({ @@ -1536,7 +1536,7 @@ test("concurrent guarded confirmations coalesce the Herdr lifecycle per extensio ui: { confirm: async () => new Promise((resolve) => { confirmations.push(resolve); }), }, - } as ExtensionContext); + } as unknown as ExtensionContext); const firstRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main" } }, context(first.confirmations)); const secondRequest = first.handlers.get("tool_call")!({ toolName: "bash", input: { command: "git rebase main --another-command" } }, context(first.confirmations)); await Promise.resolve(); @@ -1695,7 +1695,7 @@ test("Herdr preserves the initial label and balanced edges across overlapping so ui: { confirm: async () => new Promise((resolve) => { confirmations.push(resolve); }), }, - } as ExtensionContext; + } as unknown as ExtensionContext; return { confirmations, context, herdrEvents, pi, toolCall: handlers.get("tool_call")! }; }; @@ -1804,7 +1804,7 @@ test("closed choice blockers retain the visible choice label through guarded-con ui: { confirm: async () => new Promise((resolve) => { confirmations.push(resolve); }), }, - } as ExtensionContext, + } as unknown as ExtensionContext, ); await Promise.resolve(); assert.equal(confirmations.length, 1); @@ -1854,12 +1854,12 @@ test("permission lifecycle is inactive for unguarded and headless commands", asy cwd, hasUI: false, ui: { confirm }, - } as ExtensionContext), undefined); + } as unknown as ExtensionContext), undefined); assert.deepEqual(await toolCall!({ toolName: "bash", input: { command: "git rebase main" } }, { cwd, hasUI: false, ui: { confirm }, - } as ExtensionContext), { + } as unknown as ExtensionContext), { block: true, reason: "Gentle AI safety policy requires interactive confirmation before this command.", }); @@ -1914,7 +1914,7 @@ test("bash tool_call confirms a late guarded npm publish and denies on non-appro return false; }, }, - } as ExtensionContext; + } as unknown as ExtensionContext; const prefix = "noise ".repeat(80); const command = `${prefix}npm publish --tag beta`; @@ -1966,7 +1966,7 @@ test("bash tool_call confirms every compound action and centers a long git -C pu cwd: process.cwd(), hasUI: true, ui: { confirm: async (title: string, message: string) => (confirmArgs.push([title, message]), false) }, - } as ExtensionContext); + } as unknown as ExtensionContext); assert.deepEqual(result, { block: true, reason: "Gentle AI safety policy blocked the command because it was not confirmed.", diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 06edeca6c..bf1551354 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -10,7 +10,7 @@ import { CURSOR_MARKER, matchesKey, visibleWidth, type TUI, type TuiMouseEvent } import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, GentlePromptEditor } from "../extensions/gentle-shell.ts"; import { CODEX_USAGE_URL, NAN_QUOTA_URL, USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts"; import { bindSessionProfile, clearSessionProfileBinding, resetSessionProfileBindingsForTesting } from "../lib/session-profile-binding.ts"; -import { createVimEditorAdapter } from "../lib/vim-editor-adapter.ts"; +import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts"; import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts"; import { CHANGE_STATUS } from "../lib/shell-changes.ts"; import { sidebarPart, sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts"; @@ -167,7 +167,7 @@ async function fire(handlers: Map Promise } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise } { +function fakeContext(options: { theme?: typeof plainTheme & { getBgAnsi?(): string }; hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise } { const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined }; let resolveOverlay: () => void; const overlayReady = new Promise((resolve) => { resolveOverlay = resolve; }); @@ -188,7 +188,7 @@ function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: bo modelRegistry: { isUsingOAuth: () => options.oauth ?? true, getApiKeyForProvider: async () => options.token }, getContextUsage: () => ({ tokens: 122_400, contextWindow: 272_000, percent: 45 }), ui: { - theme: plainTheme, + theme: options.theme ?? plainTheme, getAllThemes: () => [{ name: "dark", path: undefined }, { name: "light", path: undefined }], getTheme: (name: string) => name === "dark" || name === "light" ? { name } : undefined, setTheme: (name: string) => ({ success: name === "dark" || name === "light" }), @@ -2012,8 +2012,7 @@ test("GentlePromptEditor autocomplete respects narrow frame widths", () => { test("registered prompt stays transparent while idle, working, and queued", () => { const { pi, handlers, tools } = fakePi(); gentleShell(pi, {}); - const { ctx, ui } = fakeContext(); - ctx.ui.theme = { ...plainTheme, getBgAnsi: () => "\x1b[44m" } as typeof ctx.ui.theme; + const { ctx, ui } = fakeContext({ theme: { ...plainTheme, getBgAnsi: () => "\x1b[44m" } }); const editor = installedPrompt(ctx, ui, handlers); try { for (const state of ["idle", "working", "queued"]) { @@ -2074,8 +2073,8 @@ test("visual customization and Vim register once and remain independently discov assert.equal(JSON.parse(readFileSync(join(home, "vim.json"), "utf8")).policy, "on"); }); -test("actual Pi 1.0.0 enables a live prompt and enters NORMAL without a compatibility fallback", async () => { - assert.equal(INSTALLED_PI, "1.0.0", "the shell audit must run against actual installed Pi 1.0.0"); +test(`actual Pi ${INSTALLED_PI} enables a live prompt and enters NORMAL without a compatibility fallback`, async () => { + assert.ok(isAuditedPiEditorVersion(INSTALLED_PI), `actual installed Pi ${INSTALLED_PI} must be audited`); const configHome = mkdtempSync(join(tmpdir(), "gentle-vim-shell-")); const { pi, handlers, commands } = fakePi(); gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome }); @@ -4859,7 +4858,7 @@ test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked fi test("shell Git runner hides initial and repeated background polling children", async () => { const calls: Array<{ command: string; args: readonly string[]; options: Record }> = []; - const run = ((command: string, args: readonly string[], options: Record, callback: (error: Error | null, stdout: string) => void) => { + const run = ((command: string, args: readonly string[], options: Record, callback: (error: Error | null, stdout: string, stderr: string) => void) => { calls.push({ command, args, options }); callback(null, "", ""); }) as typeof import("node:child_process").execFile; diff --git a/tests/maintainer/provider-relay.maintest.ts b/tests/maintainer/provider-relay.maintest.ts index cda500516..8734493a4 100644 --- a/tests/maintainer/provider-relay.maintest.ts +++ b/tests/maintainer/provider-relay.maintest.ts @@ -66,8 +66,11 @@ function descriptor(overrides = {}) { ...overrides, }; } +function isRoleError(error: unknown): error is Error & { kind: string; stage: string; mutationOutcome: string } { + return error instanceof Error && error instanceof ProviderRoleVectorError && "kind" in error && typeof error.kind === "string" && "stage" in error && typeof error.stage === "string" && "mutationOutcome" in error && typeof error.mutationOutcome === "string"; +} function rejects(obj, fragment) { - assert.throws(() => validateDescriptor(obj), (error) => error instanceof DescriptorValidationError && error.message.includes(fragment)); + assert.throws(() => validateDescriptor(obj), (error) => error instanceof Error && error instanceof DescriptorValidationError && error.message.includes(fragment)); } function tempDescriptor(t, obj) { const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-")); @@ -102,11 +105,9 @@ test("rejects malformed descriptor fields with exact-shape errors (no defaults, rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, captureArgumentTokens: [...CAPTURE_TOKENS].filter((t) => t !== "--materialize=true") }] }, "--materialize=true"); }); test("submission is validated through the real relay resolver before any process launches (exact shape)", () => { - const twoValues = structuredClone(SUBMISSION); - twoValues.values = [...SUBMISSION.values, { slot: "extra", domain: "artifact_path_or_stdin", substitutionLocation: 0 }]; + const twoValues = { ...structuredClone(SUBMISSION), values: [...SUBMISSION.values, { slot: "extra", domain: "artifact_path_or_stdin", substitutionLocation: 0 }] }; rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: twoValues }] }, "not a bindable provider form"); - const noSlot = structuredClone(SUBMISSION); - noSlot.argumentTokens = [...BINDING_TOKENS, "--input=/no/value/slot"]; + const noSlot = { ...structuredClone(SUBMISSION), argumentTokens: [...BINDING_TOKENS, "--input=/no/value/slot"] }; rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: noSlot }] }, "not a bindable provider form"); rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: { ...structuredClone(SUBMISSION), extra: 1 } }] }, "extra"); }); @@ -245,7 +246,7 @@ test("negative control: a role vector failure (not surface-unavailable) is repor }); test("runProviderRoleVector treats a prelaunch abort as not-started with no mutation", async () => { const controller = new AbortController(); controller.abort(); - await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: process.execPath, signal: controller.signal }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_ABORTED && error.stage === "launch" && error.mutationOutcome === "none"); + await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: process.execPath, signal: controller.signal }), (error) => isRoleError(error) && error.kind === ROLE_VECTOR_FAILURE.ROLE_ABORTED && error.stage === "launch" && error.mutationOutcome === "none"); }); function roleChild(t: test.TestContext, name: string, source: string) { const path = sandboxPath(`${name} preload.cjs`), savedNodeOptions = process.env.NODE_OPTIONS; writeFileSync(path, `const roleArgv = ["review", ...process.argv.slice(2)], Module = require("node:module"), resolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, ...args) { return /[\\\\/]review$/.test(request) ? ${JSON.stringify(path)} : resolveFilename.call(this, request, ...args); }; ${source}`); @@ -262,19 +263,19 @@ test("runProviderRoleVector executes both real stub-child verbs and decodes snak test("runProviderRoleVector rejects malformed artifact binding shape before stale binding comparison", async (t) => { for (const malformed of [{ lineage_id: "", target_identity: ROLE_TARGET }, { lineage_id: ROLE_LINEAGE, target_identity: "not-a-sha256" }]) { const child = roleChild(t, `malformed-${malformed.lineage_id || "lineage"}.cjs`, `process.stdout.write(JSON.stringify({ schema: ${JSON.stringify(PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA)}, role: "refuter", captured: true, ...${JSON.stringify(malformed)} })); process.exit(0);`); - await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_FAILED && /typed shape/.test(error.message)); + await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (error) => isRoleError(error) && error.kind === ROLE_VECTOR_FAILURE.ROLE_FAILED && /typed shape/.test(error.message)); } }); test("runProviderRoleVector classifies an empty successful artifact as unknown and requires STATUS re-query", async (t) => { const child = roleChild(t, "empty-artifact.cjs", "process.exit(0);"); await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (caught) => { - assert.ok(caught instanceof ProviderRoleVectorError); assert.equal(caught.kind, ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT); assert.equal(caught.stage, "execute"); assert.equal(caught.mutationOutcome, "unknown"); assert.match(caught.message, /re-query negotiated STATUS before any retry/); return true; + assert.ok(isRoleError(caught)); assert.equal(caught.kind, ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT); assert.equal(caught.stage, "execute"); assert.equal(caught.mutationOutcome, "unknown"); assert.match(caught.message, /re-query negotiated STATUS before any retry/); return true; }); }); test("runProviderRoleVector bounds each output stream before JSON or exit handling", async (t) => { for (const stream of ["stdout", "stderr"] as const) { const child = roleChild(t, `overflow-${stream}.cjs`, `process.${stream}.write("x".repeat(${ROLE_STREAM_MAX_BYTES + 1})); setInterval(() => {}, 1_000);`); - await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, timeoutMs: 1_000 }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_OUTPUT_OVERFLOW && error.mutationOutcome === "unknown"); + await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, timeoutMs: 1_000 }), (error) => isRoleError(error) && error.kind === ROLE_VECTOR_FAILURE.ROLE_OUTPUT_OVERFLOW && error.mutationOutcome === "unknown"); } }); async function descendantPid(path: string) { @@ -293,7 +294,7 @@ test("role watchdog and abort reap descendants on every platform", async (t) => t.after(() => { if (pid > 0) try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } }); assert.ok(DEFAULT_ROLE_VECTOR_TIMEOUT_MS > 600_000, "the outer 900s watchdog FIRES after the provider-owned 600s deadline with setup/cancellation margin"); if (mode === "abort") controller.abort(); - await assert.rejects(run, (error) => error instanceof ProviderRoleVectorError && error.kind === (mode === "abort" ? ROLE_VECTOR_FAILURE.ROLE_ABORTED : ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT) && error.mutationOutcome === "unknown"); + await assert.rejects(run, (error) => isRoleError(error) && error.kind === (mode === "abort" ? ROLE_VECTOR_FAILURE.ROLE_ABORTED : ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT) && error.mutationOutcome === "unknown"); await assertReaped(pid); }); }); @@ -302,7 +303,7 @@ test("failed tree termination settles despite a surviving descendant retaining i const child = roleChild(t, "pipe-holder-role-child.cjs", `const fs = require("node:fs"), { spawn } = require("node:child_process"), grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit", env: { ...process.env, NODE_OPTIONS: "" } }); fs.writeFileSync(${JSON.stringify(sandboxPath("ROLE_PID_PLACEHOLDER"))}.replace("ROLE_PID_PLACEHOLDER", ${JSON.stringify("role-pipe-holder.pid")}), String(grandchild.pid)); setInterval(() => {}, 1000);`); const run = runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, signal: controller.signal, terminateProcessTree: (process) => { process.kill("SIGKILL"); return false; } }); const pid = await descendantPid(grandchild); t.after(() => { try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } }); - const started = Date.now(); controller.abort(); await assert.rejects(run, (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_TERMINATION_FAILED && error.stage === "execute" && error.mutationOutcome === "unknown"); + const started = Date.now(); controller.abort(); await assert.rejects(run, (error) => isRoleError(error) && error.kind === ROLE_VECTOR_FAILURE.ROLE_TERMINATION_FAILED && error.stage === "execute" && error.mutationOutcome === "unknown"); assert.ok(Date.now() - started < 500, "failed tree termination must not wait for descendant-held pipes to close"); assert.doesNotThrow(() => process.kill(pid, 0), "the fixture descendant must survive while retaining inherited pipes"); }); test("stale-target fail-closed: missing or duplicate required binding tokens are rejected before runner invocation", () => { @@ -495,7 +496,7 @@ const baselineArmed = typeof baselineBinary === "string" && baselineBinary.lengt const baselineReason = () => `${BASELINE_ENV} is unset or not an existing absolute path; supply the baseline gentle-ai binary (external evidence establishes whether it is RC8), or set ${REQUIRE_ENV}=1 to fail instead of skip.`; if (!baselineArmed && armed) throw new Error(baselineReason()); if (!baselineArmed) console.log(`tests/maintainer/provider-relay.maintest.ts: ${baselineReason()}`); -function baselineDescriptor(kind = "relay-unavailable" as const) { +function baselineDescriptor(kind: "relay-unavailable" | "positive-lens" = "relay-unavailable") { return validateDescriptor({ ...descriptor(), gentleAiExecutable: baselineBinary, cases: [{ name: "baseline-negative-control", kind, captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }] }); } test("negative control: runMatrix proves the baseline fails closed as relay-unavailable with zero mutation", { skip: !baselineArmed }, async () => { diff --git a/tests/native-binary-gate.test.ts b/tests/native-binary-gate.test.ts index cd4b00f41..efb9dc12c 100644 --- a/tests/native-binary-gate.test.ts +++ b/tests/native-binary-gate.test.ts @@ -53,6 +53,7 @@ test("without the env var unpinned digests yield a skip with a distinct reason", const unpinned = requireNativeBinary({ ...PINNED, digestsPinned: false, env: {} }); assert.equal(unpinned.run, false); + assert.ok("reason" in unresolved); assert.match(unpinned.reason as string, /digest/i); assert.notEqual(unpinned.reason, unresolved.reason, "the two causes must be distinguishable in output"); }); @@ -110,6 +111,7 @@ test("without the env var a set but non-existent dev binary path yields a distin const missingVar = requireDevBinary({ devBinaryPath: undefined, exists: false, env: {} }); const badPath = requireDevBinary({ devBinaryPath: "/no/such/gentle-ai", exists: false, env: {} }); assert.equal(badPath.run, false); + assert.ok("reason" in missingVar); assert.match(badPath.reason as string, /\/no\/such\/gentle-ai/); assert.notEqual(badPath.reason, missingVar.reason, "the two causes must be distinguishable in output"); }); diff --git a/tests/native-review-consent.test.ts b/tests/native-review-consent.test.ts index c0797a9f6..b5b0b6d94 100644 --- a/tests/native-review-consent.test.ts +++ b/tests/native-review-consent.test.ts @@ -87,11 +87,11 @@ function queuedAdapter(outputs: readonly Record[]): { adapter: } function client(adapter: ExecFileAdapter): NativeReviewCliV216 { - return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest); + return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined); } function runtimeClient(adapter: ExecFileAdapter): RuntimeNativeReviewCliV216 { - return new RuntimeNativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest); + return new RuntimeNativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined); } test("negotiated ordinary START executes the complete STATUS-rendered relay vector and preserves the consent envelope", async () => { diff --git a/tests/native-review-parity-runtime.test.ts b/tests/native-review-parity-runtime.test.ts index e57a7314f..e3740cec4 100644 --- a/tests/native-review-parity-runtime.test.ts +++ b/tests/native-review-parity-runtime.test.ts @@ -44,7 +44,7 @@ const resolvedBinary = (() => { } })(); const nativeBinaryGate = requireNativeBinary({ resolvedBinary, digestsPinned: true, env: process.env }); -if (!nativeBinaryGate.run) console.log(`native-review-parity-runtime: ${nativeBinaryGate.reason}`); +if (nativeBinaryGate.run === false) console.log(`native-review-parity-runtime: ${nativeBinaryGate.reason}`); const test = nativeBinaryGate.run ? baseTest : baseTest.skip; const binary = resolvedBinary ?? ""; diff --git a/tests/notification-audio-native.test.ts b/tests/notification-audio-native.test.ts index d5e0e32e7..b70128c44 100644 --- a/tests/notification-audio-native.test.ts +++ b/tests/notification-audio-native.test.ts @@ -244,7 +244,8 @@ test("child environment is an allowlist: Pulse vars kept, credentials and debug test("real worker probe against a fake Unix socket authenticates without CREATE", async () => { const server = await startFakeServer(); try { - const player = new NativePulsePlayer({ env: { ...process.env, PULSE_SERVER: `unix:${server.path}` }, probeTimeoutMs: 8000 }); + // Exercise the Linux Pulse transport against a fake socket on any Unix host. + const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, probeTimeoutMs: 8000 }); const result = await player.probe(); assert.deepEqual(result, { available: true, formats: ["wav"] }); assert.deepEqual(server.seen, [8, 9, 20]); @@ -259,7 +260,7 @@ test("real worker plays a fake PCM snapshot to a fake socket with no OS audio", writeFileSync(snapshot, bytes, { mode: 0o400 }); const server = await startFakeServer(); try { - const player = new NativePulsePlayer({ env: { ...process.env, PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 }); + const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 }); await player.play(snapshot); assert.deepEqual(Buffer.concat(server.pcm), dataOf(bytes)); assert.ok(server.seen.includes(3) && server.seen.includes(12) && server.seen.includes(4)); @@ -273,7 +274,7 @@ test("real worker rejects invalid WAV before opening the socket", async () => { writeFileSync(snapshot, Buffer.alloc(64, 0x00), { mode: 0o400 }); const server = await startFakeServer(); try { - const player = new NativePulsePlayer({ env: { ...process.env, PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 }); + const player = new NativePulsePlayer({ platform: "linux", env: { PULSE_SERVER: `unix:${server.path}` }, playTimeoutMs: 8000 }); await assert.rejects(player.play(snapshot), /Native pulse/); assert.deepEqual(server.seen, []); } finally { await server.close(); rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/orchestrator-consultation-sdk.test.ts b/tests/orchestrator-consultation-sdk.test.ts index c195323f5..9889967e3 100644 --- a/tests/orchestrator-consultation-sdk.test.ts +++ b/tests/orchestrator-consultation-sdk.test.ts @@ -71,10 +71,15 @@ test("public SDK publishes, consults, pages, withdraws and replaces isolated own const roots = Array.from({ length: 10 }, (_, i) => join(root, `wt${i}`)); for (const cwd of roots) git(clone, "worktree", "add", "--detach", cwd, "HEAD"); const gitProbes = new Map(); + const gitProbeOrigins: Array<{ cwd: string; operation: string; origin: string[] }> = []; const runGit = new Proxy(execFileSync, { apply(target, _this, [command, args, options]) { assert.equal(command, "git"); assert.ok(String(args[args.indexOf("-C") + 1]).startsWith(root + "/")); const cwd = String(args[args.indexOf("-C") + 1]); + gitProbeOrigins.push({ cwd, operation: String(args.at(-1)), origin: (new Error().stack ?? "").split("\n").flatMap(line => { + const name = line.match(/\bat (?:async )?([A-Za-z_][\w.$]*)\s*\(/)?.[1]; + return name ? [name] : []; + }).slice(0, 8) }); gitProbes.set(cwd, (gitProbes.get(cwd) ?? 0) + 1); return Reflect.apply(target, undefined, [command, args, { ...options, env: gitEnv }]); } }); @@ -228,12 +233,18 @@ test("public SDK publishes, consults, pages, withdraws and replaces isolated own const before = calls; let failed = false; let toolProbes = 0; + let toolProbeStart = 0; const unsubscribe = session.subscribe(event => { - if (event.type === "tool_execution_start" && event.toolName === name) toolProbes = gitProbes.get(cwd) ?? 0; + if (event.type === "tool_execution_start" && event.toolName === name) { + toolProbes = gitProbes.get(cwd) ?? 0; + toolProbeStart = gitProbeOrigins.length; + } if (event.type === "tool_execution_end" && event.toolName === name) { result = event.result; failed = event.isError; if (name === "orchestrator_consult" || name === "orchestrator_list") { - assert.equal(gitProbes.get(cwd) ?? 0, toolProbes, "metadata query adds no caller Git probes"); + const added = gitProbeOrigins.slice(toolProbeStart).filter(probe => probe.cwd === cwd).slice(0, 2) + .map(({ operation, origin }) => ({ operation, origin })); + assert.equal(gitProbes.get(cwd) ?? 0, toolProbes, `metadata query adds no caller Git probes; origins=${JSON.stringify(added)}`); } } }); diff --git a/tests/quiet-tool-rendering.test.ts b/tests/quiet-tool-rendering.test.ts index 080ad0407..430302e9c 100644 --- a/tests/quiet-tool-rendering.test.ts +++ b/tests/quiet-tool-rendering.test.ts @@ -256,7 +256,7 @@ test("quiet tool rendering can be disabled by env", () => { test("pi-pretty suppresses overlapping tools before quiet tools register", async () => { await withEnvAsync( - { GENTLE_PI_QUIET_TOOLS: undefined, PRETTY_DISABLE_TOOLS: "multi_grep" }, + { GENTLE_PI_AGENTS_CHILD: undefined, GENTLE_PI_QUIET_TOOLS: undefined, PRETTY_DISABLE_TOOLS: "multi_grep" }, async () => { const { pi, tools } = createPi({ throwOnToolConflict: true }); @@ -274,7 +274,7 @@ test("pi-pretty suppresses overlapping tools before quiet tools register", async test("pi-pretty preserves byte-exact model-visible read results when quiet tools suppress its renderer", async () => { await withEnvAsync( - { GENTLE_PI_QUIET_TOOLS: undefined, PRETTY_DISABLE_TOOLS: undefined }, + { GENTLE_PI_AGENTS_CHILD: undefined, GENTLE_PI_QUIET_TOOLS: undefined, PRETTY_DISABLE_TOOLS: undefined }, async () => { const { pi, hooks } = createPi(); await piPretty(pi as any, fakePiPrettyDeps as any); @@ -297,6 +297,7 @@ test("pi-pretty preserves byte-exact model-visible read results when quiet tools test("pi-pretty suppression is skipped when quiet tools are disabled", async () => { await withEnvAsync( { + GENTLE_PI_AGENTS_CHILD: undefined, GENTLE_PI_QUIET_TOOLS: "0", PRETTY_DISABLE_TOOLS: undefined, PRETTY_ENABLE_TOOLS: "ls", @@ -428,7 +429,7 @@ test("quiet Bash errors preserve meaningful rows for both public output ordering ["probe stderr detail \x1b[31mwith terminal color\x1b[0m", ["", ""]], ["probe exit status: 7", []], ]], - ] as const; + ] satisfies ReadonlyArray]>; assert.equal(cases.length, 2); const args = { command: "false" }; const renderResult = (result: any, expanded: boolean) => tool.renderResult(result, { expanded, isPartial: false, isError: true }, passthroughTheme, { args, isError: true }); diff --git a/tests/review-agent-end-preflight.test.ts b/tests/review-agent-end-preflight.test.ts index 3960af864..cc389c50b 100644 --- a/tests/review-agent-end-preflight.test.ts +++ b/tests/review-agent-end-preflight.test.ts @@ -260,6 +260,7 @@ for (const scenario of ["same", "changed", "sibling-root", "nested-root", "faile await directWrite(handlers, session); const result = await review.execute("post-ack", { operation: "acknowledge-approved", lineageId }, undefined, undefined, toolContext(session)); if (unsuccessful) { + assert.ok(typeof result.details === "object" && result.details !== null && "outcome" in result.details && "mutation_outcome" in result.details); assert.equal(result.details.outcome, scenario === "failed" ? "native-operation-failed" : "native-mutation-status-reconciled"); assert.equal(result.details.mutation_outcome, scenario === "failed" ? "none" : "unknown"); } else assert.deepEqual(result.details, { @@ -725,7 +726,7 @@ test("session_start negotiates the current target identity when RDD is on", asyn const notifications: Array<{ message: string; severity: string }> = []; const session = { ...ctx("session-baseline-record", true, cwd), - ui: { notify: (message: string, severity: string) => notifications.push({ message, severity }) }, + ui: { ...ctx("session-baseline-record", true, cwd).ui, notify: (message: string, severity: string) => { notifications.push({ message, severity }); } }, }; await sessionStart!({}, session); assert.equal(statusRequests[0]?.agent, "pi"); diff --git a/tests/review-candidate-view.test.ts b/tests/review-candidate-view.test.ts index dd2f4518c..3125dcbd6 100644 --- a/tests/review-candidate-view.test.ts +++ b/tests/review-candidate-view.test.ts @@ -366,7 +366,8 @@ test("candidate owner publication never removes a replaced marker", (t) => { test("candidate owner publication preserves a replaced marker with 64-bit identity precision loss", (t) => { mockWindowsAcl(t); - const cwd = repository(t), parent = join(cwd, ".git", "gentle-ai", "candidate-views"); + // The registry canonicalizes the contributor root (e.g. /var -> /private/var on macOS). + const cwd = repository(t), parent = join(realpathSync(cwd), ".git", "gentle-ai", "candidate-views"); const originalLstat = fs.lstatSync, fsync = fs.fsyncSync, write = fs.writeFileSync; // Modeled identities, not measured NTFS values: distinct 64-bit indices round to one Number. const high = 2n ** 53n; @@ -386,7 +387,8 @@ test("candidate owner publication preserves a replaced marker with 64-bit identi }); }); t.mock.method(fs, "fsyncSync", (fd: number) => { - if (replaced) return fsync(fd); + // The reaper lock is synced before marker publication; it is not this fault's boundary. + if (marker === undefined || replaced) return fsync(fd); assert.ok(marker, "owned marker identity must be captured before the fault"); faults++; savedContent = readFileSync(marker, "utf8"); @@ -1281,22 +1283,21 @@ test("candidate view skips a shared index that disappears during stat or copy", for (const phase of ["stat", "copy"] as const) { const originalLstatSync = fs.lstatSync; const originalCopyFileSync = fs.copyFileSync; - fs.lstatSync = ((path: string | Buffer, options?: Parameters[1]) => { + t.mock.method(fs, "lstatSync", (path: string | Buffer, options?: Parameters[1]) => { if (phase === "stat" && path === sharedIndexPath) throw Object.assign(new Error("shared index disappeared"), { code: "ENOENT" }); return originalLstatSync(path, options); - }) as typeof fs.lstatSync; - fs.copyFileSync = ((source: string | Buffer, destination: string | Buffer) => { + }); + t.mock.method(fs, "copyFileSync", (source: string | Buffer, destination: string | Buffer) => { if (phase === "copy" && source === sharedIndexPath) throw Object.assign(new Error("shared index disappeared"), { code: "ENOENT" }); return originalCopyFileSync(source, destination); - }) as typeof fs.copyFileSync; + }); syncBuiltinESMExports(); let view: ReturnType | undefined; try { view = createCandidateView({ contributorRoot, intendedUntracked: [] }); assert.equal(view.paths.includes("staged-addition.txt"), true, phase); } finally { - fs.lstatSync = originalLstatSync; - fs.copyFileSync = originalCopyFileSync; + t.mock.restoreAll(); syncBuiltinESMExports(); view?.cleanup(); } @@ -1523,13 +1524,13 @@ test("corrected views stay within frozen scope and replace projections only when const registry = new CandidateViewRegistry(); const initial = registry.create({ contributorRoot }); registry.bind({ token: initial.token, lineageId: "correction", selectedLenses: ["review-risk"] }); writeFileSync(join(contributorRoot, "tracked.txt"), "corrected\n"); - const corrected = registry.createCorrected("correction", contributorRoot); + const corrected = registry.createCorrected("correction", contributorRoot, "corrected-1"); assert.notEqual(corrected.candidateTree, initial.candidateTree); assert.equal(registry.resolveProjection("correction", contributorRoot).candidateTree, initial.candidateTree); registry.promoteCorrected("correction", corrected.token); assert.equal(registry.resolveProjection("correction", contributorRoot).candidateTree, corrected.candidateTree); writeFileSync(join(contributorRoot, "escaped.txt"), "outside scope\n"); - assert.throws(() => registry.createCorrected("correction", contributorRoot), /escapes the frozen genesis paths/); + assert.throws(() => registry.createCorrected("correction", contributorRoot, "escaped-2"), /escapes the frozen genesis paths/); registry.cleanupTerminal("correction", "approved"); }); @@ -1752,7 +1753,7 @@ test("candidate view compacts an oversized non-ASCII scope losslessly and determ assert.throws(() => readCandidateContextManifestPage(compact.encoded, compact.sha256, actorEntries.length + 1), /cursor/); const nonCanonicalBytes = Buffer.from(JSON.stringify({ gitlinks: decoded.manifest.gitlinks, scopeByMode: decoded.manifest.scopeByMode, version: 1 }), "utf8"); assert.throws( - () => decodeCandidateContextManifest(gzipSync(nonCanonicalBytes, { mtime: 0 }).toString("base64url"), createHash("sha256").update(nonCanonicalBytes).digest("hex")), + () => decodeCandidateContextManifest(gzipSync(nonCanonicalBytes).toString("base64url"), createHash("sha256").update(nonCanonicalBytes).digest("hex")), /canonical/, ); } finally { @@ -1770,7 +1771,7 @@ test("candidate context manifest decoder accepts canonical numeric-looking gitli scopeByMode: { "160000": ["10", "2"] }, gitlinks, }), "utf8"); - const encoded = gzipSync(bytes, { mtime: 0 }).toString("base64url"); + const encoded = gzipSync(bytes).toString("base64url"); assert.deepEqual(decodeCandidateContextManifest(encoded, createHash("sha256").update(bytes).digest("hex")).manifest, { version: 1, scopeByMode: { "160000": ["10", "2"] }, @@ -1789,7 +1790,7 @@ test("candidate context manifest decoder rejects noncanonical nonnumeric gitlink gitlinks, }), "utf8"); assert.throws( - () => decodeCandidateContextManifest(gzipSync(bytes, { mtime: 0 }).toString("base64url"), createHash("sha256").update(bytes).digest("hex")), + () => decodeCandidateContextManifest(gzipSync(bytes).toString("base64url"), createHash("sha256").update(bytes).digest("hex")), /canonical/, ); }); @@ -1797,7 +1798,7 @@ test("candidate context manifest decoder rejects noncanonical nonnumeric gitlink test("candidate context manifest decoder rejects noncanonical gzip transport for verified bytes", () => { const bytes = Buffer.from(JSON.stringify({ version: 1, scopeByMode: { "100644": ["file.ts"] }, gitlinks: {} }), "utf8"); const sha256 = createHash("sha256").update(bytes).digest("hex"); - const canonical = gzipSync(bytes, { mtime: 0 }); + const canonical = gzipSync(bytes); const noncanonical = Buffer.from(canonical); noncanonical[4] = (noncanonical[4]! + 1) & 0xff; assert.throws(() => decodeCandidateContextManifest(noncanonical.toString("base64url"), sha256), /canonical/); diff --git a/tests/review-controller-native-recovery.test.ts b/tests/review-controller-native-recovery.test.ts index 5cb61b202..cdce641d0 100644 --- a/tests/review-controller-native-recovery.test.ts +++ b/tests/review-controller-native-recovery.test.ts @@ -574,7 +574,7 @@ test("RECOVER, RESET, and RECONCILE keep provider inputs and failures authority- for (const error of [ new NativeReviewCliError("cancelled", "review/reconcile-authority", true, true, "cancelled"), - new NativeReviewCliError("non_zero", "review/reconcile-authority", true, true, "partial", undefined, { schema: "gentle-ai.review-reconcile-audit/v1", status: "partial" }), + new NativeReviewCliError("non-zero", "review/reconcile-authority", true, true, "partial", undefined, { schema: "gentle-ai.review-reconcile-audit/v1", status: "partial" }), ]) { const failed = await __testing.executeReviewControllerOperation({ operation: "reconcile-authority", input: JSON.stringify(reconciliation) }, process.cwd(), { reconcileAuthority: async () => { throw error; }, diff --git a/tests/review-controller-native-routing.test.ts b/tests/review-controller-native-routing.test.ts index 370bb1085..9046c6768 100644 --- a/tests/review-controller-native-routing.test.ts +++ b/tests/review-controller-native-routing.test.ts @@ -1922,6 +1922,7 @@ test("plain START adopts the pre-lineage selection retained by inspect and delet retained, ); assert.equal(started.operation, "start"); + assert.ok(typeof started.result === "object" && started.result !== null && "lineage_id" in started.result); assert.equal(started.result.lineage_id, "review-started"); assert.equal(starts.length, 1); assert.equal(requests.length, 3); @@ -2377,6 +2378,7 @@ test("a failed START retains a pre-lineage selection for a same-candidate retry" { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, cwd, native, undefined, null, undefined, retained, ); + assert.ok(typeof retried.result === "object" && retried.result !== null && "lineage_id" in retried.result); assert.equal(retried.result.lineage_id, "retried"); assert.equal(starts, 2); assert.equal(retained.has(`${cwd}\u0000`), false); diff --git a/tests/review-controller-workspace-root.test.ts b/tests/review-controller-workspace-root.test.ts index 2d550759f..7ad03189e 100644 --- a/tests/review-controller-workspace-root.test.ts +++ b/tests/review-controller-workspace-root.test.ts @@ -38,7 +38,7 @@ function runtime( const tools = new Map(); let toolCall: ToolCallHandler | undefined; const handlers = new Map unknown>(); - const dependencies = { nativeReviewCli, candidateViews } as unknown as Parameters[0]; + const dependencies = { nativeReviewCli, candidateViews, processEnv: { GENTLE_PI_AGENTS_CHILD: "0" } } as unknown as Parameters[0]; createGentleAiExtension(dependencies)({ on(name: string, handler: ToolCallHandler) { if (name === "tool_call") toolCall = handler; @@ -739,7 +739,7 @@ test("same-session START binding migrates to one validation capture without a FI captureProviderRole: async (request) => { captureCalls += 1; assert.equal(request.captureOperation, "review.capture-validation"); - return { schema: "gentle-ai.review-last-event-closure/v1", operation: "review.capture-validation", lineageId, state: "approved", storeRevision: `sha256:${"a".repeat(64)}` }; + return { schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-validation", lineageId, state: "approved", storeRevision: `sha256:${"a".repeat(64)}` }; }, }); const { controller } = runtime(native, candidateViews); diff --git a/tests/review-correction-lifecycle.test.ts b/tests/review-correction-lifecycle.test.ts index a8894e24e..a604f18f0 100644 --- a/tests/review-correction-lifecycle.test.ts +++ b/tests/review-correction-lifecycle.test.ts @@ -61,7 +61,7 @@ test("passed is the only outcome that unlocks targeted validation", () => { assert.equal(step.kind, "run-targeted-validation"); assert.equal(step.unlocksTargetedValidation, true); assert.equal(step.transactionOpen, false); - assert.equal(step.escalation, undefined); + assert.equal(Object.hasOwn(step, "escalation"), false); // The provider issues the request; Pi must re-query STATUS for it rather // than inventing one, which is why the step names the operation instead of // carrying a fabricated payload. @@ -95,6 +95,7 @@ test("verification_failed carries the prior identity as supersedes and demands a test("verification_failed never consumes budget even when lines were already charged", () => { const charged = { ...STATUS, changedLinesCharged: 40 }; const step = resolveCorrectionStep(charged, evidence("verification_failed")); + assert.equal(step.kind, "recapture-required"); // The invariant is about what THIS outcome adds, not about resetting prior // accounting: a failed verification must not move the needle at all. diff --git a/tests/review-gate.test.ts b/tests/review-gate.test.ts index 2b1aa931d..070c0c6e9 100644 --- a/tests/review-gate.test.ts +++ b/tests/review-gate.test.ts @@ -14,6 +14,7 @@ import { recheckReleaseFastPathRemoteHeadV1, resolveConfiguredPushDestinationV1, type GateTargetV1, + type ReleaseGateTargetV1, type GhCommandRunnerV1, type ReleaseFastPathEvidenceV1, } from "../lib/review-publication-gate.ts"; @@ -177,14 +178,14 @@ function temporaryAuthority(t: test.TestContext): GateRepository & { test("unbranded receipts are rejected before lifecycle gate evaluation", (t) => { const { repository, finalTree, store, receipt } = temporaryAuthority(t); execFileSync("git", ["read-tree", finalTree], { cwd: repository }); - assert.throws(() => validateReviewGate({ + assert.throws(() => Reflect.apply(validateReviewGate, undefined, [{ store, receipt, target: { kind: GATE_TARGET_KIND.INTENDED_COMMIT, intended_commit_tree: finalTree }, repositoryCwd: repository, idempotencyKey: "unbranded-gate", scopeBudget: budget(), - }), /branded authoritative receipt/i); + }]), /branded authoritative receipt/i); }); test("authoritative receipts cannot be validated through another repository store", (t) => { @@ -333,6 +334,7 @@ test("push gate allows normal same-name updates while preserving exact-old and c const driftedUpdate = { kind: GATE_TARGET_KIND.PUSH, remote, + destination_id: target.destination_id, updates: [{ ...target.updates[1], old_object: finalCommit, @@ -347,6 +349,7 @@ test("push gate allows normal same-name updates while preserving exact-old and c const createOverExisting = { kind: GATE_TARGET_KIND.PUSH, remote, + destination_id: target.destination_id, updates: [{ ...target.updates[0], destination_ref: "refs/heads/final" }], } as const; assert.equal( @@ -580,7 +583,7 @@ test("scope child claim and parent gate journal publish atomically across faults assert.equal(store.read(receipt.body.lineage_id).child_claims?.length, 1); }); -function releaseTarget(repository: GateRepository): GateTargetV1 { +function releaseTarget(repository: GateRepository): ReleaseGateTargetV1 { return { kind: GATE_TARGET_KIND.RELEASE, tag_ref: "refs/tags/v1.2.3", @@ -628,7 +631,7 @@ test("release fast path independently accepts complete successful Check Runs des const repository = createGateRepository(t); setRemoteMain(repository, repository.finalCommit); const calls: string[][] = []; - const checkRuns = (checks: unknown[], totalCount = checks.length, legacyStatus = "pending"): GhCommandRunnerV1 => (args) => { + const checkRuns = (checks: readonly unknown[], totalCount = checks.length, legacyStatus = "pending"): GhCommandRunnerV1 => (args) => { calls.push([...args]); if (args[1] === `repos/{owner}/{repo}/commits/${repository.finalCommit}/check-runs?per_page=100`) { return { status: 0, stdout: JSON.stringify({ total_count: totalCount, returned: checks.length, checks }) }; diff --git a/tests/review-graph-schema.test.ts b/tests/review-graph-schema.test.ts index bab8c0a91..49f30d0b2 100644 --- a/tests/review-graph-schema.test.ts +++ b/tests/review-graph-schema.test.ts @@ -63,8 +63,8 @@ test("event schema commits a canonical reducer transition and input", () => { payload: { initial: true }, reduced_state_hash: stateHash, } as never); - assert.equal((event.body as Record).reducer_transition, "start"); - assert.deepEqual((event.body as Record).reducer_input, { source: "controller" }); + assert.equal(event.body.reducer_transition, "start"); + assert.deepEqual(event.body.reducer_input, { source: "controller" }); assert.throws(() => createReviewEventV1({ lineage_id: "lineage-a", sequence: 0, diff --git a/tests/review-host-relay-routing.test.ts b/tests/review-host-relay-routing.test.ts index 134302241..8b3700931 100644 --- a/tests/review-host-relay-routing.test.ts +++ b/tests/review-host-relay-routing.test.ts @@ -211,6 +211,8 @@ function nativeHarness(statuses: readonly ReviewStatusV3[], unachievableResponde native: undefined as unknown as NativeReviewCli, }; harness.native = { + start: async () => { throw new Error("unexpected START"); }, + reviewStatus: async () => { throw new Error("unexpected compact STATUS"); }, targetStatus: async (request) => { harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...(request.agent === undefined ? {} : { agent: request.agent }) }); const next = harness.statusQueue.shift(); @@ -436,7 +438,7 @@ test("Pi-authored review documents are rejected at the capture input boundary", assert.equal(relayCalls, 0); }); -function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return ["review-risk", "review-resilience", "review-readability", "review-reliability"].map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); } +function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return (["review-risk", "review-resilience", "review-readability", "review-reliability"] as const).map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); } async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true, modelRegistry?: InProcessReviewerRegistry): Promise> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input, index) => index % 2 === 0 ? input : JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native, undefined, undefined, undefined, false, modelRegistry) as Record; } diff --git a/tests/review-integration-v2-forward.test.ts b/tests/review-integration-v2-forward.test.ts index e6d99c277..4610511ad 100644 --- a/tests/review-integration-v2-forward.test.ts +++ b/tests/review-integration-v2-forward.test.ts @@ -947,7 +947,7 @@ test("review-acknowledged/v1 is disjoint from every prior captured identity in b ["status/v3", decodeReviewStatusV3], ["start/v3", decodeReviewStartV3], ["start/v4", decodeReviewStartV4], - ["capabilities/v2", decodeReviewCapabilitiesV2], + ["capabilities/v2", (value) => decodeReviewCapabilitiesV2(value, CAPTURED_DIGEST)], ["consent/v2", decodeReviewConsentV2], ["consent/v3", decodeReviewConsentV3], ["last-event-closure/v1", decodeReviewLastEventClosureV1], diff --git a/tests/review-last-event-closure.test.ts b/tests/review-last-event-closure.test.ts index a37dd261c..d45f2e84b 100644 --- a/tests/review-last-event-closure.test.ts +++ b/tests/review-last-event-closure.test.ts @@ -34,13 +34,20 @@ function closure(operation: string, lineageId: string): Record }; } +function fixtureLens(suffix: string) { + const lenses = ["review-risk", "review-reliability", "review-resilience", "review-readability"] as const; + const lens = lenses[Number(suffix)]; + assert.ok(lens, "fixture order must select an authoritative lens"); + return lens; +} + function bindingArguments(lineageId: string, suffix = "0", subjectHash = SHA): ReviewCollectInputV3["arguments"] { return [ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` }, { name: "expected-revision", value: SHA, token: `--expected-revision=${SHA}` }, { name: "target", value: SHA, token: `--target=${SHA}` }, { name: "repository-context", value: `rctx1_${"c".repeat(64)}`, token: `--repository-context=rctx1_${"c".repeat(64)}` }, - { name: "lens", value: `review-risk-${suffix}`, token: `--lens=review-risk-${suffix}` }, + { name: "lens", value: fixtureLens(suffix), token: `--lens=${fixtureLens(suffix)}` }, { name: "order", value: suffix, token: `--order=${suffix}` }, { name: "subject-hash", value: subjectHash, token: `--subject-hash=${subjectHash}` }, ]; @@ -66,7 +73,7 @@ function materializeInput(lineageId: string, suffix = "0", subjectHash = SHA): R baseTree: TREE, candidateTree: TREE, changedPathManifestSha256: SHA, - lens: `review-risk-${suffix}`, + lens: fixtureLens(suffix), selectedOrder: Number(suffix), }, submission: { diff --git a/tests/review-policy-judgment-day.test.ts b/tests/review-policy-judgment-day.test.ts index 7de2b8086..0ce4fccff 100644 --- a/tests/review-policy-judgment-day.test.ts +++ b/tests/review-policy-judgment-day.test.ts @@ -27,7 +27,7 @@ const TREE = { FIX_TWO: "4".repeat(40), } as const; -function judgmentDayState(mode = REVIEW_MODE.JUDGMENT_DAY): ReviewStateV1 { +function judgmentDayState(mode: ReviewStateV1["mode"] = REVIEW_MODE.JUDGMENT_DAY): ReviewStateV1 { return createReviewState({ lineageId: "judgment-day-lineage", mode, diff --git a/tests/review-policy-ordinary.test.ts b/tests/review-policy-ordinary.test.ts index 42c90df3d..bc673bc54 100644 --- a/tests/review-policy-ordinary.test.ts +++ b/tests/review-policy-ordinary.test.ts @@ -121,7 +121,7 @@ test("ordinary discovery runs the selected zero, one, or four lenses exactly onc rows: selected.lenses.length === 0 ? [] - : rows().filter(({ lens }) => selected.lenses.includes(lens as ReviewLens)), + : rows().filter(({ lens }) => selected.lenses.some((selectedLens) => selectedLens === lens)), }, ); assert.equal(discovered.phase, REVIEW_PHASE.DISCOVERY_COMPLETE); @@ -167,7 +167,7 @@ test("no-finding ordinary path runs zero refuters, fixes, and validators then ve assert.equal(resolved.counters.refuter_batches, 0); assert.equal(resolved.counters.fix_batches, 0); assert.equal(resolved.counters.validator_runs, 0); - assert.throws(() => ordinaryValidatorRequest(resolved), /fix.*required/i); + assert.throws(() => ordinaryValidatorRequest(resolved, validationProof([])), /fix.*required/i); const terminal = recordOrdinaryFinalVerification(resolved, { passed: true }); assert.equal(terminal.terminal_state, TERMINAL_STATE.APPROVED); diff --git a/tests/review-risk-assessment.test.ts b/tests/review-risk-assessment.test.ts index 2653d0fde..97bd93015 100644 --- a/tests/review-risk-assessment.test.ts +++ b/tests/review-risk-assessment.test.ts @@ -29,6 +29,7 @@ import { type NativeReviewOutcome, } from "../lib/review-risk-assessment.ts"; import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts"; +import type { ReviewStatusV3 } from "../lib/review-integration-v2.ts"; // --------------------------------------------------------------------------- // gentle-pi#662: decoder for the native `gentle-ai review assess` envelope @@ -188,9 +189,9 @@ test("decodeReviewAssessmentV1 rejects malformed consumed, review_due, and revie // gentle-pi#1175 (T2): the native schema fixes review_due by reason // (true for high_risk and slice_budget_reached, false for passive, -// under_budget, and already_reviewed), and already_reviewed is reported -// exactly when the candidate is consumed. A contradictory envelope can never -// be trusted as closure evidence, so it fails decoding. +// under_budget, and already_reviewed). Consumed candidates must report +// already_reviewed, but an acknowledged predecessor plus a passive delta can +// also produce that reason without consuming this exact candidate (#1954). interface ReviewDuePair { reason: string; due: boolean; @@ -203,6 +204,7 @@ const CONSISTENT_REVIEW_DUE_PAIRS: readonly ReviewDuePair[] = [ { reason: "passive", due: false, consumed: false }, { reason: "under_budget", due: false, consumed: false }, { reason: "already_reviewed", due: false, consumed: true }, + { reason: "already_reviewed", due: false, consumed: false }, ]; function consistentEnvelope(pair: ReviewDuePair): Record { @@ -237,14 +239,38 @@ test("decodeReviewAssessmentV1 rejects a review_due boolean that contradicts rev } }); -test("decodeReviewAssessmentV1 rejects already_reviewed unless the candidate is consumed", () => { - for (const candidate of [{ kind: "current-changes", consumed: false }, { kind: "current-changes" }]) { - assert.throws( - () => decodeReviewAssessmentV1(newEnvelope({ candidate, review_due: false, review_due_reason: "already_reviewed", next_transition: undefined })), - TypeError, - `already_reviewed with ${JSON.stringify(candidate)} must be rejected`, - ); - } +function reviewedPredecessorEnvelope(): Record { + // The executable range still has medium risk; only the delta after the + // acknowledged committed predecessor is a passive ODD note. + return validEnvelope({ + risk: "medium", + reasons: [{ code: "executable_changes" }], + changed_paths: 3, + changed_lines: 24, + candidate: { kind: "current-changes", consumed: false }, + review_due: false, + review_due_reason: "already_reviewed", + }); +} + +test("decodeReviewAssessmentV1 accepts an already-reviewed predecessor without consuming the medium-risk candidate (#1954)", () => { + const decoded = decodeReviewAssessmentV1(reviewedPredecessorEnvelope()); + assert.equal(decoded.risk, "medium"); + assert.deepEqual(decoded.reasons, [{ code: "executable_changes" }]); + assert.equal(decoded.changedPaths, 3); + assert.equal(decoded.changedLines, 24); + assert.equal(decoded.reviewDue, false); + assert.equal(decoded.reviewDueReason, "already_reviewed"); + assert.equal(decoded.candidate.consumed, false); + assert.equal(Object.hasOwn(decoded, "nextTransition"), false); +}); + +test("decodeReviewAssessmentV1 rejects already_reviewed with missing consumed evidence", () => { + // Native v2 requires consumed; older envelopes omit the entire due pair. + assert.throws( + () => decodeReviewAssessmentV1(newEnvelope({ candidate: { kind: "current-changes" }, review_due: false, review_due_reason: "already_reviewed", next_transition: undefined })), + TypeError, + ); }); test("decodeReviewAssessmentV1 rejects a consumed candidate reported with any reason other than already_reviewed", () => { @@ -710,7 +736,25 @@ function assessOnNativeCli(currentTargetIdentity: () => string): Partial ({ operation: "status", scope: "clone", status: { global: "on", cloneLocal: "", effective: "on", source: NATIVE_REVIEW_MODE_SOURCE.GLOBAL } }), assess: async () => ({ schema: REVIEW_ASSESSMENT_SCHEMA, risk: "high", reasons: [], changedPaths: 1, changedLines: 5, candidate: { kind: "current-changes", baseRef: undefined } }), - targetStatus: (async () => ({ applicability: "current_target", targetIdentity: currentTargetIdentity() })) as NativeReviewCli["targetStatus"], + targetStatus: async (): Promise => ({ + contract: "gentle-ai.review-integration/v2", applicability: "current_target", + action: "stop", replayability: "manual_action_required", targetIdentity: currentTargetIdentity(), + projection: { + schema: "gentle-ai.review-integration.projection/v1", + kind: "current-changes", projection: "workspace", baseTree: "a".repeat(40), + initialReviewTree: "a".repeat(40), currentCandidateTree: "b".repeat(40), + pathsDigest: "c".repeat(64), paths: ["candidate.ts"], intendedUntracked: [], + intendedUntrackedProof: "d".repeat(64), initialSnapshotIdentity: "e".repeat(64), + currentSnapshotIdentity: currentTargetIdentity(), + }, + repair: { + schema: "gentle-ai.review-authority-repair-assessment/v1", status: "unsupported", + counts: { lineages: 0, compactLineages: 0, legacyLineages: 0, events: 0, bytes: 0, eligibleCandidates: 0, unsupportedLineages: 0, conflicts: 0 }, + supportedOperations: ["review/complete-fix", "review/validate-fix"], + authorizationSchema: "gentle-ai.review-repair-authorization/v1", + }, + candidates: [], raw: {}, + }), }; } @@ -804,6 +848,40 @@ async function assessWith(cli: Partial, input?: Record { + t.after(() => __testing.clearNativeReviewOutcomeMemoForTesting()); + __testing.clearNativeReviewOutcomeMemoForTesting(); + for (const input of [undefined, { nativeReviewOutcome: "closed" }]) { + const queue = queuedAdapter([{ stdout: JSON.stringify(reviewedPredecessorEnvelope()) }]); + const client = nativeClient(queue.adapter); + const cli = { + ...closureCli({ risk: "medium", consumed: false }), + assess: client.assess.bind(client), + }; + const details = await assessWith(cli, input) as AssessDetails & { + candidate: { consumed: boolean }; + reviewDue: boolean; + reviewDueReason: string; + }; + assert.equal(queue.calls.length, 1, "assessment must pass through the native CLI decoder"); + assert.equal(details.risk, "medium", "supported predecessor evidence must not become unassessable"); + assert.equal(details.candidate.consumed, false); + assert.equal(details.reviewDue, false); + assert.equal(details.reviewDueReason, "already_reviewed"); + assert.equal(Object.hasOwn(details, "nextTransition"), false); + assert.equal(details.nativeReviewOutcome, "unknown", "already_reviewed is not exact-candidate consumption"); + assert.equal(details.outcome_source, "unknown"); + assert.deepEqual(details.plan, verificationPlan({ + rddLine: RDD_LINE.ON, + risk: VERIFICATION_TIER.MEDIUM, + writerProfile: details.writerProfile as WriterProfile, + nativeReviewOutcome: NATIVE_REVIEW_OUTCOME.UNKNOWN, + })); + assert.equal(details.plan.writerSelfVerification, true); + assert.equal(details.plan.structuralReadbackOnly, false); + } +}); + test("gentle_review assess: native consumed true derives closed for this candidate", async () => { const details = await assessWith(closureCli({ consumed: true })); assert.equal(details.nativeReviewOutcome, "closed"); @@ -1152,7 +1230,7 @@ test("native assess: a non-zero exit (an older binary reporting an unknown comma const textOnStdout = queuedAdapter([{ stdout: "unknown command \"assess\" for \"gentle-ai review\"", exitCode: 1 }]); await assert.rejects( () => nativeClient(textOnStdout.adapter).assess!({ cwd: process.cwd() }), - (error: unknown) => error instanceof NativeReviewCliError && [NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON, NATIVE_REVIEW_ERROR_CODE.NON_ZERO].includes(error.code), + (error: unknown) => error instanceof NativeReviewCliError && (error.code === NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON || error.code === NATIVE_REVIEW_ERROR_CODE.NON_ZERO), ); }); diff --git a/tests/review-session-standing-permission-controller.test.ts b/tests/review-session-standing-permission-controller.test.ts index d4fe0162a..1c65a7ba8 100644 --- a/tests/review-session-standing-permission-controller.test.ts +++ b/tests/review-session-standing-permission-controller.test.ts @@ -42,7 +42,7 @@ function nonPiV3Consent() { } function consentCustom(select: (title: string, options: string[]) => Promise) { - return async (factory: (...args: never[]) => unknown) => { + return async (factory: (...args: unknown[]) => unknown) => { let result: unknown; const component = factory( { terminal: { rows: 24 }, requestRender() {} }, @@ -241,10 +241,10 @@ function piConsent() { const decoded = consent(); return { ...decoded, - choices: decoded.choices.map((choice) => ({ - ...choice, - invocation: choice.invocation.replace(" --consent ", " --agent pi --consent "), - })) as typeof decoded.choices, + choices: [ + { ...decoded.choices[0], invocation: decoded.choices[0].invocation.replace(" --consent ", " --agent pi --consent ") }, + { ...decoded.choices[1], invocation: decoded.choices[1].invocation.replace(" --consent ", " --agent pi --consent ") }, + ] as const, }; } @@ -623,7 +623,7 @@ test("a package-owned child replays its exact pending ordinary grant from a sibl const parentToChild = new PassThrough(); const broker = new ParentStandingReviewPermissionBroker( { readable: childToParent, writable: parentToChild }, - (repositoryIdentity) => repositoryIdentity === parentIdentity.repositoryIdentity && parentManager.getSessionId() === "parent-session" && hasReviewSessionPermission(parentIdentity), + (repositoryIdentity) => repositoryIdentity === parentIdentity.repositoryIdentity && parentIdentity.sessionManager.getSessionId() === "parent-session" && hasReviewSessionPermission(parentIdentity), ); const childPermission = new ChildStandingReviewPermissionClient( { readable: parentToChild, writable: childToParent }, diff --git a/tests/review-session-standing-permission-ipc.test.ts b/tests/review-session-standing-permission-ipc.test.ts index bab34366b..45db9e9f8 100644 --- a/tests/review-session-standing-permission-ipc.test.ts +++ b/tests/review-session-standing-permission-ipc.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { PassThrough, Writable } from "node:stream"; +import { Duplex, PassThrough, Writable } from "node:stream"; import test from "node:test"; import { AGENT_MODE, type AgentDefinition } from "../lib/agents-config.ts"; import { AgentRunner, type ChildLike, type TaskRequest } from "../lib/agents-runner.ts"; @@ -111,6 +111,7 @@ async function productionChild(requests: number, authorize: () => boolean, optio stdio: options.withoutChannel ? ["ignore", "pipe", "pipe"] : ["ignore", "pipe", "pipe", options.stdioMode ?? productionFd3StdioMode], }); const pipe = child.stdio[3]; + assert.ok(pipe === null || pipe === undefined || pipe instanceof Duplex, "fd3 must be a bidirectional channel"); const trace = options.diagnosticTrace; const onParentData = () => { if (trace !== undefined) trace.parentRawDataChunkCount += 1; }; if (trace !== undefined && pipe !== null && pipe !== undefined) pipe.on("data", onParentData); @@ -120,9 +121,9 @@ async function productionChild(requests: number, authorize: () => boolean, optio trace.parentAuthorizationCallbackCount += 1; return authorize(); }; - const broker = pipe === null || pipe === undefined - ? undefined - : new ParentStandingReviewPermissionBroker({ readable: pipe, writable: pipe }, parentAuthorize, options); + const broker = pipe instanceof Duplex + ? new ParentStandingReviewPermissionBroker({ readable: pipe, writable: pipe }, parentAuthorize, options) + : undefined; if (options.closeChannel && pipe !== null && pipe !== undefined) { broker?.close(); pipe.destroy(); @@ -323,7 +324,7 @@ test("fresh Jiti moduleCache:false reloads share fd3 structurally and reject sta stdio: ["ignore", "pipe", "pipe", productionFd3StdioMode], }); const pipe = child.stdio[3]; - assert.ok(pipe); + assert.ok(pipe instanceof Duplex, "fd3 must be a bidirectional channel"); const broker = new ParentStandingReviewPermissionBroker({ readable: pipe, writable: pipe }, () => true); let stdout = ""; let stderr = ""; diff --git a/tests/review-test-fixtures.ts b/tests/review-test-fixtures.ts index 703ac471c..71c30ba7d 100644 --- a/tests/review-test-fixtures.ts +++ b/tests/review-test-fixtures.ts @@ -7,6 +7,7 @@ import { type ReviewMode, } from "../lib/review-snapshot.ts"; import { existsSync, renameSync } from "node:fs"; +import { correctionBudget, REVIEW_RISK_TIER } from "../lib/review-risk.ts"; import type { ReviewLockPlatformAdapterV1 } from "../lib/review-lock.ts"; import { REVIEW_EVENT, @@ -45,6 +46,7 @@ export function testSnapshot(options: TestSnapshotOptions): SnapshotV1 { const lenses = options.lenses ?? []; const initialTree = options.initialTree ?? options.completeTree; const selected = lenses[0]; + const changedLines = route === REVIEW_ROUTE.FULL_4R ? 401 : route === REVIEW_ROUTE.TRIVIAL ? 1 : 10; return { schema: "gentle-ai.review-snapshot/v1", mode: options.mode ?? REVIEW_MODE.ORDINARY, @@ -57,9 +59,13 @@ export function testSnapshot(options: TestSnapshotOptions): SnapshotV1 { : { kind: REVIEW_PROJECTION.INTENDED_COMMIT, tree: initialTree }, initial_review_tree: initialTree, genesis_paths: options.genesisPaths ?? ["app.ts", "src/auth.ts", "src/retry.ts", "src/review.ts"], + intended_untracked: [], + risk_tier: route === REVIEW_ROUTE.FULL_4R ? REVIEW_RISK_TIER.HIGH : route === REVIEW_ROUTE.TRIVIAL ? REVIEW_RISK_TIER.LOW : REVIEW_RISK_TIER.MEDIUM, + original_changed_lines: changedLines, + correction_budget: correctionBudget(changedLines), diff_evidence: { event: REVIEW_EVENT.ORDINARY_START, - changedLines: route === REVIEW_ROUTE.FULL_4R ? 401 : route === REVIEW_ROUTE.TRIVIAL ? 1 : 10, + changedLines, triviality: route === REVIEW_ROUTE.TRIVIAL ? TRIVIALITY.PROVEN diff --git a/tests/review-transaction.test.ts b/tests/review-transaction.test.ts index 74f3269d0..17bb9f0bd 100644 --- a/tests/review-transaction.test.ts +++ b/tests/review-transaction.test.ts @@ -33,6 +33,7 @@ import { createFrozenLedger, createReceiptEnvelope, createReviewState, + validateReviewGraphReplayV1, evaluateGateTarget, type CanonicalFrozenRowV1, type ReceiptBodyV1, @@ -41,8 +42,12 @@ import { import { REVIEW_LENS, REVIEW_ROUTE } from "../lib/review-triggers.ts"; import { ordinaryValidatorRequest, + recordOrdinaryDiscovery, + resolveOrdinaryEvidence, + recordOrdinaryFinalVerification, recordOrdinaryValidation, } from "../lib/review-policy-ordinary.ts"; +import { createReviewEventV1 } from "../lib/review-graph-schema.ts"; import { qualifiedReviewLockPlatform, testSnapshot } from "./review-test-fixtures.ts"; const TREE = { @@ -393,7 +398,19 @@ test("ordinary follow-ups are ID-sorted action-free validation evidence and do n assert.equal(recorded.phase, REVIEW_PHASE.FINAL_VERIFICATION); assert.equal(recorded.counters.validator_runs, fixed.counters.validator_runs + 1); assert.equal(recorded.current_candidate_tree, fixed.current_candidate_tree); - assert.equal(recorded.follow_ups, undefined); + assert.equal(Object.hasOwn(recorded, "follow_ups"), false); +}); + +test("graph replay rejects a persisted non-boolean final verification instead of approving truthy input", () => { + const ready = resolveOrdinaryEvidence(recordOrdinaryDiscovery(state(), { rows: [] }), { deterministicResults: [] }); + const approved = recordOrdinaryFinalVerification(ready, { passed: true }); + const genesis = createReviewEventV1({ lineage_id: ready.lineage_id, sequence: 0, predecessor_event_id: null, kind: "lineage-created", reducer_transition: "start", reducer_input: ready, payload: { state: ready }, reduced_state_hash: canonicalHash(ready) }); + for (const passed of ["false", "true", 0, 1, null]) { + const next = createReviewEventV1({ lineage_id: ready.lineage_id, sequence: 1, predecessor_event_id: genesis.event_id, kind: "operation-completed", reducer_transition: REVIEW_TRANSITION.ORDINARY_FINAL_VERIFICATION, reducer_input: { passed }, payload: { state: approved }, reduced_state_hash: canonicalHash(approved) }); + assert.throws(() => validateReviewGraphReplayV1([genesis, next]), /reducer input.*passed/i); + } + const valid = createReviewEventV1({ lineage_id: ready.lineage_id, sequence: 1, predecessor_event_id: genesis.event_id, kind: "operation-completed", reducer_transition: REVIEW_TRANSITION.ORDINARY_FINAL_VERIFICATION, reducer_input: { passed: true }, payload: { state: approved }, reduced_state_hash: canonicalHash(approved) }); + assert.deepEqual(validateReviewGraphReplayV1([genesis, valid]), approved); }); test("new ordinary lineages fail closed when immutable genesis paths are absent", () => { diff --git a/tests/runtime-metrics-extension.test.ts b/tests/runtime-metrics-extension.test.ts index bd1fc05d6..b7b0a2fd3 100644 --- a/tests/runtime-metrics-extension.test.ts +++ b/tests/runtime-metrics-extension.test.ts @@ -13,7 +13,7 @@ function harness(env: NodeJS.ProcessEnv = {}, mode: "tui" | "print" = "tui", shu let session = "first"; let finish!: () => void; let signal!: AbortSignal; - const sent: RuntimeMetricBucket[][] = []; + const sent: (readonly RuntimeMetricBucket[])[] = []; const launches: unknown[] = []; const ctx: any = { cwd: "/fixture", mode, model, sessionManager: { getSessionId: () => session, getEntries: () => assert.fail("no reconstruction") } }; diff --git a/tests/shell-changes-view.test.ts b/tests/shell-changes-view.test.ts index 3bfd2c142..f38737161 100644 --- a/tests/shell-changes-view.test.ts +++ b/tests/shell-changes-view.test.ts @@ -147,9 +147,10 @@ test("worktree accordion selects a clicked file without opening its editor", () test("worktree accordion receives native fullscreen press and release as a file click", async () => { let onInput: ((data: string) => void) | undefined; - const terminal: Terminal = { - start(input) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 8; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, + const terminalFixture = { + start(input: Parameters[0]) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 8; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, setProgramStatus() {}, }; + const terminal: Terminal = terminalFixture; const opened: string[] = []; const component = new WorktreeChangesView([{ root: "/main", branch: "main", model: changesModel([file("a.ts", 1, 0), file("b.ts", 1, 0)]) }], { theme: plainTheme, rows: 8, loadDiff: async () => "+preview", @@ -297,9 +298,10 @@ test("non-left gestures pass through with current, stale, and missing layouts in test("right press reaches the native Windows paste fallback when eligible", () => { let onInput: ((data: string) => void) | undefined; let pasted = 0; - const terminal: Terminal = { - start(input) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 12; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, + const terminalFixture = { + start(input: Parameters[0]) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 12; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, setProgramStatus() {}, }; + const terminal: Terminal = terminalFixture; const { view: component } = view(); const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true, onRightClickPaste: () => { pasted++; } }); const platform = Object.getOwnPropertyDescriptor(process, "platform")!; @@ -616,9 +618,10 @@ test("ChangesView click selects a file without opening it", async () => { test("ChangesView receives native fullscreen press and release as a click", async () => { let onInput: ((data: string) => void) | undefined; - const terminal: Terminal = { - start(input) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 12; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, + const terminalFixture = { + start(input: Parameters[0]) { onInput = input; }, stop() {}, async drainInput() {}, write() {}, get columns() { return 80; }, get rows() { return 12; }, get kittyProtocolActive() { return false; }, moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {}, setTitle() {}, setProgress() {}, setProgramStatus() {}, }; + const terminal: Terminal = terminalFixture; const { view: component, events } = view(); const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true }); tui.setLayoutRoot(component); diff --git a/tests/shell-sidebar-layout.test.ts b/tests/shell-sidebar-layout.test.ts index c34637e61..af405abb4 100644 --- a/tests/shell-sidebar-layout.test.ts +++ b/tests/shell-sidebar-layout.test.ts @@ -49,7 +49,7 @@ test("grouped Status preserves structured fields and opaque integration text", ( const lines = renderShellSidebarBar({ cwd: "/project", branch: "main", dirty: 2, sessionName: "session", modelId: "model", effort: "high", contextPercent: 45, contextWindow: 1000, - costTotal: 1, subscription: false, statuses: ["opaque integration"], + costTotal: 1, subscription: false, usage: undefined, statuses: ["opaque integration"], }, painted, 46); const text = lines.join("\n"); let previous = -1; @@ -452,7 +452,7 @@ test("rail rejects removed or replaced parts before cached geometry is prepared return { handled: true }; }, }; - const mounted = sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {} }, original); + const mounted = sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {}, dispose() {} }, original); const dispose = installSidebar(f.tui, theme); t.after(dispose); const scroll = rail(f); diff --git a/tests/shell-usage-view.test.ts b/tests/shell-usage-view.test.ts index 37d3777a5..20197a321 100644 --- a/tests/shell-usage-view.test.ts +++ b/tests/shell-usage-view.test.ts @@ -22,7 +22,7 @@ function payload(percent: number) { test("UsageView frames the panel, keeps every line at width, and shows the empty state", () => { const store = new UsageStore(); const events: string[] = []; - const view = new UsageView(store, { theme: plainTheme, now: () => NOW, active: () => undefined, onRefresh: async () => events.push("refresh"), onClose: () => events.push("close"), requestRender: () => events.push("render") }); + const view = new UsageView(store, { theme: plainTheme, now: () => NOW, active: () => undefined, onRefresh: async () => { events.push("refresh"); }, onClose: () => events.push("close"), requestRender: () => events.push("render") }); const empty = view.render(90).map(stripAnsi); assert.match(empty[0], /^╭─ ✿ Subscriptions ─+╮$/); assert.match(empty[1], /No subscription usage yet/); diff --git a/tests/vim-editor-adapter.test.ts b/tests/vim-editor-adapter.test.ts index 26ee04224..35acb2f42 100644 --- a/tests/vim-editor-adapter.test.ts +++ b/tests/vim-editor-adapter.test.ts @@ -229,8 +229,8 @@ function assertInstalledPiPairBehavior(version: string, EditorClass: typeof Edit assert.equal(adapter.renderSelection(18, { line: 0, col: 0 }, { line: 0, col: 6 }, scrolled).length, scrolled.length); } -test("actual bundled Pi 1.0.0 proves editing, undo, paste, selection, wrap and autocomplete", () => { - assert.equal(bundledAgent.VERSION, "1.0.0"); +test(`actual bundled Pi ${INSTALLED_PI} proves editing, undo, paste, selection, wrap and autocomplete`, () => { + assert.equal(bundledAgent.VERSION, INSTALLED_PI); assertInstalledPiPairBehavior(bundledAgent.VERSION, BundledEditor, bundledAgent.CustomEditor, bundledAgent.VERSION); }); @@ -270,8 +270,8 @@ test("resolveVimRuntime resolves local bundle cli entrypoint when provided", () }); test("audited Pi editor releases are one frozen exact allowlist that includes the installed TUI", () => { - assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0"]); - assert.equal(INSTALLED_PI, "1.0.0", "this audit must exercise actual Pi 1.0.0, not a fabricated release"); + assert.deepEqual([...AUDITED_PI_EDITOR_VERSIONS], ["0.99.1", "0.99.2", "1.0.0", "1.1.0"]); + assert.equal(bundledAgent.VERSION, INSTALLED_PI, "bundled and local metadata must describe the same actual installed release"); assert.ok(Object.isFrozen(AUDITED_PI_EDITOR_VERSIONS)); assert.throws(() => (AUDITED_PI_EDITOR_VERSIONS as unknown as string[]).push("0.99.3"), TypeError); assert.ok(isAuditedPiEditorVersion(INSTALLED_PI), `installed pi-tui ${INSTALLED_PI} must be audited`); @@ -302,7 +302,7 @@ function fabricatedPiCli(t: test.TestContext, version: string, tuiVersion = vers } test("bundled host admits each audited release and rejects unknown or mismatched bundle metadata", (t) => { - for (const version of ["0.99.1", "0.99.2", "1.0.0"]) { + for (const version of AUDITED_PI_EDITOR_VERSIONS) { assert.deepEqual(resolveVimRuntime(fabricatedPiCli(t, version), bundledAgent.CustomEditor), { version, editorClass: BundledEditor }, version); } for (const version of ["0.99.3", "1.0.1", ">=1.0.0", "v1.0.0"]) { @@ -312,7 +312,7 @@ test("bundled host admits each audited release and rejects unknown or mismatched }); test("installed agent/TUI pair admits each audited release and skips unknown or mismatched pairs", (t) => { - for (const version of ["0.99.1", "0.99.2", "1.0.0"]) { + for (const version of AUDITED_PI_EDITOR_VERSIONS) { assert.deepEqual(resolveVimRuntime(fabricatedPiCli(t, version), runtimeAgent.CustomEditor), { version, editorClass: runtimeTui.Editor }, version); } // Rejected candidates must resolve to the real local pair, not their claim. @@ -325,7 +325,7 @@ test("installed agent/TUI pair admits each audited release and skips unknown or test("adapter admits each verified audited release and rejects unknown or mismatched claims without mutation", () => { const host = () => new BundledEditor!({ terminal: { rows: 6 }, requestRender() {} } as never, { borderColor: (s: string) => s } as never); - for (const version of ["0.99.1", "0.99.2", "1.0.0"]) { + for (const version of AUDITED_PI_EDITOR_VERSIONS) { const e = host(); e.setText("alpha beta"); const adapter = createVimEditorAdapter(e, version, BundledEditor, version); diff --git a/tests/writer-edit-surface-scope.test.ts b/tests/writer-edit-surface-scope.test.ts index 4c2966273..74925b621 100644 --- a/tests/writer-edit-surface-scope.test.ts +++ b/tests/writer-edit-surface-scope.test.ts @@ -58,7 +58,7 @@ function dispatchWriter(input: Record) { cwd, hasUI: false, ui: { confirm: async () => true }, - } as ExtensionContext); + } as unknown as ExtensionContext); } async function assertAccepted(input: Record, message: string) {