import type { RoleHost, HostContext, HostToolResult, HostGatekeeperActions } from "./host-contracts.ts"; import { Type, type Static } from "typebox"; import { openToolObjectFromUnion } from "./open-tool-schema.ts"; import { withTerminatingOutputDeclarations } from "./package-contracts/terminating-infrastructure.ts"; import { CorrectableSubmissionError } from "./submission-correctable-error.ts"; import type { AnyCanonicalSkillBinding, CanonicalSkillBinding, } from "./canonical-skill-binding.ts"; import { CODER_ACCEPTED_TEXT, CODER_OUTPUT_TOOL_NAME, FIXER_ACCEPTED_TEXT, FIXER_OUTPUT_TOOL_NAME, validateAcceptedWorkerDetails, type CoderOutput, type FixerOutput, type WorkerOutput, type WorkerRoleLabel, } from "./package-contracts/worker-output.ts"; import { fixerOutputSchema, validateFixerOutput, type FixerPhase } from "./package-contracts/fixer-output.ts"; import { FixerPacketValidationError, parseFixerPrerequisites, type FixerInvocationInput, } from "./package-contracts/fixer-packet.ts"; import { createWorkerSubmissionGate, WORKER_DONE_STATUSES, WorkerCommitReminderError, WorkerPrefixReminderError, WorkerUnfinishedReasonReminderError, } from "./worker-submission-gates.ts"; import { fixerBashSeatbeltDenyReason, matchFixerBashForbiddenLiteral, } from "./fixer-bash-seatbelt.ts"; export { CODER_OUTPUT_TOOL_NAME, FIXER_OUTPUT_TOOL_NAME, validateAcceptedWorkerDetails, }; export type { WorkerOutput }; // #836 r16 class 1: report/remainingScope are LLM/human-read narrative content — // no code branches on their length. `reason` alone keeps minLength: the worker // gate reads `reason.trim().length > 0` to pick typed-reminder-bounce vs accept // (src/worker-submission-gates.ts:159-163,297-302). // #836 (ADR 0003 Amendment): status kept open like countersignStatus // (src/countersign-role.ts) — one shared description across every variant // so openToolObjectFromUnion's identical-declaration collapse drops none of it. const CODER_STATUS_DESCRIPTION = "planned | completed | refused | unfinished — 形状指引,非 schema 闸;completed 回执含 TDD、同模式、引入回归、行为事实四项证据;unfinished 缺前置或违宪约束致本局未完成时可用,缺待决 owner 决定或答复属缺前置。" as const; const coderOutputVariants = Type.Union([ Type.Object({ status: Type.Unknown({ description: CODER_STATUS_DESCRIPTION }), report: Type.String({ description: "如实结果报告" }), }, { additionalProperties: false }), Type.Object({ status: Type.Unknown({ description: CODER_STATUS_DESCRIPTION }), report: Type.String({ description: "如实结果报告" }), }, { additionalProperties: false }), Type.Object({ status: Type.Unknown({ description: CODER_STATUS_DESCRIPTION }), report: Type.String({ description: "如实结果报告" }), remainingScope: Type.String({ description: "本局后剩余工作" }), reason: Type.Optional(Type.String({ minLength: 1, description: "阻断原因:缺前置或违宪约束。缺待决 owner 决定或答复属缺前置。", })), }, { additionalProperties: false }), ]); export const coderOutputSchema = withTerminatingOutputDeclarations( openToolObjectFromUnion(coderOutputVariants), ); export type { FixerOutput, CoderOutput }; export const FIXER_FLAG_DEFINITIONS = { packet: { name: "ak-fix-packet", definition: { description: "Path to opaque prose instructions for the Fixer", type: "string" as const, }, }, prerequisites: { name: "ak-fixer-prerequisites", definition: { description: "Optional path to a JSON array of typed Fixer prerequisites", type: "string" as const, }, }, phase: { name: "ak-fixer-phase", definition: { description: "Fixer phase: plan (inspect and propose a repair plan; no edits or commits) or apply (execute the approved plan, verify, and commit when repaired)", type: "string" as const, }, }, } as const; export const FIXER_PHASES = ["plan", "apply"] as const satisfies readonly FixerPhase[]; type WorkerPhase = (typeof FIXER_PHASES)[number]; function isWorkerPhase(value: unknown): value is WorkerPhase { return typeof value === "string" && (FIXER_PHASES as readonly string[]).includes(value); } export type WorkerRoleHostActions = HostGatekeeperActions; /** Stable code for completed apply without host skill-expansion capability evidence (#525). */ export const CODER_SKILL_EXPANSION_EVIDENCE_MISSING_CODE = "coder_skill_expansion_evidence_missing" as const; export type CoderSkillExpansionEvidenceMissingResult = { readonly code: typeof CODER_SKILL_EXPANSION_EVIDENCE_MISSING_CODE; }; /** Correct completed rejection — not infrastructure; projected via submission non-pass bridge. */ export class CoderSkillExpansionEvidenceMissingError extends CorrectableSubmissionError { readonly code = CODER_SKILL_EXPANSION_EVIDENCE_MISSING_CODE; readonly result: CoderSkillExpansionEvidenceMissingResult; constructor() { super("Coder completed requires host skill-expansion capability evidence"); this.name = "CoderSkillExpansionEvidenceMissingError"; this.result = Object.freeze({ code: CODER_SKILL_EXPANSION_EVIDENCE_MISSING_CODE }); } } export type FixerRoleDependencies = { loadSoul(): Promise; loadPacket(path: string): Promise; }; export type CoderRoleDependencies = { loadSoul(): Promise; loadTask(path: string): Promise; loadCanonicalSkillBinding?( name: "tdd", ): Promise; }; export type WorkerRoleRuntime = { activate(ctx?: HostContext): Promise; /** Arm gate ① baseline after envelope places the worktree (coder/fixer). Durable parent required (#857). */ armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }): void; }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Read the accepted receipt's own `status` word as submitted (#836: `status` * stays an open Type.Unknown provider field, so FixerOutput's Static type no * longer narrows it to a literal union — code still just reads whatever * string the role wrote; a non-string status reads as "" and simply misses * every known-status gate below, same as any other unrecognized status). */ function workerStatusOf(output: WorkerOutput): string { return typeof output.status === "string" ? output.status : ""; } function deepFreeze(value: T): T { if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; for (const child of Object.values(value)) deepFreeze(child); return Object.freeze(value); } export function validateWorkerOutput( output: unknown, phase: WorkerPhase, roleLabel: WorkerRoleLabel, ): WorkerOutput { if (roleLabel === "Fixer") return validateFixerOutput(output, phase); return validateAcceptedWorkerDetails(output, "Coder") as CoderOutput; } /** Reminder bounces stay typed rejects; IO/infrastructure keep identity via host failInfrastructure. */ function assertAcceptableThroughHost( submissionGate: { assertAcceptable(status: string, details?: unknown): void }, status: string, details: unknown, hostActions: WorkerRoleHostActions, ctx: HostContext, toolCallId: string, ): void { try { submissionGate.assertAcceptable(status, details); } catch (error) { if ( error instanceof WorkerCommitReminderError || error instanceof WorkerPrefixReminderError || error instanceof WorkerUnfinishedReasonReminderError ) { throw error; } hostActions.failInfrastructure(error, ctx, toolCallId); } } export function createFixerRoleRuntime( pi: RoleHost, dependencies: FixerRoleDependencies, hostActions: WorkerRoleHostActions, ): WorkerRoleRuntime { let soul: string | undefined; let packet: FixerInvocationInput | undefined; let packetPath: string | undefined; let prerequisitesPath: string | undefined; let phase: WorkerPhase | undefined; let lifecycleRegistered = false; const submissionGate = createWorkerSubmissionGate(); pi.registerFlag( FIXER_FLAG_DEFINITIONS.packet.name, FIXER_FLAG_DEFINITIONS.packet.definition, ); pi.registerFlag( FIXER_FLAG_DEFINITIONS.prerequisites.name, FIXER_FLAG_DEFINITIONS.prerequisites.definition, ); pi.registerFlag( FIXER_FLAG_DEFINITIONS.phase.name, FIXER_FLAG_DEFINITIONS.phase.definition, ); return { async activate() { soul = (await dependencies.loadSoul()).trim(); if (soul.length === 0) throw new Error("Fixer soul is empty"); const selectedPhase = pi.getFlag(FIXER_FLAG_DEFINITIONS.phase.name); if (!isWorkerPhase(selectedPhase)) { throw new Error( "Fixer role requires --ak-fixer-phase plan|apply; no other phase is supported", ); } phase = selectedPhase; const resolvedPacketPath = pi.getFlag(FIXER_FLAG_DEFINITIONS.packet.name); if (typeof resolvedPacketPath !== "string" || resolvedPacketPath.trim().length === 0) { throw new Error("Fixer role requires --ak-fix-packet"); } packetPath = resolvedPacketPath; const instructions = await dependencies.loadPacket(packetPath); if (instructions.trim().length === 0) { throw new FixerPacketValidationError( new Error("Fixer instructions must be nonblank"), ); } const resolvedPrerequisitesPath = pi.getFlag(FIXER_FLAG_DEFINITIONS.prerequisites.name); if (resolvedPrerequisitesPath !== undefined && (typeof resolvedPrerequisitesPath !== "string" || resolvedPrerequisitesPath.trim().length === 0)) { throw new Error("Fixer --ak-fixer-prerequisites path must be nonblank when supplied"); } prerequisitesPath = typeof resolvedPrerequisitesPath === "string" ? resolvedPrerequisitesPath : undefined; const prerequisites = prerequisitesPath !== undefined ? parseFixerPrerequisites(await dependencies.loadPacket(prerequisitesPath)) : Object.freeze([]); packet = Object.freeze({ instructions, prerequisites }); if (!lifecycleRegistered) { lifecycleRegistered = true; pi.registerTool({ name: FIXER_OUTPUT_TOOL_NAME, label: "修内司输出", description: "提交修内司终局回执;基础设施失败走 abort,不经本工具。", promptSnippet: "提交修内司终局回执", parameters: fixerOutputSchema, async execute(toolCallId: string, parameters: unknown, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: HostContext): Promise> { if (packet === undefined || phase === undefined) { throw new Error("修内司修理包与阶段未装载"); } const output = deepFreeze(validateFixerOutput(parameters, phase)); assertAcceptableThroughHost( submissionGate, workerStatusOf(output), output, hostActions, ctx, toolCallId, ); if (WORKER_DONE_STATUSES.has(workerStatusOf(output))) { await pi.requireGatekeeperPass!({ context: ctx, subject: { kind: "worker_completion" }, ...(_signal === undefined ? {} : { signal: _signal }), hostActions, toolCallId, // #879: this-turn typed payload — identity-bound at submit site. submission: output, }); } const acceptedDetails = output; return { content: [{ type: "text" as const, text: FIXER_ACCEPTED_TEXT }], details: acceptedDetails, terminate: true as const, }; }, }); pi.on("tool_call", (event) => { if (event.toolName !== "bash") return; const command = event.input["command"]; if (typeof command !== "string") return; const matched = matchFixerBashForbiddenLiteral(command); if (matched === undefined) return; return { block: true, reason: fixerBashSeatbeltDenyReason(matched), }; }); pi.on("before_agent_start", (event) => { if (soul === undefined) throw new Error("修内司职分未装载"); if (packetPath === undefined) throw new Error("修内司修理包路径未装载"); // Path delivery only — body is self-fetched; no inline duplicate of flag bytes (#632). const prerequisitesBlock = prerequisitesPath === undefined ? "" : `\n\n\n${prerequisitesPath}\n`; return { systemPrompt: `${event.systemPrompt}\n\n\n${soul}\n\n\n\n${phase ?? ""}\n\n\n\n${packetPath}\n${prerequisitesBlock}`, }; }); } }, armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }) { submissionGate.arm(cwd, parent); }, }; } export function createCoderRoleRuntime( pi: RoleHost, dependencies: CoderRoleDependencies, hostActions: WorkerRoleHostActions, ): WorkerRoleRuntime { let soul: string | undefined; let task: string | undefined; let phase: WorkerPhase | undefined; let binding: CanonicalSkillBinding<"tdd"> | undefined; let tddInvocationInjected = false; let originalRequest: string | undefined; let expansionPending = false; let lifecycleRegistered = false; const submissionGate = createWorkerSubmissionGate(); pi.registerFlag("ak-coder-task", { description: "Markdown task assigned to the coder role", type: "string", }); pi.registerFlag("ak-coder-phase", { description: "Coder phase: plan (inspect and propose an implementation plan; no edits or commits) or apply (execute the approved plan and verify the first implementation)", type: "string", }); return { async activate(ctx) { // Each activation owns its own Skill capture state; prior-session flags must // not authorize a later apply completed (same RoleHost, sequential activate). tddInvocationInjected = false; originalRequest = undefined; expansionPending = false; soul = (await dependencies.loadSoul()).trim(); if (soul.length === 0) throw new Error("Coder soul is empty"); const selectedPhase = pi.getFlag("ak-coder-phase"); if (selectedPhase !== "plan" && selectedPhase !== "apply") { throw new Error( "Coder role requires --ak-coder-phase plan|apply; no other phase is supported", ); } phase = selectedPhase; const taskPath = pi.getFlag("ak-coder-task"); if (typeof taskPath !== "string" || taskPath.trim().length === 0) { throw new Error("Coder role requires --ak-coder-task"); } task = (await dependencies.loadTask(taskPath)).trim(); if (task.length === 0) throw new Error("Coder task is empty"); binding = undefined; if (phase === "apply") { if (dependencies.loadCanonicalSkillBinding === undefined) { throw new Error("Coder canonical Skill binding loader is not configured"); } try { const loaded = await dependencies.loadCanonicalSkillBinding("tdd"); if (loaded.name !== "tdd") { throw new Error( "Canonical Skill binding loader returned ak-cross-m-review for tdd", ); } binding = loaded; } catch (error) { if (ctx === undefined) throw error; hostActions.failInfrastructure(error, ctx); } } if (!lifecycleRegistered) { lifecycleRegistered = true; pi.registerTool({ name: CODER_OUTPUT_TOOL_NAME, label: "将作监输出", description: "提交将作监终局回执;本工具无 escalate 通道。", promptSnippet: "提交将作监终局回执", parameters: coderOutputSchema, async execute(toolCallId: string, parameters: unknown, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: HostContext) { if (task === undefined || phase === undefined) { throw new Error("将作监任务与阶段未装载"); } const output = validateWorkerOutput(parameters, phase, "Coder"); // #836: skill-expansion evidence rejection deleted (陛下「2.4/5 删」). // Skill still ships with the package (ADR 0052); code no longer refuses on it. assertAcceptableThroughHost( submissionGate, workerStatusOf(output), output, hostActions, ctx, toolCallId, ); if (WORKER_DONE_STATUSES.has(workerStatusOf(output))) { await pi.requireGatekeeperPass!({ context: ctx, subject: { kind: "worker_completion" }, ...(_signal === undefined ? {} : { signal: _signal }), hostActions, toolCallId, // #879: this-turn typed payload — identity-bound at submit site. submission: output, }); } const acceptedDetails = output; return { content: [{ type: "text" as const, text: CODER_ACCEPTED_TEXT }], details: acceptedDetails, terminate: true as const, }; }, }); pi.on("input", (event) => { if (phase !== "apply" || tddInvocationInjected) { return { action: "continue" as const }; } tddInvocationInjected = true; expansionPending = true; // Original request via host capability when Pi argv already carries native form; // non-pi keeps plain original bytes (no consumer trim; #822 r3 / reviewer-aligned). // Role never emits or parses `/skill:` (ADR 0082). originalRequest = binding === undefined ? event.text : (pi.capabilities?.skillOriginalRequest?.(binding.name, event.text) ?? event.text); return { action: "continue" as const }; }); pi.on("before_agent_start", (event, ctx) => { if (soul === undefined) throw new Error("将作监职分未装载"); if (phase === "apply") { if (binding === undefined) { hostActions.failInfrastructure( new Error("Coder canonical tdd Skill binding was not initialized"), ctx, ); } if (expansionPending) { expansionPending = false; } } return { systemPrompt: `${event.systemPrompt}\n\n\n${soul}\n\n\n\n${phase ?? ""}\n\n\n\n${task ?? ""}\n`, }; }); } }, armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }) { submissionGate.arm(cwd, parent); }, }; }