diff --git a/dependency-cruiser.config.mjs b/dependency-cruiser.config.mjs index 0b89e153..19a815bb 100644 --- a/dependency-cruiser.config.mjs +++ b/dependency-cruiser.config.mjs @@ -45,6 +45,10 @@ const config = { tsConfig: { fileName: "tsconfig.build.json", }, + enhancedResolveOptions: { + exportsFields: ["exports"], + conditionNames: ["import", "node", "default"], + }, }, }; diff --git a/nix/package.nix b/nix/package.nix index fa67b5f8..f67b77d8 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -26,7 +26,7 @@ buildNpmPackageNode24 rec { || baseName == "result"); }; - npmDepsHash = "sha256-22/Z3RkueF3fcG4gk8qJB7a3xr+IJvJNNFbehUrrIL4="; + npmDepsHash = "sha256-azGNdflUJ6C5gu5QQT4rQB685S9jk2ImyORrVXOncwU="; npmDepsFetcherVersion = 2; dontNpmBuild = true; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 7d664812..3e0e85f9 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -12,7 +12,8 @@ "@earendil-works/pi-coding-agent": "0.84.2", "@earendil-works/pi-tui": "0.84.2", "pi-subagents": "0.61.0", - "superpowers": "https://github.com/obra/superpowers/archive/refs/tags/v6.3.0.tar.gz" + "superpowers": "https://github.com/obra/superpowers/archive/refs/tags/v6.3.0.tar.gz", + "typebox": "1.3.7" }, "bin": { "patchmill": "dist/bin/patchmill.js" @@ -1117,7 +1118,6 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "version": "0.84.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.2.tgz", - "integrity": "sha512-8Pn3wSCxj0cfo5I6jxQYVB/3uuQRmHhAlEclyjqpOuMEdQMIODHizRogv56FLdbU+dTiGnybeHQ2N+sV1/L2YA==", "license": "MIT", "dependencies": { "@earendil-works/pi-ai": "^0.84.2", @@ -1129,12 +1129,12 @@ }, "engines": { "node": ">=22.19.0" - } + }, + "integrity": "sha512-8Pn3wSCxj0cfo5I6jxQYVB/3uuQRmHhAlEclyjqpOuMEdQMIODHizRogv56FLdbU+dTiGnybeHQ2N+sV1/L2YA==" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "version": "0.84.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.2.tgz", - "integrity": "sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -1154,45 +1154,45 @@ }, "engines": { "node": ">=22.19.0" - } + }, + "integrity": "sha512-6MzsrYIYNVlE7SfpbL2yYb67Qo58p/7Q+xWG1RZvoX1P80aRCHSod2/13aFpxkow1lPO2LEh3c495J0Gwmyjig==" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": { "version": "0.84.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.2.tgz", - "integrity": "sha512-/RFSPhD/bZbpOp1oJj+UneSUFSgZhWxzcSENUY+8+8xhoBrWXMYI2t77XNx4Yf+c8YK2qTHquForhNcelYpXvg==", "license": "MIT", "dependencies": { "@earendil-works/pi-protocol": "^0.84.2" }, "engines": { "node": ">=22.19.0" - } + }, + "integrity": "sha512-/RFSPhD/bZbpOp1oJj+UneSUFSgZhWxzcSENUY+8+8xhoBrWXMYI2t77XNx4Yf+c8YK2qTHquForhNcelYpXvg==" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": { "version": "0.84.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.2.tgz", - "integrity": "sha512-jbBh03fkeckWEroHpcZBr4w5/Ibat8WwdXFlXHivYQImrQNFtLpDeL0t1cku4hmK0q3pceIRQHkw4fwbM4YILQ==", "license": "MIT", "dependencies": { "typebox": "1.3.7" }, "engines": { "node": ">=22.19.0" - } + }, + "integrity": "sha512-jbBh03fkeckWEroHpcZBr4w5/Ibat8WwdXFlXHivYQImrQNFtLpDeL0t1cku4hmK0q3pceIRQHkw4fwbM4YILQ==" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { "version": "0.84.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.2.tgz", - "integrity": "sha512-wg5caea7uIv1BHRBm2Y116RvFG4oSAiP5qk9tA2463PDGIr4K8M1Ceyyg5DOpF/shUUl0gk826yQJAeAcHYB9g==", "license": "MIT", "engines": { "node": ">=22.19.0" - } + }, + "integrity": "sha512-wg5caea7uIv1BHRBm2Y116RvFG4oSAiP5qk9tA2463PDGIr4K8M1Ceyyg5DOpF/shUUl0gk826yQJAeAcHYB9g==" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "version": "0.84.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.2.tgz", - "integrity": "sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg==", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -1200,7 +1200,8 @@ }, "engines": { "node": ">=22.19.0" - } + }, + "integrity": "sha512-ds2TLihOnM5sLJB3VpXV6y0uR5efVuHf4MN7yDpsty6hA2DUO/EDVzjp/0od0G2JslzVLMjT8T8zavtxVb+qbg==" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { "version": "1.52.0", @@ -6760,6 +6761,12 @@ } } }, + "node_modules/pi-subagents/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, "node_modules/pi-subagents/node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", @@ -7546,9 +7553,9 @@ } }, "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", "license": "MIT" }, "node_modules/typed-inject": { diff --git a/package.json b/package.json index fe9b2c1b..926a0d19 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@earendil-works/pi-coding-agent": "0.84.2", "@earendil-works/pi-tui": "0.84.2", "pi-subagents": "0.61.0", - "superpowers": "https://github.com/obra/superpowers/archive/refs/tags/v6.3.0.tar.gz" + "superpowers": "https://github.com/obra/superpowers/archive/refs/tags/v6.3.0.tar.gz", + "typebox": "1.3.7" } } diff --git a/src/cli/commands/doctor/checks.ts b/src/cli/commands/doctor/checks.ts index 55e15e32..ffb747b5 100644 --- a/src/cli/commands/doctor/checks.ts +++ b/src/cli/commands/doctor/checks.ts @@ -215,7 +215,8 @@ function parseSkillFrontmatter( text: string, ): { name: string; description: string } | { error: string } { const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u); - if (!match) { + const frontmatter = match?.[1]; + if (frontmatter === undefined) { return { error: "missing frontmatter" }; } @@ -224,7 +225,7 @@ function parseSkillFrontmatter( let currentKey: string | undefined; let currentValueAllowsContinuation = false; - for (const line of match[1].split(/\r?\n/u)) { + for (const line of frontmatter.split(/\r?\n/u)) { const trimmed = line.trim(); if (/^[^\s].*:/u.test(line)) { diff --git a/src/cli/commands/doctor/pi-resources.ts b/src/cli/commands/doctor/pi-resources.ts index 732fbe0f..0629b97f 100644 --- a/src/cli/commands/doctor/pi-resources.ts +++ b/src/cli/commands/doctor/pi-resources.ts @@ -15,7 +15,6 @@ import { loadProjectContextFiles, loadSkills, SettingsManager, - type MissingSourceAction, type ResolvedResource, } from "@earendil-works/pi-coding-agent"; import { loadPatchmillConfigState } from "../../../config/load.ts"; @@ -26,6 +25,8 @@ import { import { localPiAgentDir } from "../init/pi-agent-settings.ts"; import type { DoctorCheckResult } from "./checks.ts"; +type MissingSourceAction = "install" | "skip" | "error"; + export type DoctorPiResourceSection = { heading: "Context" | "Skills" | "Prompts" | "Extensions"; items: string[]; @@ -397,7 +398,8 @@ export async function loadDoctorPiResources( if (block.sections.length > 0) blocks.push(block); } - return { blocks, check: piResourceWarningCheck(warnings) }; + const check = piResourceWarningCheck(warnings); + return check === undefined ? { blocks } : { blocks, check }; } catch (error) { return { blocks: [], check: piResourceDiscoveryFailureCheck(error) }; } diff --git a/src/cli/commands/init/args.ts b/src/cli/commands/init/args.ts index b6aaed48..0c7717fd 100644 --- a/src/cli/commands/init/args.ts +++ b/src/cli/commands/init/args.ts @@ -55,6 +55,7 @@ export function parseArgs(args: string[], repoRoot = cwd()): InitConfig { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; + if (arg === undefined) throw new Error("Unexpected missing argument"); if (arg === "--help" || arg === "-h") { config.showHelp = true; } else if (arg === "--yes") { diff --git a/src/cli/commands/init/config-writer.ts b/src/cli/commands/init/config-writer.ts index 643a98c5..68f9ef5d 100644 --- a/src/cli/commands/init/config-writer.ts +++ b/src/cli/commands/init/config-writer.ts @@ -13,7 +13,7 @@ export type InitialConfigSkills = Pick< type InitialConfig = { host: Pick; - skills?: InitialConfigSkills; + skills?: InitialConfigSkills | undefined; }; export type InitWriteResult = @@ -40,9 +40,9 @@ export function inferHostProviderFromRemote( export function buildInitialConfig( options: { - provider?: PatchmillConfig["host"]["provider"]; - login?: string; - skills?: InitialConfigSkills; + provider?: PatchmillConfig["host"]["provider"] | undefined; + login?: string | undefined; + skills?: InitialConfigSkills | undefined; } = {}, ): InitialConfig { const provider = options.provider ?? DEFAULT_PATCHMILL_CONFIG.host.provider; @@ -99,8 +99,8 @@ async function originRemoteUrl(repoRoot: string): Promise { export async function writeInitialConfig( repoRoot: string, options: { - login?: string; - skills?: InitialConfigSkills; + login?: string | undefined; + skills?: InitialConfigSkills | undefined; }, ): Promise { const path = join(repoRoot, CONFIG_FILE_NAME); diff --git a/src/cli/commands/init/pi-auth-dialog.ts b/src/cli/commands/init/pi-auth-dialog.ts index 3d94b8d3..578e774e 100644 --- a/src/cli/commands/init/pi-auth-dialog.ts +++ b/src/cli/commands/init/pi-auth-dialog.ts @@ -123,9 +123,9 @@ class OptionComponent extends Container { async function promptText(options: { title: string; prompt: string; - allowEmpty?: boolean; - terminal?: Terminal; - signal?: AbortSignal; + allowEmpty?: boolean | undefined; + terminal?: Terminal | undefined; + signal?: AbortSignal | undefined; }): Promise { const terminal = options.terminal ?? new ProcessTerminal(); const tui = new TuiMainScreen(terminal, true); @@ -157,7 +157,7 @@ async function promptText(options: { export function promptApiKeyInteractively(options: { providerName: string; - terminal?: Terminal; + terminal?: Terminal | undefined; }): Promise { return promptText({ title: options.providerName, @@ -169,7 +169,7 @@ export function promptApiKeyInteractively(options: { async function selectOption(options: { title: string; choices: Array<{ id: string; label: string }>; - terminal?: Terminal; + terminal?: Terminal | undefined; }): Promise { const terminal = options.terminal ?? new ProcessTerminal(); const tui = new TuiMainScreen(terminal, false); @@ -213,8 +213,8 @@ function defaultOpenUrl(url: string): void { export function createOAuthCallbacks( options: { - terminal?: Terminal; - openUrl?: OpenUrl; + terminal?: Terminal | undefined; + openUrl?: OpenUrl | undefined; } = {}, ): OAuthLoginCallbacksLike { const terminal = options.terminal ?? new ProcessTerminal(); diff --git a/src/cli/commands/init/pi-auth-flow.ts b/src/cli/commands/init/pi-auth-flow.ts index 2fae9571..9fff2035 100644 --- a/src/cli/commands/init/pi-auth-flow.ts +++ b/src/cli/commands/init/pi-auth-flow.ts @@ -32,17 +32,17 @@ import { } from "./pi-runtime.ts"; export type OAuthLoginCallbacksLike = { - onAuth: (info: { url: string; instructions?: string }) => void; + onAuth: (info: { url: string; instructions?: string | undefined }) => void; onDeviceCode: (info: { userCode: string; verificationUri: string; - intervalSeconds?: number; - expiresInSeconds?: number; + intervalSeconds?: number | undefined; + expiresInSeconds?: number | undefined; }) => void; onPrompt: (prompt: { message: string; - placeholder?: string; - allowEmpty?: boolean; + placeholder?: string | undefined; + allowEmpty?: boolean | undefined; }) => Promise; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; @@ -50,7 +50,7 @@ export type OAuthLoginCallbacksLike = { message: string; options: Array<{ id: string; label: string }>; }) => Promise; - signal?: AbortSignal; + signal?: AbortSignal | undefined; dispose?: () => void; }; @@ -75,8 +75,8 @@ type SelectAuthPromptOption = (prompt: { type PromptAuthText = (prompt: { message: string; - placeholder?: string; - allowEmpty?: boolean; + placeholder?: string | undefined; + allowEmpty?: boolean | undefined; }) => Promise; export type InteractivePiAuthSetupOptions = { @@ -89,8 +89,8 @@ export type InteractivePiAuthSetupOptions = { selectProvider: SelectAuthProvider; promptApiKey: PromptApiKey; selectModelInteractively: SelectInteractiveModel; - persistDefaultModel?: PersistDefaultModel; - oauthCallbacks?: OAuthCallbacksFactory; + persistDefaultModel?: PersistDefaultModel | undefined; + oauthCallbacks?: OAuthCallbacksFactory | undefined; }; type InteractivePiAuthSetupResult = { @@ -128,7 +128,7 @@ async function promptForAuthValue(options: { prompt: PiAuthPrompt; provider: AuthProviderChoice; promptApiKey: PromptApiKey; - promptText?: PromptAuthText; + promptText?: PromptAuthText | undefined; selectOption: SelectAuthPromptOption; }): Promise { if (options.prompt.type === "select") { @@ -174,7 +174,7 @@ async function promptForAuthValue(options: { function createApiKeyInteraction(options: { provider: AuthProviderChoice; promptApiKey: PromptApiKey; - promptText?: PromptAuthText; + promptText?: PromptAuthText | undefined; selectOption: SelectAuthPromptOption; }): PiAuthInteraction { return { @@ -194,10 +194,13 @@ function createPiAuthInteraction( callbacks: OAuthLoginCallbacksLike, ): PiAuthInteraction { return { - signal: callbacks.signal, + ...(callbacks.signal === undefined ? {} : { signal: callbacks.signal }), notify: (event: PiAuthEvent) => { if (event.type === "auth_url") { - callbacks.onAuth({ url: event.url, instructions: event.instructions }); + callbacks.onAuth({ + url: event.url, + instructions: event.instructions, + }); } else if (event.type === "device_code") { callbacks.onDeviceCode({ userCode: event.userCode, @@ -336,8 +339,8 @@ export async function setupPiInteractively(options: { agentDir: string; currentDefault: LocalPiDefaultModel | undefined; initialReadiness: PiReadiness; - selectModelInteractively?: SelectInteractiveModel; - persistDefaultModel?: PersistDefaultModel; + selectModelInteractively?: SelectInteractiveModel | undefined; + persistDefaultModel?: PersistDefaultModel | undefined; }): Promise { const { runtime } = await createRepoLocalPiAuth({ agentDir: options.agentDir, diff --git a/src/cli/commands/init/pi-init-setup.ts b/src/cli/commands/init/pi-init-setup.ts index 4362a05d..2287747b 100644 --- a/src/cli/commands/init/pi-init-setup.ts +++ b/src/cli/commands/init/pi-init-setup.ts @@ -15,8 +15,8 @@ export type InteractivePiSetup = (options: { agentDir: string; currentDefault: LocalPiDefaultModel | undefined; initialReadiness: PiReadiness; - selectModelInteractively?: SelectInteractiveModel; - persistDefaultModel?: PersistDefaultModel; + selectModelInteractively?: SelectInteractiveModel | undefined; + persistDefaultModel?: PersistDefaultModel | undefined; }) => Promise<{ readiness: PiReadiness; selection: PiModelSelection; @@ -71,12 +71,12 @@ export async function resolvePiInitSetup(options: { piAgentDir: string; readiness: PiReadiness; isInteractive: boolean; - currentDefault?: LocalPiDefaultModel; - selectModelInteractively?: SelectInteractiveModel; - persistDefaultModel?: PersistDefaultModel; - setupPiInteractively?: InteractivePiSetup; - runPiSmokeTest?: PiSmokeTestRunner; - forceInteractiveSetup?: boolean; + currentDefault?: LocalPiDefaultModel | undefined; + selectModelInteractively?: SelectInteractiveModel | undefined; + persistDefaultModel?: PersistDefaultModel | undefined; + setupPiInteractively?: InteractivePiSetup | undefined; + runPiSmokeTest?: PiSmokeTestRunner | undefined; + forceInteractiveSetup?: boolean | undefined; }): Promise { const interactiveSetup = options.setupPiInteractively ?? setupPiInteractively; let readiness = options.readiness; @@ -107,19 +107,20 @@ export async function resolvePiInitSetup(options: { } const abortStatus = abortingSelectionStatus(selection); - if (abortStatus) { + if (abortStatus && selection.status === "unavailable") { return { status: abortStatus, readiness, selection }; } + const model = + selection.status === "selected" + ? selection.model + : selectedModelFromReadiness(readiness); const smoke = await (options.runPiSmokeTest ?? runPiSmokeTest)( createCommandRunner(), { repoRoot: options.repoRoot, piAgentDir: options.piAgentDir, - model: - selection.status === "selected" - ? selection.model - : selectedModelFromReadiness(readiness), + ...(model === undefined ? {} : { model }), }, ); diff --git a/src/cli/commands/init/pi-model-selection.ts b/src/cli/commands/init/pi-model-selection.ts index 0e97651c..6ea46091 100644 --- a/src/cli/commands/init/pi-model-selection.ts +++ b/src/cli/commands/init/pi-model-selection.ts @@ -99,9 +99,9 @@ async function persistSelection( export async function selectPiModel(options: { readiness: PiReadiness; isInteractive: boolean; - currentDefault?: LocalPiDefaultModel; - selectModelInteractively?: SelectInteractiveModel; - persistDefaultModel?: PersistDefaultModel; + currentDefault?: LocalPiDefaultModel | undefined; + selectModelInteractively?: SelectInteractiveModel | undefined; + persistDefaultModel?: PersistDefaultModel | undefined; }): Promise { if (options.readiness.status !== "ready") { return { diff --git a/src/cli/commands/init/pi-model-selector-state.ts b/src/cli/commands/init/pi-model-selector-state.ts index 030feb37..de50a795 100644 --- a/src/cli/commands/init/pi-model-selector-state.ts +++ b/src/cli/commands/init/pi-model-selector-state.ts @@ -8,7 +8,7 @@ export type ModelSelectorState = { filtered: PiModelChoice[]; query: string; selectedIndex: number; - current?: LocalPiDefaultModel; + current?: LocalPiDefaultModel | undefined; }; export type VisibleModelRow = { @@ -44,7 +44,7 @@ function filterModels(models: PiModelChoice[], query: string): PiModelChoice[] { export function createModelSelectorState( models: PiModelChoice[], - options: { current?: LocalPiDefaultModel; query?: string } = {}, + options: { current?: LocalPiDefaultModel | undefined; query?: string } = {}, ): ModelSelectorState { const filtered = filterModels(models, options.query ?? ""); const currentIndex = filtered.findIndex((model) => diff --git a/src/cli/commands/init/pi-model-selector.ts b/src/cli/commands/init/pi-model-selector.ts index c6d136a4..b7196bf0 100644 --- a/src/cli/commands/init/pi-model-selector.ts +++ b/src/cli/commands/init/pi-model-selector.ts @@ -25,8 +25,8 @@ import type { PiModelChoice } from "./pi-preflight.ts"; export type InteractiveModelSelector = (options: { models: PiModelChoice[]; - current?: LocalPiDefaultModel; - terminal?: Terminal; + current?: LocalPiDefaultModel | undefined; + terminal?: Terminal | undefined; }) => Promise; function modelRow(row: VisibleModelRow): string { @@ -123,8 +123,8 @@ class ModelSelectorComponent extends Container implements Focusable { export async function selectModelInteractively(options: { models: PiModelChoice[]; - current?: LocalPiDefaultModel; - terminal?: Terminal; + current?: LocalPiDefaultModel | undefined; + terminal?: Terminal | undefined; }): Promise { const terminal = options.terminal ?? new ProcessTerminal(); const tui = new TuiMainScreen(terminal, true); diff --git a/src/cli/commands/init/skill-installer.ts b/src/cli/commands/init/skill-installer.ts index 191407de..2127434b 100644 --- a/src/cli/commands/init/skill-installer.ts +++ b/src/cli/commands/init/skill-installer.ts @@ -399,6 +399,9 @@ export async function validateExistingSkillDirectory( skillPath: skillConfig.implementation, }, ]) { + if (skillPath === undefined) { + throw new Error(`Missing configured path for required skill ${name}`); + } await assertRequiredSkillFiles( name, resolve(repoRoot, skillPath), diff --git a/src/cli/commands/run-once/approval-artifact-preflight.ts b/src/cli/commands/run-once/approval-artifact-preflight.ts index 5deab226..0c6a5ef2 100644 --- a/src/cli/commands/run-once/approval-artifact-preflight.ts +++ b/src/cli/commands/run-once/approval-artifact-preflight.ts @@ -33,11 +33,11 @@ export type ApprovedArtifactPreflightOptions = { "repoRoot" | "specsDir" | "plansDir" | "approvalPolicy" >; issue: IssueSummary; - existingState?: AgentIssueRunState; + existingState?: AgentIssueRunState | undefined; resolvedArtifacts: ResolvedIssueArtifactSources; now: Date; - artifactWorkspace?: ReadOnlyIssueWorkspace; - runner?: CommandRunner; + artifactWorkspace?: ReadOnlyIssueWorkspace | undefined; + runner?: CommandRunner | undefined; }; export type ApprovedArtifactPreflight = { @@ -96,9 +96,9 @@ async function approvedArtifactPolicy(input: { function assertExplicitMatchesSaved(input: { kind: "spec" | "plan"; - explicit?: ResolvedIssueArtifactSource; - savedPath?: string; - savedCommit?: string; + explicit?: ResolvedIssueArtifactSource | undefined; + savedPath?: string | undefined; + savedCommit?: string | undefined; }): void { if (!input.explicit || !input.savedPath) return; if (input.explicit.path !== input.savedPath) { @@ -203,8 +203,8 @@ async function assertApprovedSourcesMaterializable(input: { requirePlan: boolean; specLabel: string; planLabel: string; - artifactWorkspace?: ReadOnlyIssueWorkspace; - runner?: CommandRunner; + artifactWorkspace?: ReadOnlyIssueWorkspace | undefined; + runner?: CommandRunner | undefined; }): Promise { const approved = [ ...(input.requireSpec && input.sources.spec diff --git a/src/cli/commands/run-once/artifact-sources.ts b/src/cli/commands/run-once/artifact-sources.ts index f18f28d3..f58a71c1 100644 --- a/src/cli/commands/run-once/artifact-sources.ts +++ b/src/cli/commands/run-once/artifact-sources.ts @@ -19,7 +19,7 @@ export type ResolvedIssueArtifactSources = Partial< >; export class ArtifactSourcePreflightError extends Error { - readonly name = "ArtifactSourcePreflightError"; + override readonly name = "ArtifactSourcePreflightError"; readonly issueNumber: number; readonly artifactKind?: WorkflowArtifactKind; @@ -29,7 +29,9 @@ export class ArtifactSourcePreflightError extends Error { ) { super(message); this.issueNumber = options.issueNumber; - this.artifactKind = options.artifactKind; + if (options.artifactKind !== undefined) { + this.artifactKind = options.artifactKind; + } } } diff --git a/src/cli/commands/run-once/development-environment-stage.ts b/src/cli/commands/run-once/development-environment-stage.ts index ee68a3ee..428895f8 100644 --- a/src/cli/commands/run-once/development-environment-stage.ts +++ b/src/cli/commands/run-once/development-environment-stage.ts @@ -25,10 +25,10 @@ export type DevelopmentEnvironmentStageResult = | { kind: "not-ready"; result: AgentIssuePipelineResult }; type DevelopmentEnvironmentDetails = { - specPath?: string; - specCommit?: string; + specPath?: string | undefined; + specCommit?: string | undefined; planPath: string; - planCommit?: string; + planCommit?: string | undefined; branch: string; worktreePath: string; }; @@ -49,7 +49,7 @@ type DevelopmentEnvironmentStageOptions = DevelopmentEnvironmentDetails & { heartbeatMs?: number; piAgentDir: string; tokenUsageState: { total: number }; - progressReporter?: ProgressReporter; + progressReporter?: ProgressReporter | undefined; progress: ( level: AgentIssueProgressEvent["level"], stage: string, diff --git a/src/cli/commands/run-once/final-json.ts b/src/cli/commands/run-once/final-json.ts index bb8e124c..43e94085 100644 --- a/src/cli/commands/run-once/final-json.ts +++ b/src/cli/commands/run-once/final-json.ts @@ -1,7 +1,7 @@ function fencedJsonBody(stdout: string): string { const trimmed = stdout.trim(); const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```\s*$/u); - return fenced ? fenced[1] : trimmed; + return fenced?.[1] ?? trimmed; } export function finalJsonCandidates(stdout: string): Record[] { diff --git a/src/cli/commands/run-once/git.ts b/src/cli/commands/run-once/git.ts index 15227e0d..dff2392d 100644 --- a/src/cli/commands/run-once/git.ts +++ b/src/cli/commands/run-once/git.ts @@ -69,7 +69,7 @@ export function buildIssueWorktreePath( } export function cleanStatusIgnoredPaths(config: { - cleanStatusIgnorePrefixes?: string[]; + cleanStatusIgnorePrefixes?: string[] | undefined; runStateDir: string; todoRoot: string; additionalPaths?: string[]; diff --git a/src/cli/commands/run-once/issue-todos.ts b/src/cli/commands/run-once/issue-todos.ts index 7dd83386..f7024091 100644 --- a/src/cli/commands/run-once/issue-todos.ts +++ b/src/cli/commands/run-once/issue-todos.ts @@ -100,13 +100,14 @@ export async function readIssueTodoTasks( const headerTags = new Set(readTodoTags(header.tags)); if (!requiredIssueTags.every((tag) => headerTags.has(tag))) continue; } - const match = header.title?.match(pattern); - if (!match) continue; + const title = header.title; + const match = title?.match(pattern); + if (!match || title === undefined) continue; const taskNumber = readCapture(match, "taskNumber", 1); if (!taskNumber) continue; tasks.push({ number: Number(taskNumber), - title: header.title, + title, label: labelFromSlug(readCapture(match, "taskSlug", 2) ?? "task"), done: issueTodoStatusDone(taskContract, header.status), }); diff --git a/src/cli/commands/run-once/pi-errors.ts b/src/cli/commands/run-once/pi-errors.ts index ed318a33..1e35bb8b 100644 --- a/src/cli/commands/run-once/pi-errors.ts +++ b/src/cli/commands/run-once/pi-errors.ts @@ -12,7 +12,10 @@ export function aggregatePiErrors( causes: PiErrorCause[], ): Error | undefined { if (causes.length === 0) return undefined; - if (causes.length === 1) return errorFromUnknown(causes[0].error); + const firstCause = causes[0]; + if (causes.length === 1 && firstCause !== undefined) { + return errorFromUnknown(firstCause.error); + } return new AggregateError( causes.map(({ label, error }) => { const cause = errorFromUnknown(error); diff --git a/src/cli/commands/run-once/pi-session-stream.ts b/src/cli/commands/run-once/pi-session-stream.ts index 909b4b0c..bb0c93fe 100644 --- a/src/cli/commands/run-once/pi-session-stream.ts +++ b/src/cli/commands/run-once/pi-session-stream.ts @@ -341,8 +341,8 @@ function readExactRange( return new Promise((resolve, reject) => { const chunks: Uint8Array[] = []; const stream = createReadStream(path, { start, end: end - 1 }); - stream.on("data", (chunk: Buffer) => { - chunks.push(chunk); + stream.on("data", (chunk: string | Buffer) => { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); }); stream.on("error", reject); stream.on("end", () => resolve(Buffer.concat(chunks))); diff --git a/src/cli/commands/run-once/pi.ts b/src/cli/commands/run-once/pi.ts index aff8dad2..c23b96dd 100644 --- a/src/cli/commands/run-once/pi.ts +++ b/src/cli/commands/run-once/pi.ts @@ -294,32 +294,35 @@ export type PiRepairOptions = { }; export type RunPiPromptOptions = { - progress?: ProgressReporter; + progress?: ProgressReporter | undefined; stage: RunPiPromptStage; - parseResult?: (stdout: string) => Result; - skillPaths?: string[]; - extensionArgs?: string[]; - heartbeatMs?: number; - streamOutput?: (chunk: string) => void; - issueNumber?: number; - repoRoot?: string; - taskProgress?: () => - | PiTaskProgress - | undefined - | Promise; - onTaskProgress?: (progress: PiTaskProgress) => void | Promise; - tokenUsage?: () => string | undefined; - tokenUsageState?: { total: number }; - observeSession?: boolean; - sessionRoot?: string; - sessionDir?: string; - onObservation?: (observation: PiSessionObservation) => void | Promise; - verbosePiOutput?: boolean; - taskContract?: PatchmillPiTaskContract; - piAgentDir?: string; - piCommand?: PiCommandSpec; - cleanupPromptTempDir?: (dir: string) => Promise; - repair?: PiRepairOptions; + parseResult?: ((stdout: string) => Result) | undefined; + skillPaths?: string[] | undefined; + extensionArgs?: string[] | undefined; + heartbeatMs?: number | undefined; + streamOutput?: ((chunk: string) => void) | undefined; + issueNumber?: number | undefined; + repoRoot?: string | undefined; + taskProgress?: + | (() => PiTaskProgress | undefined | Promise) + | undefined; + onTaskProgress?: + | ((progress: PiTaskProgress) => void | Promise) + | undefined; + tokenUsage?: (() => string | undefined) | undefined; + tokenUsageState?: { total: number } | undefined; + observeSession?: boolean | undefined; + sessionRoot?: string | undefined; + sessionDir?: string | undefined; + onObservation?: + | ((observation: PiSessionObservation) => void | Promise) + | undefined; + verbosePiOutput?: boolean | undefined; + taskContract?: PatchmillPiTaskContract | undefined; + piAgentDir?: string | undefined; + piCommand?: PiCommandSpec | undefined; + cleanupPromptTempDir?: ((dir: string) => Promise) | undefined; + repair?: PiRepairOptions | undefined; }; function stageStatus(stage: RunPiPromptStage): string { @@ -334,8 +337,8 @@ function formatElapsed(seconds: number): string { return `${Math.max(1, Math.round(seconds / 60))}m`; } -function statusLine( - options: RunPiPromptOptions, +function statusLine( + options: RunPiPromptOptions, elapsedSeconds: number, tokenUsage: string | undefined, taskProgress: PiTaskProgress | undefined, @@ -351,8 +354,8 @@ function statusLine( return `[${issue}] ${stageStatus(options.stage)}${task} | ${tokenUsage ?? "tok: task=? total=?"} | elapsed ${formatElapsed(elapsedSeconds)}`; } -async function heartbeatStatusLine( - options: RunPiPromptOptions, +async function heartbeatStatusLine( + options: RunPiPromptOptions, elapsedSeconds: number, latestTokenUsage: string | undefined, ): Promise { @@ -374,9 +377,9 @@ async function heartbeatStatusLine( ); } -async function emitPiOutput( +async function emitPiOutput( result: CommandResult, - options?: RunPiPromptOptions, + options?: RunPiPromptOptions, ): Promise { if (!options?.progress) return; const time = new Date().toISOString(); @@ -447,7 +450,20 @@ export async function runPiPrompt( message: "started pi", }); const session = options - ? await createPiSessionAllocation({ ...options, promptTempDir: dir }) + ? await createPiSessionAllocation({ + stage: options.stage, + promptTempDir: dir, + ...(options.observeSession === undefined + ? {} + : { observeSession: options.observeSession }), + streamOutput: options.streamOutput !== undefined, + ...(options.sessionRoot === undefined + ? {} + : { sessionRoot: options.sessionRoot }), + ...(options.sessionDir === undefined + ? {} + : { sessionDir: options.sessionDir }), + }) : undefined; if (session?.sessionDir) { await options?.progress?.event({ @@ -491,11 +507,13 @@ export async function runPiPrompt( await options?.onObservation?.(observation); }, { - startOffset, - progressState: exactSessionProgressState, - verboseOutput: options?.verbosePiOutput - ? options.streamOutput - : undefined, + ...(startOffset === undefined ? {} : { startOffset }), + ...(exactSessionProgressState === undefined + ? {} + : { progressState: exactSessionProgressState }), + ...(options?.verbosePiOutput && options.streamOutput !== undefined + ? { verboseOutput: options.streamOutput } + : {}), }, ) : session?.sessionDir @@ -513,8 +531,12 @@ export async function runPiPrompt( ) : undefined; let observationFailure: Promise | undefined; - if (sessionStreamer && "failure" in sessionStreamer) { - observationFailure = sessionStreamer.failure.catch((error) => { + const sessionFailure = + sessionStreamer && "failure" in sessionStreamer + ? sessionStreamer.failure + : undefined; + if (sessionFailure instanceof Promise) { + observationFailure = sessionFailure.catch((error: unknown) => { record("observation", error); controller?.abort(error); }); @@ -548,7 +570,7 @@ export async function runPiPrompt( ), }, ), - signal: controller?.signal, + ...(controller === undefined ? {} : { signal: controller.signal }), }, ); } catch (error) { diff --git a/src/cli/commands/run-once/pipeline-failures.ts b/src/cli/commands/run-once/pipeline-failures.ts index f7c6a63d..dff2992b 100644 --- a/src/cli/commands/run-once/pipeline-failures.ts +++ b/src/cli/commands/run-once/pipeline-failures.ts @@ -25,12 +25,12 @@ import { } from "./pipeline-progress.ts"; type FailureDetails = { - specPath?: string; - specCommit?: string; - planPath?: string; - planCommit?: string; - branch?: string; - worktreePath?: string; + specPath?: string | undefined; + specCommit?: string | undefined; + planPath?: string | undefined; + planCommit?: string | undefined; + branch?: string | undefined; + worktreePath?: string | undefined; }; export async function unexpectedFailure( diff --git a/src/cli/commands/run-once/pipeline-finish.ts b/src/cli/commands/run-once/pipeline-finish.ts index 67dcd708..1bf142de 100644 --- a/src/cli/commands/run-once/pipeline-finish.ts +++ b/src/cli/commands/run-once/pipeline-finish.ts @@ -40,7 +40,7 @@ export type PipelineFinishStageOptions = { needsInfoLabel: string; checkpoints: Record; implemented: PipelineSuccessfulImplementationResult; - runCostReport?: RunCostReport; + runCostReport?: RunCostReport | undefined; specPath: string | undefined; specCommit: string | undefined; planPath: string | undefined; @@ -82,6 +82,11 @@ export async function runPipelineFinishStage( } = options; let { implemented } = options; let labels = options.labels; + if (!planPath || !branch || !worktreePath) { + throw new Error( + `Finishing implementation requires plan, branch, and worktree for issue #${issue.number}`, + ); + } await writeRunState( config.runStateDir, diff --git a/src/cli/commands/run-once/pipeline-implementation.ts b/src/cli/commands/run-once/pipeline-implementation.ts index 44e8660f..03210f91 100644 --- a/src/cli/commands/run-once/pipeline-implementation.ts +++ b/src/cli/commands/run-once/pipeline-implementation.ts @@ -78,13 +78,13 @@ export type PipelineImplementationStageOptions = { checkpoints: Record; timestamp: string; runOptions: PipelineProgressOptions & { - streamPiOutput?: (chunk: string) => void; - verbosePiOutput?: boolean; - heartbeatMs?: number; + streamPiOutput?: ((chunk: string) => void) | undefined; + verbosePiOutput?: boolean | undefined; + heartbeatMs?: number | undefined; }; piAgentDir: string; tokenUsageState: { total: number }; - progressReporter?: ProgressReporter; + progressReporter?: ProgressReporter | undefined; runStep: (label: string, fn: () => Promise) => Promise; stepStart: (label: string) => Promise; stepComplete: (label: string) => Promise; @@ -187,45 +187,64 @@ export async function runPipelineImplementationStage( let developmentEnvironment: | AgentIssueDevelopmentEnvironmentHandoff | undefined; - if (!implemented && config.skills.developmentEnvironment) { - const developmentEnvironmentStage = await runDevelopmentEnvironmentStage({ - runner, - host, - config, - issue, - labels: options.labels, - readyLabel, - inProgressLabel, - specPath, - specCommit, - planPath, - planCommit, - branch, - worktreePath, - timestamp, - logPath: runOptions.logPath, - piSessionPath: runOptions.piSessionPath, - streamPiOutput: runOptions.streamPiOutput, - verbosePiOutput: runOptions.verbosePiOutput, - heartbeatMs: runOptions.heartbeatMs, - piAgentDir, - tokenUsageState, - progressReporter, - progress: (level, stage, message, extras) => - emitProgress(runOptions, level, stage, message, extras), - runStep, - observePi, - emitSimpleStep, - }); - - if (developmentEnvironmentStage.kind === "not-ready") { - return { kind: "blocked", result: developmentEnvironmentStage.result }; + if (!implemented) { + if (!planPath || !branch) { + throw new Error( + `Implementation requires a plan and branch for issue #${issue.number}`, + ); } + if (config.skills.developmentEnvironment) { + const developmentEnvironmentStage = + await runDevelopmentEnvironmentStage({ + runner, + host, + config, + issue, + labels: options.labels, + readyLabel, + inProgressLabel, + specPath, + specCommit, + planPath, + planCommit, + branch, + worktreePath, + timestamp, + ...(runOptions.logPath === undefined + ? {} + : { logPath: runOptions.logPath }), + ...(runOptions.piSessionPath === undefined + ? {} + : { piSessionPath: runOptions.piSessionPath }), + ...(runOptions.streamPiOutput === undefined + ? {} + : { streamPiOutput: runOptions.streamPiOutput }), + ...(runOptions.verbosePiOutput === undefined + ? {} + : { verbosePiOutput: runOptions.verbosePiOutput }), + ...(runOptions.heartbeatMs === undefined + ? {} + : { heartbeatMs: runOptions.heartbeatMs }), + piAgentDir, + tokenUsageState, + progressReporter, + progress: (level, stage, message, extras) => + emitProgress(runOptions, level, stage, message, extras), + runStep, + observePi, + emitSimpleStep, + }); - developmentEnvironment = developmentEnvironmentStage.handoff; - } + if (developmentEnvironmentStage.kind === "not-ready") { + return { + kind: "blocked", + result: developmentEnvironmentStage.result, + }; + } + + developmentEnvironment = developmentEnvironmentStage.handoff; + } - if (!implemented) { await emitProgress( runOptions, "info", @@ -388,7 +407,9 @@ export async function runPipelineImplementationStage( priorBlockerQuestions: existingState?.blockerQuestions, priorValidation: existingState?.validation, }, - developmentEnvironment, + ...(developmentEnvironment === undefined + ? {} + : { developmentEnvironment }), }), { progress: progressReporter, diff --git a/src/cli/commands/run-once/pipeline-lifecycle.ts b/src/cli/commands/run-once/pipeline-lifecycle.ts index cc1a42a5..c1130e82 100644 --- a/src/cli/commands/run-once/pipeline-lifecycle.ts +++ b/src/cli/commands/run-once/pipeline-lifecycle.ts @@ -10,7 +10,7 @@ import type { import type { readRunState } from "./run-state.ts"; export class AgentIssueSafetyError extends Error { - readonly name = "AgentIssueSafetyError"; + override readonly name = "AgentIssueSafetyError"; } export function nextLabels( @@ -158,10 +158,10 @@ export function resetReceiptCheckpoints( export function successfulImplementationFromState( state: | { - implementationStatus?: "pr-created" | "merged"; - branch?: string; - prUrl?: string; - mergeCommit?: string; + implementationStatus?: "pr-created" | "merged" | undefined; + branch?: string | undefined; + prUrl?: string | undefined; + mergeCommit?: string | undefined; commits?: unknown; validation?: unknown; reviewSummary?: unknown; diff --git a/src/cli/commands/run-once/pipeline-progress.ts b/src/cli/commands/run-once/pipeline-progress.ts index cc8ef563..bc04b530 100644 --- a/src/cli/commands/run-once/pipeline-progress.ts +++ b/src/cli/commands/run-once/pipeline-progress.ts @@ -2,10 +2,10 @@ import type { AgentIssueProgressEvent, ProgressReporter } from "./progress.ts"; import type { AgentIssuePipelineResult } from "./types.ts"; export type PipelineProgressOptions = { - now?: Date; - progress?: ProgressReporter; - logPath?: string; - piSessionPath?: string; + now?: Date | undefined; + progress?: ProgressReporter | undefined; + logPath?: string | undefined; + piSessionPath?: string | undefined; }; export async function progress( @@ -65,9 +65,9 @@ export function withLogPath( } export function createStepAccounting(options: { - progress?: ProgressReporter; + progress?: ProgressReporter | undefined; issueNumber: number; - runStartedAtMs?: number; + runStartedAtMs?: number | undefined; }) { type ActiveStep = { label: string; @@ -174,7 +174,9 @@ export async function recordPiObservation(options: { stage: options.stage, message: options.observation?.type ?? "pi observation", issueNumber: options.issueNumber, - observation: options.observation, - data: options.data, + ...(options.observation === undefined + ? {} + : { observation: options.observation }), + ...(options.data === undefined ? {} : { data: options.data }), }); } diff --git a/src/cli/commands/run-once/pipeline-selection.ts b/src/cli/commands/run-once/pipeline-selection.ts index 85bdb5d6..060b2028 100644 --- a/src/cli/commands/run-once/pipeline-selection.ts +++ b/src/cli/commands/run-once/pipeline-selection.ts @@ -148,7 +148,10 @@ export async function selectResumableIssue( resumed: resumable[0]?.number === selected.number, }; } - if (resumable.length === 1) return { issue: resumable[0], resumed: true }; + const resumableIssue = resumable[0]; + if (resumable.length === 1 && resumableIssue !== undefined) { + return { issue: resumableIssue, resumed: true }; + } const diagnostics = selectIssueWithDiagnostics(issues, { issueNumber: config.issueNumber, readyLabel: ready, diff --git a/src/cli/commands/run-once/pipeline-workspace.ts b/src/cli/commands/run-once/pipeline-workspace.ts index 129a05cb..1da61f3d 100644 --- a/src/cli/commands/run-once/pipeline-workspace.ts +++ b/src/cli/commands/run-once/pipeline-workspace.ts @@ -101,7 +101,7 @@ export function hasSavedPlanningArtifactWorkspace( export function planningArtifactPolicyForWorkspace(input: { config: Pick; - existingState?: AgentIssueRunState; + existingState?: AgentIssueRunState | undefined; resolvedArtifacts: ResolvedIssueArtifactSources; worktreePath: string; allowGeneratedSpec: boolean; @@ -128,7 +128,7 @@ export function planningArtifactPolicyForWorkspace(input: { export function freshPlanningArtifactPolicy(input: { config: Pick; - existingState?: AgentIssueRunState; + existingState?: AgentIssueRunState | undefined; resolvedArtifacts: ResolvedIssueArtifactSources; allowGeneratedSpec: boolean; allowGeneratedPlan: boolean; diff --git a/src/cli/commands/run-once/pipeline.ts b/src/cli/commands/run-once/pipeline.ts index f8b57046..61bf3fd6 100644 --- a/src/cli/commands/run-once/pipeline.ts +++ b/src/cli/commands/run-once/pipeline.ts @@ -87,13 +87,15 @@ import type { IssueSummary, } from "./types.ts"; +type PiOutputStream = (chunk: string) => void; + export type RunOneIssueOptions = { - now?: Date; - progress?: ProgressReporter; - logPath?: string; - streamPiOutput?: (chunk: string) => void; - verbosePiOutput?: boolean; - heartbeatMs?: number; + now?: Date | undefined; + progress?: ProgressReporter | undefined; + logPath?: string | undefined; + streamPiOutput?: PiOutputStream | undefined; + verbosePiOutput?: boolean | undefined; + heartbeatMs?: number | undefined; }; type LeasedRunOneIssueOptions = RunOneIssueOptions & { lease?: import("./types.ts").IssueRunLease; @@ -326,7 +328,10 @@ async function runOneIssueInternal( ); } - const ignoredPaths = cleanStatusIgnoredPaths(config, runOptions); + const ignoredPaths = cleanStatusIgnoredPaths( + config, + runOptions.logPath === undefined ? {} : { logPath: runOptions.logPath }, + ); // Typed recovery owns every blocked-state classification, including safely // recreatable missing worktrees and branches. Do not pre-gate it here. const blockedRecoveryResumable = hasBlockedRunRecoveryState(existingState); @@ -584,6 +589,8 @@ async function runOneIssueInternal( status: "blocked" as const, reason: existingState.lastError, questions: existingState.blockerQuestions ?? [], + commits: existingState.commits ?? [], + validation: existingState.validation ?? [], }; const body = blockerComment(blocked); if (issue.comments?.some((comment) => comment.body === body)) @@ -661,8 +668,9 @@ async function runOneIssueInternal( } if (artifactPolicy?.kind === "implementation-resume") { + const resumeArtifactPolicy = artifactPolicy; const sourcesToMaterialize = { - ...(!artifactPolicy.saved.specPath && resolvedArtifacts.spec + ...(!resumeArtifactPolicy.saved.specPath && resolvedArtifacts.spec ? { spec: resolvedArtifacts.spec } : {}), ...(!artifactPolicy.saved.planPath && resolvedArtifacts.plan @@ -674,7 +682,7 @@ async function runOneIssueInternal( "materialize issue artifact sources", async () => materializeIssueArtifactSources({ - repoRoot: artifactPolicy.primary.repoRoot, + repoRoot: resumeArtifactPolicy.primary.repoRoot, runner, issueNumber: issueForRun.number, sources: sourcesToMaterialize, @@ -805,6 +813,13 @@ async function runOneIssueInternal( } const worktree = await ensureIssueWorkspace(); + const implementationWorktreePath = worktreePath ?? worktree.worktreePath; + const implementationBranch = branch ?? worktree.branch; + if (!implementationBranch || !planPath) { + throw new AgentIssueSafetyError( + `Implementation requires a branch and plan for issue #${issue.number}`, + ); + } const implementationStage = await runPipelineImplementationStage({ runner, host, @@ -817,8 +832,8 @@ async function runOneIssueInternal( specCommit, planPath, planCommit, - branch, - worktreePath, + branch: implementationBranch, + worktreePath: implementationWorktreePath, worktree, worktreeStrategy, existingState, @@ -888,8 +903,8 @@ async function runOneIssueInternal( specCommit, planPath, planCommit, - branch, - worktreePath, + branch: implementationBranch, + worktreePath: implementationWorktreePath, timestamp, runOptions, runStep, diff --git a/src/cli/commands/run-once/planning-artifacts.ts b/src/cli/commands/run-once/planning-artifacts.ts index 7e1adab3..38c7044d 100644 --- a/src/cli/commands/run-once/planning-artifacts.ts +++ b/src/cli/commands/run-once/planning-artifacts.ts @@ -14,28 +14,28 @@ export type PlanningArtifactRoot = { }; export type ResolvedPlanningArtifact = { - path?: string; - commit?: string; + path?: string | undefined; + commit?: string | undefined; exists: boolean; fromState: boolean; created: boolean; generated: boolean; - rootSource?: PlanningArtifactRoot["source"]; + rootSource?: PlanningArtifactRoot["source"] | undefined; }; export type PlanningArtifactPolicy = | { kind: "fresh"; primary: PlanningArtifactRoot; - fallbacks?: PlanningArtifactRoot[]; - explicit?: ResolvedIssueArtifactSources; + fallbacks?: PlanningArtifactRoot[] | undefined; + explicit?: ResolvedIssueArtifactSources | undefined; saved?: { - specPath?: string; - specCommit?: string; - planPath?: string; - planCommit?: string; - specCreated?: boolean; - planCreated?: boolean; + specPath?: string | undefined; + specCommit?: string | undefined; + planPath?: string | undefined; + planCommit?: string | undefined; + specCreated?: boolean | undefined; + planCreated?: boolean | undefined; }; allowGeneratedSpec: boolean; allowGeneratedPlan: boolean; @@ -45,14 +45,14 @@ export type PlanningArtifactPolicy = primary: PlanningArtifactRoot; fallbacks: PlanningArtifactRoot[]; saved: { - specPath?: string; - specCommit?: string; - planPath?: string; - planCommit?: string; - specCreated?: boolean; - planCreated?: boolean; + specPath?: string | undefined; + specCommit?: string | undefined; + planPath?: string | undefined; + planCommit?: string | undefined; + specCreated?: boolean | undefined; + planCreated?: boolean | undefined; }; - explicit?: ResolvedIssueArtifactSources; + explicit?: ResolvedIssueArtifactSources | undefined; }; export type ResolvedPlanningArtifacts = { @@ -105,9 +105,9 @@ export function planningArtifactRoot( function explicitMatchesSaved(input: { kind: "spec" | "plan"; - explicit?: { path: string; commit?: string }; - savedPath?: string; - savedCommit?: string; + explicit?: { path: string; commit?: string } | undefined; + savedPath?: string | undefined; + savedCommit?: string | undefined; }): void { if (!input.explicit || !input.savedPath) return; if (input.explicit.path !== input.savedPath) { @@ -128,9 +128,9 @@ function explicitMatchesSaved(input: { async function findSaved(input: { roots: PlanningArtifactRoot[]; - savedPath?: string; - savedCommit?: string; - savedCreated?: boolean; + savedPath?: string | undefined; + savedCommit?: string | undefined; + savedCreated?: boolean | undefined; }): Promise { if (!input.savedPath) return unresolvedArtifact(); diff --git a/src/cli/commands/run-once/pr-cost-summary.ts b/src/cli/commands/run-once/pr-cost-summary.ts index 867ad95b..23da2fd4 100644 --- a/src/cli/commands/run-once/pr-cost-summary.ts +++ b/src/cli/commands/run-once/pr-cost-summary.ts @@ -6,7 +6,7 @@ const TOKEN_FORMAT = new Intl.NumberFormat("en-US", { useGrouping: true, }); export class PrCostSummaryError extends Error { - readonly name = "PrCostSummaryError"; + override readonly name = "PrCostSummaryError"; } function escapeCell(value: string): string { return value diff --git a/src/cli/commands/run-once/prompts.ts b/src/cli/commands/run-once/prompts.ts index 725f124b..4cebb3a0 100644 --- a/src/cli/commands/run-once/prompts.ts +++ b/src/cli/commands/run-once/prompts.ts @@ -40,7 +40,7 @@ export type SpecCreationPromptInput = { export type PlanCreationPromptInput = { issue: IssueSummary; - specPath?: string; + specPath?: string | undefined; planPath: string; projectPolicy: PatchmillProjectPolicy; planApprovalRequired?: boolean; @@ -292,6 +292,7 @@ function renderNumberedStepText(text: string): string { if (lines.length === 0) return ""; const [first, ...rest] = lines; + if (first === undefined) return ""; const normalizedFirst = first.replace(/^-\s+/, ""); if (rest.length === 0) return normalizedFirst; diff --git a/src/cli/commands/run-once/recovery-assessment.ts b/src/cli/commands/run-once/recovery-assessment.ts index 5b141db6..bec8cf6b 100644 --- a/src/cli/commands/run-once/recovery-assessment.ts +++ b/src/cli/commands/run-once/recovery-assessment.ts @@ -117,20 +117,20 @@ function classify(input: { branchExists: boolean; worktreeExists: boolean; registered: boolean; - registeredBranch?: string; + registeredBranch?: string | undefined; expectedBranchElsewhere: boolean; expectedBranch: string; - dirty?: string; + dirty?: string | undefined; ignored: string[]; savedCommits: string[]; fenced: boolean; active: boolean; - divergence?: { ahead: number; behind: number }; + divergence?: { ahead: number; behind: number } | undefined; commits: string[]; - savedBranch?: string; - savedWorktreePath?: string; + savedBranch?: string | undefined; + savedWorktreePath?: string | undefined; expectedWorktreePath: string; - registrationLocked?: boolean; + registrationLocked?: boolean | undefined; registrationsMalformed?: boolean; }): RunRecoveryClassification { if ( diff --git a/src/cli/commands/run-once/recovery-lease-repair.ts b/src/cli/commands/run-once/recovery-lease-repair.ts index bcc91876..5b717c22 100644 --- a/src/cli/commands/run-once/recovery-lease-repair.ts +++ b/src/cli/commands/run-once/recovery-lease-repair.ts @@ -60,6 +60,7 @@ function migrationFence(value: unknown): RunLegacyMigrationFence | undefined { if ( fence.version !== 1 || !Number.isSafeInteger(fence.issueNumber) || + typeof fence.issueNumber !== "number" || fence.issueNumber <= 0 || !["claimed", "planning", "implementing"].includes(fence.status as string) || typeof fence.stateSha256 !== "string" || @@ -162,7 +163,11 @@ export async function repairIssueRunLease(input: { throw error; } try { - const [kind, expected] = requested[0]; + const requestedItem = requested[0]; + if (requestedItem === undefined) { + throw new Error("Repair requires exactly one expected SHA-256"); + } + const [kind, expected] = requestedItem; const source = kind === "lease" ? file(input.runStateDir, input.issueNumber, ".lock") diff --git a/src/cli/commands/run-once/recovery-lease.ts b/src/cli/commands/run-once/recovery-lease.ts index 79b1bf9f..d3ce8e78 100644 --- a/src/cli/commands/run-once/recovery-lease.ts +++ b/src/cli/commands/run-once/recovery-lease.ts @@ -14,7 +14,7 @@ export type IssueRunLeaseOptions = { pid?: number; hostname?: string; ownerToken?: string; - now?: () => Date; + now?: (() => Date) | undefined; processState?: (pid: number) => "alive" | "dead" | "unverifiable"; afterObserveLease?: () => Promise | void; }; @@ -41,7 +41,7 @@ export class IssueRunLeaseConflictError extends Error { this.leasePath = leasePath; this.resource = resource; this.issueNumber = issueNumber; - this.owner = owner; + if (owner !== undefined) this.owner = owner; } } function paths(dir: string, issue: number) { @@ -88,11 +88,15 @@ export function parseIssueRunLeaseRecord( value.acquiredAt, ) && !Number.isNaN(Date.parse(value.acquiredAt)); + const issueNumber = value.issueNumber; + const pid = value.pid; return value.version === 1 && - Number.isSafeInteger(value.issueNumber) && - value.issueNumber > 0 && - Number.isSafeInteger(value.pid) && - value.pid > 0 && + typeof issueNumber === "number" && + Number.isSafeInteger(issueNumber) && + issueNumber > 0 && + typeof pid === "number" && + Number.isSafeInteger(pid) && + pid > 0 && typeof value.hostname === "string" && validHostname(value.hostname) && typeof value.ownerToken === "string" && diff --git a/src/cli/commands/run-once/recovery-legacy.ts b/src/cli/commands/run-once/recovery-legacy.ts index f084d361..1401a439 100644 --- a/src/cli/commands/run-once/recovery-legacy.ts +++ b/src/cli/commands/run-once/recovery-legacy.ts @@ -23,16 +23,20 @@ export type BlockedRunRecoveryReport = { issueNumber: number; title: string; status: AgentIssueRunState["status"]; - blockerReason?: string; - branch: { name?: string; exists: boolean; merged: boolean }; + blockerReason?: string | undefined; + branch: { + name?: string | undefined; + exists: boolean; + merged: boolean; + }; worktree: { - path?: string; + path?: string | undefined; exists: boolean; registered: boolean; clean?: boolean; dirtyStatus?: string; }; - divergence?: { ahead: number; behind: number }; + divergence?: { ahead: number; behind: number } | undefined; commits: string[]; recommendedActions: string[]; }; @@ -171,8 +175,14 @@ async function branchDivergence(input: { `git rev-list returned unparseable divergence for ${input.branch}: ${result.stdout.trim() || "(empty output)"}`, ); } - const [behindText, aheadText] = fields; - if (!/^\d+$/u.test(behindText) || !/^\d+$/u.test(aheadText)) { + const behindText = fields[0]; + const aheadText = fields[1]; + if ( + behindText === undefined || + aheadText === undefined || + !/^\d+$/u.test(behindText) || + !/^\d+$/u.test(aheadText) + ) { throw new Error( `git rev-list returned unparseable divergence for ${input.branch}: ${result.stdout.trim()}`, ); @@ -297,7 +307,11 @@ export async function inspectBlockedRunRecovery(input: { input.state.worktreePath, ), ]); - const worktree = { + const worktree: Omit & { + exists: boolean; + registered: boolean; + clean?: boolean; + } = { ...baseReport.worktree, exists: physicalExists, registered, diff --git a/src/cli/commands/run-once/recovery-policy.ts b/src/cli/commands/run-once/recovery-policy.ts index d70fbcd8..d9ab11cd 100644 --- a/src/cli/commands/run-once/recovery-policy.ts +++ b/src/cli/commands/run-once/recovery-policy.ts @@ -1,5 +1,6 @@ import type { RunRecoveryAssessment, + RunRecoveryClassification, RunRecoveryDecision, RunRecoveryIntent, RunResetSeed, @@ -25,7 +26,12 @@ function refusal( .concat(assessment.savedCommits) .join(", ") : undefined; - const preserveGuidance = { + const preserveGuidance: Partial< + Record< + RunRecoveryClassification | "not-blocked" | "active-run", + string | readonly string[] + > + > = { "dirty-worktree": "Commit, stash, or clean local modifications before retrying recovery.", "ignored-worktree-content": @@ -43,6 +49,7 @@ function refusal( "Use normal run-once execution; this Run state is not blocked.", "active-run": "Wait for the active Run attempt before retrying recovery.", } as const; + const guidance = preserveGuidance[reason] ?? `Recovery is unsafe: ${reason}.`; return { action: "refuse", assessment, @@ -51,9 +58,7 @@ function refusal( detail ? `Recovery is unsafe: ${detail}` : `Recovery is unsafe: ${reason}.`, - ...(Array.isArray(preserveGuidance[reason]) - ? preserveGuidance[reason] - : [preserveGuidance[reason]]), + ...(Array.isArray(guidance) ? guidance : [guidance]), ], }; } diff --git a/src/cli/commands/run-once/recovery-worktree.ts b/src/cli/commands/run-once/recovery-worktree.ts index a21712ef..4296b6d1 100644 --- a/src/cli/commands/run-once/recovery-worktree.ts +++ b/src/cli/commands/run-once/recovery-worktree.ts @@ -31,14 +31,21 @@ export function parseWorktreeRegistrations(output: string): { } { const entries: RegisteredWorktree[] = []; let malformed = false; - let entry: RegisteredWorktree; - let worktrees: number; - let heads: number; - let branchFields: number; - let detached: number; - let bares: number; - let locks: number; - let prunables: number; + let entry: RegisteredWorktree = { + path: "", + bare: false, + locked: false, + prunable: false, + malformed: false, + seen: false, + }; + let worktrees = 0; + let heads = 0; + let branchFields = 0; + let detached = 0; + let bares = 0; + let locks = 0; + let prunables = 0; const reset = () => { entry = { path: "", diff --git a/src/cli/commands/run-once/result-output.ts b/src/cli/commands/run-once/result-output.ts index 39b22ca9..4e6cb539 100644 --- a/src/cli/commands/run-once/result-output.ts +++ b/src/cli/commands/run-once/result-output.ts @@ -14,10 +14,10 @@ export type RunOnceResultStream = { export type WriteRunOnceResultOptions = { stdout: RunOnceResultStream; env: Record; - logPath?: string; - progress?: FinalResultProgressSnapshot; - elapsedSeconds?: number; - time?: Date; + logPath?: string | undefined; + progress?: FinalResultProgressSnapshot | undefined; + elapsedSeconds?: number | undefined; + time?: Date | undefined; }; export function exitCodeForRunOnceResult(summary: RunOnceResultSummary): 0 | 1 { return summary.status === "approval-required" || diff --git a/src/cli/commands/run-once/result-summary.ts b/src/cli/commands/run-once/result-summary.ts index 12146510..a2211615 100644 --- a/src/cli/commands/run-once/result-summary.ts +++ b/src/cli/commands/run-once/result-summary.ts @@ -36,9 +36,9 @@ export type RunOncePipelineResultSummary = RunOnceResultLog & worktreePath: string; commits: string[]; validation: string[]; - reviewSummary?: string; - landingDecision?: string; - visualEvidence?: AgentIssueVisualEvidence[]; + reviewSummary?: string | undefined; + landingDecision?: string | undefined; + visualEvidence?: AgentIssueVisualEvidence[] | undefined; } | { status: "merged"; @@ -50,8 +50,8 @@ export type RunOncePipelineResultSummary = RunOnceResultLog & worktreePath: string; commits: string[]; validation: string[]; - reviewSummary?: string; - landingDecision?: string; + reviewSummary?: string | undefined; + landingDecision?: string | undefined; } | { status: "approval-required"; diff --git a/src/cli/commands/run-once/run-cost-files.ts b/src/cli/commands/run-once/run-cost-files.ts index 8b87262c..339d2c79 100644 --- a/src/cli/commands/run-once/run-cost-files.ts +++ b/src/cli/commands/run-once/run-cost-files.ts @@ -48,9 +48,10 @@ function startedAt(content: string, path: string, fallback: number): number { const match = /^(\d{4}-\d\d-\d\dT\d\d-\d\d-\d\d-\d\d\dZ)_/u.exec( basename(path), ); - if (match) { + const timestamp = match?.[1]; + if (timestamp !== undefined) { const time = Date.parse( - match[1].replace(/T(\d\d)-(\d\d)-(\d\d)-(\d\d\d)Z/u, "T$1:$2:$3.$4Z"), + timestamp.replace(/T(\d\d)-(\d\d)-(\d\d)-(\d\d\d)Z/u, "T$1:$2:$3.$4Z"), ); if (!Number.isNaN(time)) return time; } diff --git a/src/cli/commands/run-once/run-cost.ts b/src/cli/commands/run-once/run-cost.ts index caf11e87..bac61cf5 100644 --- a/src/cli/commands/run-once/run-cost.ts +++ b/src/cli/commands/run-once/run-cost.ts @@ -24,7 +24,7 @@ export type RunCostSessionFile = { }; export class RunCostReportError extends Error { - readonly name = "RunCostReportError"; + override readonly name = "RunCostReportError"; } const STAGE_ORDER = [ @@ -89,7 +89,10 @@ function assistantUsage(entry: unknown, path: string): Usage | undefined { }; } function total( - rows: readonly RunCostModelUsage[], + rows: readonly Pick< + RunCostModelUsage, + "promptTokens" | "outputTokens" | "estimatedCostUsd" + >[], ): Omit { return rows.reduce( (sum, row) => ({ diff --git a/src/cli/commands/run-once/run-state.ts b/src/cli/commands/run-once/run-state.ts index ee381890..1907ac8a 100644 --- a/src/cli/commands/run-once/run-state.ts +++ b/src/cli/commands/run-once/run-state.ts @@ -255,11 +255,8 @@ function mergeRunState( } const timestampField = STATUS_TIMESTAMPS[update.status]; - if (!next[timestampField]) { - next[timestampField] = now; - } - - return next; + if (next[timestampField]) return next; + return { ...next, [timestampField]: now }; } export function isResumableRunState(state: AgentIssueRunState): boolean { diff --git a/src/cli/commands/run-once/selection.ts b/src/cli/commands/run-once/selection.ts index ef134191..ab60d424 100644 --- a/src/cli/commands/run-once/selection.ts +++ b/src/cli/commands/run-once/selection.ts @@ -21,7 +21,7 @@ const DEFAULT_TRIAGE_POLICY = createTriagePolicy( type ResolvedIssueSelectionOptions = { issueNumber?: number; readyLabel: IssueSelectionOptions["readyLabel"]; - approvalPolicy: IssueSelectionOptions["approvalPolicy"]; + approvalPolicy?: IssueSelectionOptions["approvalPolicy"]; priorityLabels: readonly string[]; excludedLabels: Set; }; @@ -39,9 +39,13 @@ function resolveSelectionOptions( const triagePolicy = options.triagePolicy ?? DEFAULT_TRIAGE_POLICY; return { - issueNumber: options.issueNumber, + ...(options.issueNumber === undefined + ? {} + : { issueNumber: options.issueNumber }), readyLabel: options.readyLabel, - approvalPolicy: options.approvalPolicy, + ...(options.approvalPolicy === undefined + ? {} + : { approvalPolicy: options.approvalPolicy }), priorityLabels: options.priorityLabels ?? triagePolicy.runOnceSelection.priorityOrder, excludedLabels: new Set([ @@ -152,8 +156,9 @@ export function selectIssueWithDiagnostics( const resolved = resolveSelectionOptions(options); if (resolved.issueNumber !== undefined) { + const issue = selectIssue(issues, options); return { - issue: selectIssue(issues, options), + ...(issue === undefined ? {} : { issue }), rejections: [], consideredCount: issues.length, }; diff --git a/src/cli/commands/run-once/stage-advancement.ts b/src/cli/commands/run-once/stage-advancement.ts index 438fcea6..4fa24d13 100644 --- a/src/cli/commands/run-once/stage-advancement.ts +++ b/src/cli/commands/run-once/stage-advancement.ts @@ -47,12 +47,12 @@ type RunStep = (label: string, fn: () => Promise) => Promise; type BlockIssue = ( result: AgentIssueBlockedResult, details: { - specPath?: string; - specCommit?: string; - planPath?: string; - planCommit?: string; - branch?: string; - worktreePath?: string; + specPath?: string | undefined; + specCommit?: string | undefined; + planPath?: string | undefined; + planCommit?: string | undefined; + branch?: string | undefined; + worktreePath?: string | undefined; }, ) => Promise; @@ -63,26 +63,26 @@ type PlanningArtifactWorkspace = Partial< }; type ExistingPlanningState = { - status?: AgentIssueRunStateStatus; - branch?: string; - worktreePath?: string; - blockedAt?: string; - lastError?: string; - specPath?: string; - specCommit?: string; - planPath?: string; - planCommit?: string; - checkpoints?: AgentIssueRunCheckpoints; + status?: AgentIssueRunStateStatus | undefined; + branch?: string | undefined; + worktreePath?: string | undefined; + blockedAt?: string | undefined; + lastError?: string | undefined; + specPath?: string | undefined; + specCommit?: string | undefined; + planPath?: string | undefined; + planCommit?: string | undefined; + checkpoints?: AgentIssueRunCheckpoints | undefined; }; export type PlanningStageAdvanceResult = | { kind: "continue"; labels: string[]; - specPath?: string; - specCommit?: string; + specPath?: string | undefined; + specCommit?: string | undefined; planPath: string; - planCommit?: string; + planCommit?: string | undefined; } | { kind: "finished"; result: AgentIssuePipelineResult }; @@ -96,19 +96,21 @@ export type AdvancePlanningStagesOptions = { inProgress: string; needsInfo: string; approvalGatesSatisfied: boolean; - existingState?: ExistingPlanningState; - resolvedArtifacts?: ResolvedIssueArtifactSources; - artifactPolicy?: PlanningArtifactPolicy; + existingState?: ExistingPlanningState | undefined; + resolvedArtifacts?: ResolvedIssueArtifactSources | undefined; + artifactPolicy?: PlanningArtifactPolicy | undefined; ensurePlanningArtifactWorkspace?: () => Promise; checkpoints: AgentIssueRunCheckpoints; timestamp: string; now: Date; runOptions: { - progress?: { event(event: AgentIssueProgressEvent): void | Promise }; - streamPiOutput?: (chunk: string) => void; - verbosePiOutput?: boolean; - heartbeatMs?: number; - piSessionPath?: string; + progress?: + | { event(event: AgentIssueProgressEvent): void | Promise } + | undefined; + streamPiOutput?: ((chunk: string) => void) | undefined; + verbosePiOutput?: boolean | undefined; + heartbeatMs?: number | undefined; + piSessionPath?: string | undefined; }; piAgentDir: string; tokenUsageState: { total: number }; @@ -508,6 +510,7 @@ export async function advancePlanningStages({ !checkpoints.specPublished ) { await runStep("publish spec artifact", async () => { + if (!specCommit) throw new Error("Published spec is missing a commit"); await assertCommittedArtifact({ runner, repoRoot: planningRepoRoot, @@ -647,6 +650,7 @@ export async function advancePlanningStages({ } if (!plan.exists) { + const planPathForPrompt = planPath; const planned = await runStep("create plan", async () => { await progress("info", "pi-plan", "creating plan with pi", { issueNumber: issue.number, @@ -657,7 +661,7 @@ export async function advancePlanningStages({ buildPlanCreationPrompt({ issue, specPath, - planPath, + planPath: planPathForPrompt, projectPolicy: config.projectPolicy, planApprovalRequired: config.approvalPolicy.planApproval.required, skills: config.skills, @@ -745,6 +749,7 @@ export async function advancePlanningStages({ !checkpoints.planPublished ) { await runStep("publish plan artifact", async () => { + if (!planCommit) throw new Error("Published plan is missing a commit"); await assertCommittedArtifact({ runner, repoRoot: planningRepoRoot, diff --git a/src/cli/commands/run-once/terminal-result-layout.ts b/src/cli/commands/run-once/terminal-result-layout.ts index 85b370e8..774edf18 100644 --- a/src/cli/commands/run-once/terminal-result-layout.ts +++ b/src/cli/commands/run-once/terminal-result-layout.ts @@ -7,12 +7,15 @@ import type { TerminalResultSeverity } from "./terminal-result.ts"; export type TerminalValue = { text: string; - role?: "plain" | "url" | "path" | "commit"; + role?: "plain" | "url" | "path" | "commit" | undefined; +}; +export type TerminalField = { + label?: string | undefined; + value: TerminalValue; }; -export type TerminalField = { label?: string; value: TerminalValue }; export type TerminalListItem = { value: TerminalValue; - details?: TerminalField[]; + details?: TerminalField[] | undefined; }; export type TerminalSectionBlock = | { kind: "value"; value: TerminalValue } @@ -25,7 +28,7 @@ export type TerminalSectionBlock = }; export type TerminalSection = { heading: string; - count?: number; + count?: number | undefined; blocks: TerminalSectionBlock[]; }; export type TerminalDocument = { diff --git a/src/cli/commands/run-once/terminal-result.ts b/src/cli/commands/run-once/terminal-result.ts index daf51228..9eeafbe2 100644 --- a/src/cli/commands/run-once/terminal-result.ts +++ b/src/cli/commands/run-once/terminal-result.ts @@ -15,9 +15,9 @@ export type TerminalResultSeverity = "success" | "warning" | "failure"; export type TerminalResultOptions = { width: number; color: boolean; - stepNumber?: number; - totalOutputTokens?: number; - elapsedSeconds?: number; + stepNumber?: number | undefined; + totalOutputTokens?: number | undefined; + elapsedSeconds?: number | undefined; }; const STATUS = { "no-issue": { label: "No eligible issue", severity: "success" }, @@ -316,9 +316,19 @@ export function formatTerminalResult( blocks: [{ kind: "list", marker: "•", items: files }], }); return renderTerminalDocument({ + width: options.width, + color: options.color, label: STATUS[summary.status].label, severity: STATUS[summary.status].severity, sections, - ...options, + ...(options.stepNumber === undefined + ? {} + : { stepNumber: options.stepNumber }), + ...(options.totalOutputTokens === undefined + ? {} + : { totalOutputTokens: options.totalOutputTokens }), + ...(options.elapsedSeconds === undefined + ? {} + : { elapsedSeconds: options.elapsedSeconds }), }); } diff --git a/src/cli/commands/run-once/types.ts b/src/cli/commands/run-once/types.ts index ead58bce..d7b348f0 100644 --- a/src/cli/commands/run-once/types.ts +++ b/src/cli/commands/run-once/types.ts @@ -1,3 +1,4 @@ +import type { CommandRunner, IssueSummary } from "../triage/types.ts"; import type { PatchmillHostConfig } from "../../../config/types.ts"; import type { PatchmillTriagePolicy } from "../../../policy/triage.ts"; import type { PatchmillProjectPolicy } from "../../../policy/types.ts"; @@ -19,22 +20,22 @@ export type AgentIssueConfig = { repoRoot: string; dryRun: boolean; execute: boolean; - showHelp?: boolean; - quiet?: boolean; - verbosePiOutput?: boolean; - issueNumber?: number; + showHelp?: boolean | undefined; + quiet?: boolean | undefined; + verbosePiOutput?: boolean | undefined; + issueNumber?: number | undefined; planOnly: boolean; host: PatchmillHostConfig; - teaLogin?: string; + teaLogin?: string | undefined; specsDir: string; plansDir: string; runStateDir: string; worktreeDir: string; - cleanStatusIgnorePrefixes?: string[]; - cleanupHook?: string; + cleanStatusIgnorePrefixes?: string[] | undefined; + cleanupHook?: string | undefined; projectPolicy: PatchmillProjectPolicy; skills: PatchmillSkillsConfig; - triagePolicy?: PatchmillTriagePolicy; + triagePolicy?: PatchmillTriagePolicy | undefined; readyLabel: string; issueLimit: 1; labelCatalog: PatchmillLabelCatalog; @@ -52,9 +53,9 @@ export type IssueSelectionOptions = Pick< AgentIssueConfig, "issueNumber" | "readyLabel" | "triagePolicy" > & { - approvalPolicy?: AgentIssueConfig["approvalPolicy"]; - priorityLabels?: readonly string[]; - excludedLabels?: readonly string[]; + approvalPolicy?: AgentIssueConfig["approvalPolicy"] | undefined; + priorityLabels?: readonly string[] | undefined; + excludedLabels?: readonly string[] | undefined; }; export type IssueSelectionRejectionReason = @@ -71,12 +72,12 @@ export type IssueSelectionRejection = { labels: string[]; workflowState: string; reason: IssueSelectionRejectionReason; - blockingLabels?: string[]; - missingLabel?: string; + blockingLabels?: string[] | undefined; + missingLabel?: string | undefined; }; export type IssueSelectionDiagnostics = { - issue?: IssueSummary; + issue?: IssueSummary | undefined; rejections: IssueSelectionRejection[]; consideredCount: number; }; @@ -121,75 +122,75 @@ export type AgentIssueRunState = { issueNumber: number; title: string; status: AgentIssueRunStateStatus; - branch?: string; - worktreePath?: string; - specPath?: string; - specCommit?: string; - planPath?: string; - planCommit?: string; - checkpoints?: AgentIssueRunCheckpoints; - implementationStatus?: "pr-created" | "merged"; - prUrl?: string; - mergeCommit?: string; - commits?: string[]; - validation?: string[]; - reviewSummary?: string; - landingDecision?: string; - runCostReport?: RunCostReport; - visualEvidence?: AgentIssueVisualEvidence[]; - handoffCommentPosted?: boolean; - failureCommentKeys?: string[]; - blockerCommentKeys?: string[]; - leaseProtocolVersion?: 1; - blockerQuestions?: AgentIssueBlockerQuestion[]; + branch?: string | undefined; + worktreePath?: string | undefined; + specPath?: string | undefined; + specCommit?: string | undefined; + planPath?: string | undefined; + planCommit?: string | undefined; + checkpoints?: AgentIssueRunCheckpoints | undefined; + implementationStatus?: "pr-created" | "merged" | undefined; + prUrl?: string | undefined; + mergeCommit?: string | undefined; + commits?: string[] | undefined; + validation?: string[] | undefined; + reviewSummary?: string | undefined; + landingDecision?: string | undefined; + runCostReport?: RunCostReport | undefined; + visualEvidence?: AgentIssueVisualEvidence[] | undefined; + handoffCommentPosted?: boolean | undefined; + failureCommentKeys?: string[] | undefined; + blockerCommentKeys?: string[] | undefined; + leaseProtocolVersion?: 1 | undefined; + blockerQuestions?: AgentIssueBlockerQuestion[] | undefined; createdAt: string; updatedAt: string; - claimedAt?: string; - planningAt?: string; - implementingAt?: string; - blockedAt?: string; - finishedAt?: string; - lastError?: string; + claimedAt?: string | undefined; + planningAt?: string | undefined; + implementingAt?: string | undefined; + blockedAt?: string | undefined; + finishedAt?: string | undefined; + lastError?: string | undefined; }; export type AgentIssueRunStateUpdate = { issueNumber: number; status: AgentIssueRunStateStatus; - title?: string; - branch?: string; - worktreePath?: string; - specPath?: string; - specCommit?: string; - planPath?: string; - planCommit?: string; - checkpoints?: AgentIssueRunCheckpoints; - resetCheckpoints?: boolean; - implementationStatus?: "pr-created" | "merged"; - prUrl?: string; - mergeCommit?: string; - commits?: string[]; - validation?: string[]; - reviewSummary?: string; - landingDecision?: string; - runCostReport?: RunCostReport; - visualEvidence?: AgentIssueVisualEvidence[]; - handoffCommentPosted?: boolean; - failureCommentKeys?: string[]; - blockerCommentKeys?: string[]; - leaseProtocolVersion?: 1; - blockerQuestions?: AgentIssueBlockerQuestion[]; - lastError?: string; - clearLastError?: boolean; - clearBlockerQuestions?: boolean; + title?: string | undefined; + branch?: string | undefined; + worktreePath?: string | undefined; + specPath?: string | undefined; + specCommit?: string | undefined; + planPath?: string | undefined; + planCommit?: string | undefined; + checkpoints?: AgentIssueRunCheckpoints | undefined; + resetCheckpoints?: boolean | undefined; + implementationStatus?: "pr-created" | "merged" | undefined; + prUrl?: string | undefined; + mergeCommit?: string | undefined; + commits?: string[] | undefined; + validation?: string[] | undefined; + reviewSummary?: string | undefined; + landingDecision?: string | undefined; + runCostReport?: RunCostReport | undefined; + visualEvidence?: AgentIssueVisualEvidence[] | undefined; + handoffCommentPosted?: boolean | undefined; + failureCommentKeys?: string[] | undefined; + blockerCommentKeys?: string[] | undefined; + leaseProtocolVersion?: 1 | undefined; + blockerQuestions?: AgentIssueBlockerQuestion[] | undefined; + lastError?: string | undefined; + clearLastError?: boolean | undefined; + clearBlockerQuestions?: boolean | undefined; }; export type AgentIssueImplementationResumeContext = { resumed: boolean; worktreeCreated: boolean; existingCommits: string[]; - priorBlockerReason?: string; - priorBlockerQuestions?: AgentIssueBlockerQuestion[]; - priorValidation?: string[]; + priorBlockerReason?: string | undefined; + priorBlockerQuestions?: AgentIssueBlockerQuestion[] | undefined; + priorValidation?: string[] | undefined; }; export type AgentIssueBlockerQuestion = @@ -207,13 +208,13 @@ export type AgentIssueBlockedResult = { export type AgentIssueSpecCreatedResult = { status: "spec-created"; specPath: string; - commit?: string; + commit?: string | undefined; }; export type AgentIssuePlanCreatedResult = { status: "plan-created"; planPath: string; - commit?: string; + commit?: string | undefined; }; export type AgentIssueApprovalRequiredResult = { @@ -227,7 +228,7 @@ export type AgentIssueDevelopmentEnvironmentReadyResult = { status: "ready"; summary: string; evidence: string[]; - environment?: Record; + environment?: Record | undefined; }; export type AgentIssueDevelopmentEnvironmentNotReadyResult = { @@ -248,9 +249,9 @@ export type AgentIssueDevelopmentEnvironmentHandoff = export type AgentIssueVisualEvidence = { screenshotPath: string; - caption?: string; - referencePaths?: string[]; - url?: string; + caption?: string | undefined; + referencePaths?: string[] | undefined; + url?: string | undefined; }; export type AgentIssuePrCreatedResult = { @@ -259,9 +260,9 @@ export type AgentIssuePrCreatedResult = { branch: string; commits: string[]; validation: string[]; - reviewSummary?: string; - landingDecision?: string; - visualEvidence?: AgentIssueVisualEvidence[]; + reviewSummary?: string | undefined; + landingDecision?: string | undefined; + visualEvidence?: AgentIssueVisualEvidence[] | undefined; }; export type AgentIssueMergedResult = { @@ -270,8 +271,8 @@ export type AgentIssueMergedResult = { mergeCommit: string; commits: string[]; validation: string[]; - reviewSummary?: string; - landingDecision?: string; + reviewSummary?: string | undefined; + landingDecision?: string | undefined; }; export type AgentIssuePiResult = @@ -282,8 +283,8 @@ export type AgentIssuePiResult = | AgentIssueMergedResult; type AgentIssuePipelineResultLog = { - logPath?: string; - piSessionPath?: string; + logPath?: string | undefined; + piSessionPath?: string | undefined; }; export type AgentIssuePipelineResult = AgentIssuePipelineResultLog & @@ -298,33 +299,33 @@ export type AgentIssuePipelineResult = AgentIssuePipelineResultLog & | { status: "plan-created" | "plan-found"; issue: IssueSummary; - specPath?: string; + specPath?: string | undefined; planPath: string; } | AgentIssueApprovalRequiredResult | { status: "development-environment-not-ready"; issue: IssueSummary; - specPath?: string; + specPath?: string | undefined; planPath: string; - branch?: string; - worktreePath?: string; + branch?: string | undefined; + worktreePath?: string | undefined; reason: string; evidence: string[]; remediation: string[]; } | ({ issue: IssueSummary; - specPath?: string; + specPath?: string | undefined; planPath: string; worktreePath: string; } & (AgentIssuePrCreatedResult | AgentIssueMergedResult)) | ({ issue: IssueSummary; - specPath?: string; - planPath?: string; - worktreePath?: string; - branch?: string; + specPath?: string | undefined; + planPath?: string | undefined; + worktreePath?: string | undefined; + branch?: string | undefined; } & AgentIssueBlockedResult) ); @@ -359,17 +360,17 @@ export type RunLegacyMigrationFence = { export type RunResetSeed = { issueNumber: number; title: string; - specPath?: string; - specCommit?: string; - planPath?: string; - planCommit?: string; - startedCommentPosted?: true; + specPath?: string | undefined; + specCommit?: string | undefined; + planPath?: string | undefined; + planCommit?: string | undefined; + startedCommentPosted?: true | undefined; }; export type RunRecoveryArtifactAssessment = { - path?: string; - commit?: string; + path?: string | undefined; + commit?: string | undefined; valid: boolean; - source?: "base" | "published"; + source?: "base" | "published" | undefined; }; export type RunRecoveryAssessment = { runStatePath: string; @@ -377,26 +378,33 @@ export type RunRecoveryAssessment = { title: string; status: AgentIssueRunStateStatus; lease: { status: "owned"; ownerToken: string }; - leaseProtocolVersion?: 1; + leaseProtocolVersion?: 1 | undefined; legacyMigrationFenceValid: boolean; blocked: boolean; - startedCommentPosted?: true; - blockerReason?: string; - blockerQuestions?: AgentIssueBlockerQuestion[]; + startedCommentPosted?: true | undefined; + blockerReason?: string | undefined; + blockerQuestions?: AgentIssueBlockerQuestion[] | undefined; expectedWorkspace: { branch: string; worktreePath: string }; - savedWorkspace: { branch?: string; worktreePath?: string }; + savedWorkspace: { + branch?: string | undefined; + worktreePath?: string | undefined; + }; baseOid: string; - branch: { exists: boolean; oid?: string; checkedOutAt?: string }; + branch: { + exists: boolean; + oid?: string | undefined; + checkedOutAt?: string | undefined; + }; worktree: { exists: boolean; registered: boolean; - registeredBranch?: string; - clean?: boolean; - dirtyStatus?: string; - ignoredStatus?: string; + registeredBranch?: string | undefined; + clean?: boolean | undefined; + dirtyStatus?: string | undefined; + ignoredStatus?: string | undefined; ignoredEntries: string[]; }; - divergence?: { ahead: number; behind: number }; + divergence?: { ahead: number; behind: number } | undefined; actualUniqueCommits: string[]; savedCommits: string[]; artifacts: { @@ -417,15 +425,15 @@ export type RunRecoveryRecreationPlan = { branch: string; expectedWorktreePath: string; mode: "reuse-existing" | "create-from-base" | "advance-to-base"; - expectedBranchOid?: string; + expectedBranchOid?: string | undefined; targetOid: string; stagingPath: string; }; export type RunRecoveryCleanupPlan = { - branch?: string; - expectedWorktreePath?: string; - expectedBranchOid?: string; - quarantinePath?: string; + branch?: string | undefined; + expectedWorktreePath?: string | undefined; + expectedBranchOid?: string | undefined; + quarantinePath?: string | undefined; }; export type RunRecoveryDecision = | { action: "resume"; assessment: RunRecoveryAssessment } @@ -456,7 +464,7 @@ export type RunRecoveryDecision = reason: "active-run"; resource: "lease" | "lease-guard" | "repair-lock"; leasePath: string; - owner?: RunRecoveryLeaseOwner; + owner?: RunRecoveryLeaseOwner | undefined; guidance: string[]; }; export type PlanRunRecoveryInput = { @@ -467,11 +475,13 @@ export type PlanRunRecoveryInput = { state: AgentIssueRunState; baseRef: string; expectedWorkspace: { branch: string; worktreePath: string }; - ignoredPaths?: string[]; - resolvedArtifacts?: import("./artifact-sources.ts").ResolvedIssueArtifactSources; + ignoredPaths?: string[] | undefined; + resolvedArtifacts?: + | import("./artifact-sources.ts").ResolvedIssueArtifactSources + | undefined; leaseOwnerToken: string; snapshotRaw: string; - legacyMigrationFence?: RunLegacyMigrationFence; + legacyMigrationFence?: RunLegacyMigrationFence | undefined; /** Allocated once by orchestration and reused across every reassessment. */ recoveryPaths: { quarantinePath: string; stagingPath: string }; }; diff --git a/src/cli/commands/run-once/workflow-state.ts b/src/cli/commands/run-once/workflow-state.ts index 2bdb80a6..7d0a729d 100644 --- a/src/cli/commands/run-once/workflow-state.ts +++ b/src/cli/commands/run-once/workflow-state.ts @@ -2,7 +2,7 @@ import type { WorkflowApprovalPolicy } from "../../../workflow/approval-policy.t import type { IssueSummary } from "./types.ts"; export class ApprovalRequiredError extends Error { - readonly name = "ApprovalRequiredError"; + override readonly name = "ApprovalRequiredError"; readonly issue: IssueSummary; readonly approvalKind: "spec" | "plan"; readonly missingLabel: string; diff --git a/src/cli/commands/run/lease/repair.ts b/src/cli/commands/run/lease/repair.ts index f67c85d8..4c7dcd36 100644 --- a/src/cli/commands/run/lease/repair.ts +++ b/src/cli/commands/run/lease/repair.ts @@ -106,9 +106,9 @@ export async function runLeaseRepairCommand( const result = await (dependencies.repair ?? repairIssueRunLease)({ runStateDir: config.runStateDir, issueNumber: issue, - expectedLeaseSha256: lease, - expectedGuardSha256: guard, - expectedStateSha256: state, + ...(lease === undefined ? {} : { expectedLeaseSha256: lease }), + ...(guard === undefined ? {} : { expectedGuardSha256: guard }), + ...(state === undefined ? {} : { expectedStateSha256: state }), confirmedProcessesStopped: matchingConfirmation, }); stderr.write(`${result.kind}: ${result.path}\n`); diff --git a/src/cli/commands/run/main.ts b/src/cli/commands/run/main.ts index 70c12927..8f358095 100644 --- a/src/cli/commands/run/main.ts +++ b/src/cli/commands/run/main.ts @@ -3,7 +3,10 @@ import { main as lease } from "./lease/main.ts"; export type RunCommandHandler = (args: string[]) => number | Promise; export async function runRunCommand( args: string[], - commands: ReadonlyMap = new Map([ + commands: ReadonlyMap = new Map< + string, + RunCommandHandler + >([ ["reset", reset], ["lease", lease], ]), diff --git a/src/cli/commands/run/reset/reset.ts b/src/cli/commands/run/reset/reset.ts index 33cae534..11dd24e9 100644 --- a/src/cli/commands/run/reset/reset.ts +++ b/src/cli/commands/run/reset/reset.ts @@ -65,7 +65,7 @@ export type ResetIssueRunResult = }; export function validateResetIssueEligibility(input: { issue: IssueSummary; - state?: AgentIssueRunState; + state?: AgentIssueRunState | undefined; config: AgentIssueConfig; }): void { if (input.issue.state !== "open") diff --git a/src/cli/commands/set-artifact/main.ts b/src/cli/commands/set-artifact/main.ts index c7a76783..60fed040 100644 --- a/src/cli/commands/set-artifact/main.ts +++ b/src/cli/commands/set-artifact/main.ts @@ -100,7 +100,8 @@ function parseArgs(args: string[]): ParsedArgs { if (positional.length > 1) { throw new Error("Only one artifact path may be provided"); } - parsed.path = positional[0]; + const path = positional[0]; + if (path !== undefined) parsed.path = path; return parsed; } diff --git a/src/cli/commands/setup-test-repo/args.ts b/src/cli/commands/setup-test-repo/args.ts index 2525dc58..bd679909 100644 --- a/src/cli/commands/setup-test-repo/args.ts +++ b/src/cli/commands/setup-test-repo/args.ts @@ -23,8 +23,10 @@ function requireValue(args: string[], index: number, flag: string): string { function parseRepo(value: string): RepositoryTarget { const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u.exec(value); - if (!match) throw new Error("--repo must use OWNER/REPO"); - return { owner: match[1], repo: match[2], slug: value }; + const owner = match?.[1]; + const repo = match?.[2]; + if (!owner || !repo) throw new Error("--repo must use OWNER/REPO"); + return { owner, repo, slug: value }; } function parseProvider(value: string): PatchmillHostProviderId { @@ -39,6 +41,7 @@ export function parseArgs(args: string[]): SetupTestRepoConfig { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; + if (arg === undefined) throw new Error("Unexpected missing argument"); if (arg === "--help" || arg === "-h") { config.showHelp = true; } else if (arg === "--provider") { diff --git a/src/cli/commands/setup-test-repo/issue-parser.ts b/src/cli/commands/setup-test-repo/issue-parser.ts index 14c9f83f..d1271f2d 100644 --- a/src/cli/commands/setup-test-repo/issue-parser.ts +++ b/src/cli/commands/setup-test-repo/issue-parser.ts @@ -16,12 +16,13 @@ function frontmatterValue( function parseLabels(fileName: string, value: string | undefined): string[] { if (value === undefined) return []; const match = /^\[(.*)\]$/u.exec(value.trim()); - if (!match) { + const rawLabels = match?.[1]; + if (rawLabels === undefined) { throw new Error(`${fileName} labels must use [label, other-label] syntax`); } - if (match[1].trim().length === 0) return []; - const labels = match[1].split(",").map((label) => label.trim()); + if (rawLabels.trim().length === 0) return []; + const labels = rawLabels.split(",").map((label) => label.trim()); if (labels.some((label) => label.length === 0)) { throw new Error(`${fileName} labels include an empty value`); } @@ -33,17 +34,25 @@ function parseFrontmatter(fileName: string, raw: string): Map { for (const line of raw.split(/\r?\n/u)) { if (line.trim().length === 0) continue; const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/u.exec(line); - if (!match) throw new Error(`${fileName} has invalid frontmatter: ${line}`); - fields.set(match[1], match[2]); + const key = match?.[1]; + const value = match?.[2]; + if (key === undefined || value === undefined) { + throw new Error(`${fileName} has invalid frontmatter: ${line}`); + } + fields.set(key, value); } return fields; } export function parseIssueFile(fileName: string, content: string): SetupIssue { const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/u.exec(content); - if (!match) throw new Error(`${fileName} is missing frontmatter`); + const frontmatter = match?.[1]; + const rawBody = match?.[2]; + if (frontmatter === undefined || rawBody === undefined) { + throw new Error(`${fileName} is missing frontmatter`); + } - const fields = parseFrontmatter(fileName, match[1]); + const fields = parseFrontmatter(fileName, frontmatter); const title = frontmatterValue(fields, "title"); if (!title) { throw new Error(`${fileName} is missing required frontmatter field: title`); @@ -53,6 +62,6 @@ export function parseIssueFile(fileName: string, content: string): SetupIssue { fileName, title, labels: parseLabels(fileName, frontmatterValue(fields, "labels")), - body: match[2].replace(/^\r?\n/u, "").replace(/\s*$/u, "") + "\n", + body: rawBody.replace(/^\r?\n/u, "").replace(/\s*$/u, "") + "\n", }; } diff --git a/src/cli/commands/triage/dry-run-agent.ts b/src/cli/commands/triage/dry-run-agent.ts index 6e7eb3c8..611d4909 100644 --- a/src/cli/commands/triage/dry-run-agent.ts +++ b/src/cli/commands/triage/dry-run-agent.ts @@ -34,7 +34,7 @@ export type TriageDryRunPromptInput = { skills?: PatchmillSkillsConfig; stateMap: PatchmillTriageStateMap; thinking?: string; - onToolCall?: TriageToolCallHandler; + onToolCall?: TriageToolCallHandler | undefined; piAgentDir?: string; piCommand?: PiCommandSpec; }; @@ -162,7 +162,7 @@ const STDOUT_SNIPPET_RADIUS = 80; function triagePreviewJsonBody(stdout: string): string { const trimmed = stdout.trim(); const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); - return fenced ? fenced[1] : trimmed; + return fenced?.[1] ?? trimmed; } function hasTopLevelPreviews( diff --git a/src/cli/commands/triage/execute-issues.ts b/src/cli/commands/triage/execute-issues.ts index 7a407129..ad631760 100644 --- a/src/cli/commands/triage/execute-issues.ts +++ b/src/cli/commands/triage/execute-issues.ts @@ -28,7 +28,7 @@ export type ExecuteTriageIssuesOptions = { stateMap: PatchmillTriageStateMap; skills: PatchmillSkillsConfig; thinking: string; - onToolCall?: TriageToolCallHandler; + onToolCall?: TriageToolCallHandler | undefined; onIssue?: ( entry: TriageLogIssueEntry, completed: number, @@ -37,12 +37,13 @@ export type ExecuteTriageIssuesOptions = { }; function cloneIssue(issue: IssueSummary): IssueSummary { + const comments = Array.isArray(issue.comments) + ? [...issue.comments] + : issue.comments; return { ...issue, labels: [...issue.labels], - comments: Array.isArray(issue.comments) - ? [...issue.comments] - : issue.comments, + ...(comments === undefined ? {} : { comments }), }; } @@ -87,7 +88,9 @@ export async function executeTriageIssues( host: options.hostConfig, skills: options.skills, thinking: options.thinking, - onToolCall: options.onToolCall, + ...(options.onToolCall === undefined + ? {} + : { onToolCall: options.onToolCall }), }); const afterIssue = await snapshotIssue(options.host, beforeIssue.number); diff --git a/src/cli/commands/triage/forgejo.ts b/src/cli/commands/triage/forgejo.ts index cbd6f0c0..16b58d78 100644 --- a/src/cli/commands/triage/forgejo.ts +++ b/src/cli/commands/triage/forgejo.ts @@ -111,16 +111,20 @@ function parseIssuePayload(entry: unknown): IssueSummary { throw new Error(`Unexpected issue payload: ${JSON.stringify(entry)}`); } + const author = authorName(issue.author); + const created = issueCreated(issue); + const updated = typeof issue.updated === "string" ? issue.updated : undefined; + const comments = issueComments(issue.comments); const parsedIssue: IssueSummary = { number, title: issue.title, body: typeof issue.body === "string" ? issue.body : "", state: typeof issue.state === "string" ? issue.state : "open", labels: labelNames(issue.labels), - author: authorName(issue.author), - created: issueCreated(issue), - updated: typeof issue.updated === "string" ? issue.updated : undefined, - comments: issueComments(issue.comments), + author, + created, + updated, + comments, }; if (typeof issue.url === "string") parsedIssue.url = issue.url; diff --git a/src/cli/commands/triage/main.ts b/src/cli/commands/triage/main.ts index 78bb04b2..c0849555 100755 --- a/src/cli/commands/triage/main.ts +++ b/src/cli/commands/triage/main.ts @@ -119,7 +119,9 @@ export async function main( { ...config, onProgress: reporter.onProgress, - onToolCall: reporter.onToolCall, + ...(reporter.onToolCall === undefined + ? {} + : { onToolCall: reporter.onToolCall }), }, ); reporter.finish(result); diff --git a/src/cli/commands/triage/types.ts b/src/cli/commands/triage/types.ts index 29b95e9d..c3ab4aad 100644 --- a/src/cli/commands/triage/types.ts +++ b/src/cli/commands/triage/types.ts @@ -76,11 +76,11 @@ export type IssueSummary = { body: string; labels: string[]; state: string; - url?: string; - author?: string; - created?: string; - updated?: string; - comments?: IssueCommentSummary[]; + url?: string | undefined; + author?: string | undefined; + created?: string | undefined; + updated?: string | undefined; + comments?: IssueCommentSummary[] | undefined; }; export type LabelDefinition = { @@ -137,7 +137,7 @@ export type LabelChangePlan = { export type TriageLogIssueEntry = { issueNumber: number; title: string; - url?: string; + url?: string | undefined; previousLabels: string[]; finalLabels: string[]; primaryBucket?: PrimaryBucket; diff --git a/src/config/load.ts b/src/config/load.ts index 53b62c71..cdaec456 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -27,7 +27,12 @@ import { readOptionalString, readOptionalStringArray, } from "./parse-helpers.ts"; -import type { PartialConfig } from "./partial.ts"; +import type { + PartialConfig, + PartialPiTaskContract, + PartialProjectPolicy, +} from "./partial.ts"; +import type { PartialPatchmillSkillsConfig } from "../workflow/skills.ts"; import type { PatchmillConfig } from "./types.ts"; import { cloneWorkflowConfig, @@ -251,9 +256,12 @@ function mergeConfig( base.projectPolicy, update.projectPolicy, ); + const planRequiresApprovalAlias = update.projectPolicy?.planRequiresApproval; const workflow = mergeWorkflowConfig(base.workflow, update.workflow, { labels, - planRequiresApprovalAlias: update.projectPolicy?.planRequiresApproval, + ...(planRequiresApprovalAlias === undefined + ? {} + : { planRequiresApprovalAlias }), }); return { diff --git a/src/config/partial.ts b/src/config/partial.ts index d2cb29e9..8639fa76 100644 --- a/src/config/partial.ts +++ b/src/config/partial.ts @@ -2,7 +2,7 @@ import type { PartialPatchmillSkillsConfig } from "../workflow/skills.ts"; import type { PatchmillConfig } from "./types.ts"; import type { PartialWorkflowConfig } from "./workflow.ts"; -type PartialPiTaskContract = Partial< +export type PartialPiTaskContract = Partial< PatchmillConfig["projectPolicy"]["pi"]["taskContract"] >; @@ -12,7 +12,7 @@ type PartialPiWorkflowPolicy = Partial< taskContract?: PartialPiTaskContract; }; -type PartialProjectPolicy = Partial< +export type PartialProjectPolicy = Partial< Omit< PatchmillConfig["projectPolicy"], "validation" | "directLand" | "visualEvidence" | "pi" diff --git a/src/config/workflow.ts b/src/config/workflow.ts index 55c99d37..696a2e58 100644 --- a/src/config/workflow.ts +++ b/src/config/workflow.ts @@ -197,19 +197,20 @@ export function readWorkflowConfig( if (!value || typeof value !== "object" || Array.isArray(value)) { throw configError("workflow", "an object", value); } + const workflow = value as Record; const parsed: PartialWorkflowConfig = {}; - const specApproval = readWorkflowApprovalConfig(value, "specApproval"); - const planApproval = readWorkflowApprovalConfig(value, "planApproval"); + const specApproval = readWorkflowApprovalConfig(workflow, "specApproval"); + const planApproval = readWorkflowApprovalConfig(workflow, "planApproval"); if (specApproval !== undefined) parsed.specApproval = specApproval; if (planApproval !== undefined) parsed.planApproval = planApproval; - for (const entry of Object.keys(value)) { + for (const entry of Object.keys(workflow)) { if (!WORKFLOW_APPROVAL_KEYS.includes(entry as WorkflowApprovalKey)) { throw configError( `workflow.${entry}`, "a supported workflow setting", - value[entry], + workflow[entry], ); } } diff --git a/src/host/forgejo-pr-body.ts b/src/host/forgejo-pr-body.ts index 98054db5..f9d7d273 100644 --- a/src/host/forgejo-pr-body.ts +++ b/src/host/forgejo-pr-body.ts @@ -35,15 +35,18 @@ export async function readForgejoPullRequestBody( } catch (cause) { throw new Error("tea api returned invalid JSON", { cause }); } + const record = value as Record; + const body = record?.body; + const htmlUrl = record?.html_url; if ( !value || typeof value !== "object" || - typeof (value as Record).body !== "string" || - typeof (value as Record).html_url !== "string" || - !sameCanonicalUrl(prUrl, (value as Record).html_url) + typeof body !== "string" || + typeof htmlUrl !== "string" || + !sameCanonicalUrl(prUrl, htmlUrl) ) throw new Error("tea api returned an invalid or mismatched PR body"); - return (value as Record).body; + return body; } export async function updateForgejoPullRequestBody( options: ForgejoPrBodyOptions, diff --git a/src/host/forgejo-tea.ts b/src/host/forgejo-tea.ts index a3671357..5d692f0e 100644 --- a/src/host/forgejo-tea.ts +++ b/src/host/forgejo-tea.ts @@ -114,9 +114,9 @@ function parseLabelNames(stdout: string, context: string): string[] { } type TeaLoginEntry = { - name?: string; - user?: string; - default?: boolean | string; + name?: string | undefined; + user?: string | undefined; + default?: boolean | string | undefined; }; function teaLoginEntries(stdout: string): TeaLoginEntry[] { @@ -127,15 +127,17 @@ function teaLoginEntries(stdout: string): TeaLoginEntry[] { return parsed.flatMap((entry) => { if (!entry || typeof entry !== "object") return []; const value = entry as Record; + const name = typeof value.name === "string" ? value.name : undefined; + const user = typeof value.user === "string" ? value.user : undefined; + const defaultLogin = + typeof value.default === "boolean" || typeof value.default === "string" + ? value.default + : undefined; return [ { - name: typeof value.name === "string" ? value.name : undefined, - user: typeof value.user === "string" ? value.user : undefined, - default: - typeof value.default === "boolean" || - typeof value.default === "string" - ? value.default - : undefined, + name, + user, + default: defaultLogin, }, ]; }); diff --git a/src/host/github-pr-body.ts b/src/host/github-pr-body.ts index 0d1ea557..3a859501 100644 --- a/src/host/github-pr-body.ts +++ b/src/host/github-pr-body.ts @@ -20,15 +20,18 @@ function bodyPayload(stdout: string, url: string): string { } catch (cause) { throw new Error("gh pr view returned invalid JSON", { cause }); } + const record = data as Record; + const body = record?.body; + const resultUrl = record?.url; if ( !data || typeof data !== "object" || - typeof (data as Record).body !== "string" || - typeof (data as Record).url !== "string" || - !sameCanonicalUrl(url, (data as Record).url) + typeof body !== "string" || + typeof resultUrl !== "string" || + !sameCanonicalUrl(url, resultUrl) ) throw new Error("gh pr view returned an invalid or mismatched PR body"); - return (data as Record).body; + return body; } export async function readGitHubPullRequestBody( options: GitHubPrBodyOptions, diff --git a/src/host/pull-request-reference.ts b/src/host/pull-request-reference.ts index 867a9914..e7f208bf 100644 --- a/src/host/pull-request-reference.ts +++ b/src/host/pull-request-reference.ts @@ -11,15 +11,17 @@ function parse(value: string): URL | undefined { export function pullRequestNumber(prUrl: string, pathSegment: string): number { const url = parse(prUrl); const parts = url?.pathname.split("/").filter(Boolean); + const number = parts?.[3]; if ( !url || !parts || parts.length !== 4 || parts[2] !== pathSegment || - !/^[1-9]\d*$/u.test(parts[3]) + number === undefined || + !/^[1-9]\d*$/u.test(number) ) throw new Error(`Invalid pull request URL: ${prUrl}`); - return Number(parts[3]); + return Number(number); } export function sameCanonicalUrl(left: string, right: string): boolean { const a = parse(left), diff --git a/src/host/types.ts b/src/host/types.ts index 746464ae..e7444cd2 100644 --- a/src/host/types.ts +++ b/src/host/types.ts @@ -12,11 +12,11 @@ export type IssueSummary = { body: string; labels: string[]; state: string; - author?: string; - created?: string; - updated?: string; - url?: string; - comments?: IssueCommentSummary[]; + author?: string | undefined; + created?: string | undefined; + updated?: string | undefined; + url?: string | undefined; + comments?: IssueCommentSummary[] | undefined; }; export type LabelDefinition = { diff --git a/src/pi/runner.ts b/src/pi/runner.ts index 4e8cac6b..c6f7ea1d 100644 --- a/src/pi/runner.ts +++ b/src/pi/runner.ts @@ -52,9 +52,13 @@ export class PiRunner implements PiPromptContracts { issue: input.issue, planPath: input.planPath, projectPolicy, - planApprovalRequired: input.planApprovalRequired, - skills: input.skills, - triageLabels: input.triageLabels, + ...(input.planApprovalRequired === undefined + ? {} + : { planApprovalRequired: input.planApprovalRequired }), + ...(input.skills === undefined ? {} : { skills: input.skills }), + ...(input.triageLabels === undefined + ? {} + : { triageLabels: input.triageLabels }), }), { ...input.runOptions, @@ -87,8 +91,8 @@ export class PiRunner implements PiPromptContracts { worktreePath: input.worktreePath, git: input.git, projectPolicy, - skills: input.skills, - resume: input.resume, + ...(input.skills === undefined ? {} : { skills: input.skills }), + ...(input.resume === undefined ? {} : { resume: input.resume }), }), { ...input.runOptions, diff --git a/src/policy/task-contract.ts b/src/policy/task-contract.ts index c45a34cc..32e1b097 100644 --- a/src/policy/task-contract.ts +++ b/src/policy/task-contract.ts @@ -141,9 +141,11 @@ export function compilePlanTaskHeadingPattern( } const headingMatch = pattern.match(/^(#+)\s+/); - const minHeadingLevel = headingMatch?.[1].length ?? 0; - const template = headingMatch - ? pattern.slice(headingMatch[0].length) + const headingPrefix = headingMatch?.[1]; + const fullHeadingPrefix = headingMatch?.[0]; + const minHeadingLevel = headingPrefix?.length ?? 0; + const template = fullHeadingPrefix + ? pattern.slice(fullHeadingPrefix.length) : pattern; let bodyPattern = escapeRegExp(template); bodyPattern = replacePlaceholderCapture( diff --git a/src/workflow/artifacts/published-artifacts.ts b/src/workflow/artifacts/published-artifacts.ts index 7708ecb5..eafe2e3d 100644 --- a/src/workflow/artifacts/published-artifacts.ts +++ b/src/workflow/artifacts/published-artifacts.ts @@ -31,11 +31,13 @@ export type PublishedArtifactTrustOptions = { }; export type PublishedArtifactIssue = { - body?: string; - comments?: Array<{ - body: string; - authorLogin?: string; - }>; + body?: string | undefined; + comments?: + | Array<{ + body: string; + authorLogin?: string | undefined; + }> + | undefined; }; type PublishedArtifactMetadata = { diff --git a/src/workflow/skill-pack.ts b/src/workflow/skill-pack.ts index dc9a0214..7199d230 100644 --- a/src/workflow/skill-pack.ts +++ b/src/workflow/skill-pack.ts @@ -136,14 +136,19 @@ export function buildRecommendedProjectSkillConfig( ), ) as Pick; + const triage = bundledProjectLocalConfig.triage; + const visualEvidence = bundledProjectLocalConfig.visualEvidence; + if (triage === undefined || visualEvidence === undefined) { + throw new Error("Bundled project-local skill config is incomplete"); + } return { - triage: bundledProjectLocalConfig.triage, + triage, planning: projectSkillPath(PATCHMILL_PLANNING_SKILL, skillDir), implementation: projectSkillPath( SUBAGENT_DEV_WITH_VALIDATION_AND_PR_CHECKS_SKILL, skillDir, ), - visualEvidence: bundledProjectLocalConfig.visualEvidence, + visualEvidence, }; } diff --git a/src/workflow/skill-resolution.ts b/src/workflow/skill-resolution.ts index 4b46fdff..0dfca533 100644 --- a/src/workflow/skill-resolution.ts +++ b/src/workflow/skill-resolution.ts @@ -40,10 +40,18 @@ const SKILL_NAMESPACE_PATTERN = /^[a-z0-9-]+:.+$/iu; const SKILL_FILE_NAME = "SKILL.md"; export function bundledTriageSkillPath(): string { + if (bundledTriageSkill === undefined) { + throw new Error("Bundled Patchmill skill registry is missing triage"); + } return bundledSkillPath(bundledTriageSkill); } export function bundledVisualEvidenceSkillPath(): string { + if (bundledVisualEvidenceSkill === undefined) { + throw new Error( + "Bundled Patchmill skill registry is missing visual evidence", + ); + } return bundledSkillPath(bundledVisualEvidenceSkill); }