import { randomUUID } from "node:crypto";
import { readFileSync, writeSync } from "node:fs";
import { join } from "node:path";
import {
ExplicitInternalActivationError,
isOfficerReviewSeat,
runDirectoryFromHostContext,
type HostContext,
type HostToolResult,
type RoleEnvelopeHost,
type RoleHost,
} from "./host-contracts.ts";
import { Value } from "typebox/value";
import { sitianReport } from "./sitian-facade.ts";
import { createSubmissionLedgerHost, sealAcceptedSubmission } from "./submission-ledger.ts";
import { createCollectorLedger } from "./collector-ledger.ts";
import { activationTraceRecordSchema, namedActivationCause, type ActivationTraceRecord, type ActivationTraceWriter } from "./activation-trace.ts";
import { homeFromRunDirectory } from "./activation-ledger-topology.ts";
import {
durableSessionPointer,
resolveBookKeyFromGit,
} from "./activation-ledger.ts";
import { writeStderrJsonlRecord } from "./stderr-jsonl.ts";
import {
createToolExecutionObservationFace,
systemToolExecutionObservationMonoNow,
writeToolExecutionObservationRecord,
type ToolExecutionObservationWriter,
} from "./tool-execution-observation.ts";
import {
ENGINE_DETOUR_TOOL_NAME,
ENGINE_MODEL_FLAG_NAME,
resolveEngineModel,
resolveEngineName,
} from "./engine-detour.ts";
import { engineSessionMaterialFromOptions } from "./package-resources/engine-material.ts";
import { registerEngineDetourTool } from "./engine-detour-tool.ts";
import { readableGateItem } from "./readable-gate-item.ts";
import { runIdFromRunDirectory } from "./run-terminal-artifacts.ts";
import { createReceiptDeliveryPolicy, NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, RECEIPT_DELIVERY_PROMPT } from "./receipt-delivery-policy.ts";
import type { AnyCanonicalSkillBinding } from "./canonical-skill-binding.ts";
import type { CollectorClock } from "./collector-evidence.ts";
import type { CollectorGitHubTransport } from "./collector-github.ts";
import {
COLLECTOR_REQUIRED_TOOLS,
COLLECTOR_TRANSPORT_FLAGS,
createCollectorRoleRuntime,
type CollectorActivation,
} from "./collector-role.ts";
import type { ComplianceDecision } from "./compliance-transport.ts";
import { createDoctorRoleRuntime } from "./doctor-role.ts";
import {
createNotaryRoleRuntime,
NOTARY_SESSION_BOUND_ENTRY,
projectNotaryBoundFromFlags,
readNotaryTicketFlag,
} from "./notary-role.ts";
import { NOTARY_TICKET_FLAG } from "./notary-contracts.ts";
import {
COUNTERSIGN_TOOL_SPEC,
type CountersignRuntimeDependencies,
} from "./countersign-role.ts";
import { COUNTERSIGN_ACCEPTED_TEXT } from "./countersign-contracts.ts";
import {
GLEANER_LEFT_TOOL_SPEC,
type GleanerLeftRuntimeDependencies,
} from "./gleaner-left-role.ts";
import { GLEANER_LEFT_ACCEPTED_TEXT, GLEANER_LEFT_BASE_FLAG } from "./gleaner-left-contracts.ts";
import {
INSPECTOR_TOOL_SPEC,
type InspectorRuntimeDependencies,
} from "./inspector-role.ts";
import { INSPECTOR_ACCEPTED_TEXT, INSPECTOR_SOURCE_RUN_FLAG } from "./inspector-contracts.ts";
import {
DIARIST_TOOL_SPEC,
type DiaristRuntimeDependencies,
} from "./diarist-role.ts";
import {
SECRETARIAT_OUTPUT_TOOL_SPEC,
SECRETARIAT_SUMMON_COUNTERSIGN_TOOL_SPEC,
projectSecretariatSummonResult,
type SecretariatRuntimeDependencies,
type SecretariatSummonCountersignParameters,
} from "./secretariat-role.ts";
import {
SECRETARIAT_ACCEPTED_TEXT,
SECRETARIAT_OUTPUT_TOOL_NAME,
SECRETARIAT_SUMMON_COUNTERSIGN_TOOL_NAME,
} from "./secretariat-contracts.ts";
import {
DIARIST_ACCEPTED_TEXT,
projectDiaristSessions,
} from "./diarist-contracts.ts";
import { commitDiaristProjection } from "./diarist.ts";
import { TicketProvenanceInputError } from "./ticket-provenance.ts";
import { bindTicketNumberOnRunDirectory } from "./public-cli/invocation.ts";
import {
GATEKEEPER_TOOL_SPEC,
type GatekeeperRuntimeDependencies,
} from "./gatekeeper-role.ts";
import {
NAVIGATOR_TOOL_SPEC,
type NavigatorRuntimeDependencies,
} from "./navigator-role.ts";
import {
AUDITOR_TOOL_SPEC,
type AuditorRuntimeDependencies,
} from "./auditor-role.ts";
import { AUDITOR_ACCEPTED_TEXT } from "./package-contracts/auditor-output.ts";
import { GATEKEEPER_ACCEPTED_TEXT } from "./package-contracts/gatekeeper-output.ts";
import { NAVIGATOR_ACCEPTED_TEXT } from "./package-contracts/navigator-output.ts";
import { formatNavigatorReport, NAVIGATOR_EVENT_TYPE, navigatorSubjectKey, navigatorUnavailableError, subjectPath, type NavigatorAttendance, type NavigatorAttendanceOptions, type NavigatorEvent, type NavigatorPhase, type NavigatorReport, type NavigatorSettlement, type NavigatorSubjectProvenance, type NavigatorTargetRole, type NavigatorWorkContext } from "./navigator-attendance.ts";
import {
buildNavigatorInfrastructureFailureFact,
classifyPackagedRoleTerminalResult,
extractInfrastructureFailureEvidence,
NAVIGATOR_INVOCATION_ENTRY,
resolveLifecycleInvocationPrincipal,
} from "./navigator-invocation-identity.ts";
import { recordTypedProviderHttpStatus } from "./typed-provider-http.ts";
import { NAVIGATOR_POST_ROLE_GRACE_MS, raceNavigatorGrace } from "./public-cli/settlement.ts";
import { PACKAGED_ROLE_REGISTRY, packagedRoleMetadata, packagedRoleOutputTool, packagedRolePhaseFlag, type PackagedRole } from "./packaged-role-registry.ts";
import { isAuditEscalationProjection } from "./audit-escalation.ts";
import {
createJudgeRoleRuntime,
} from "./judge-role.ts";
import {
createReviewerRoleRuntime,
type ReviewerActivation,
type ReviewerAdmittedInputs,
} from "./reviewer-role.ts";
import type { GatekeeperNonPassResult } from "./gatekeeper-role.ts";
/**
* Private transport flag names/definitions for Reviewer admitted inputs.
* Shared activation envelope owns registration and decoding (ADR 0018).
*/
const REVIEWER_TRANSPORT_FLAGS = Object.freeze([
Object.freeze({
name: "ak-review-base",
definition: Object.freeze({
description: "Fixed base revision for the pinned review target",
type: "string" as const,
}),
}),
Object.freeze({
name: "ak-review-lens",
definition: Object.freeze({
description: "Single review lens: completeness or correctness",
type: "string" as const,
}),
}),
Object.freeze({
name: "ak-review-authority-refs",
definition: Object.freeze({
description: "JSON array of durable authority references projected as Skill --authority inputs",
type: "string" as const,
}),
}),
Object.freeze({
name: "ak-review-ticket-number",
definition: Object.freeze({
description: "Typed ticketNumber for Spec self-fetch primary path",
type: "string" as const,
}),
}),
] as const);
/** Notary private transport: optional court-diary ticket (ADR 0018 / 0075 — envelope-owned). */
const NOTARY_TRANSPORT_FLAGS = Object.freeze([
Object.freeze({
name: NOTARY_TICKET_FLAG.name,
definition: NOTARY_TICKET_FLAG.definition,
}),
] as const);
/** Gleaner-left private transport: comparison-base revision (ADR 0018 / #502). */
const GLEANER_LEFT_TRANSPORT_FLAGS = Object.freeze([
Object.freeze({
name: GLEANER_LEFT_BASE_FLAG.name,
definition: GLEANER_LEFT_BASE_FLAG.definition,
}),
] as const);
export const STATION_CHILD_FLAG = Object.freeze({
name: "ak-station-child",
definition: Object.freeze({
description: "Station-child role run (omit automatic navigator attendance; #840)",
type: "boolean" as const,
}),
} as const);
/**
* Decode private transport flags into frozen admitted inputs.
* Envelope-owned; necessary JSON decode only (public --authority-ref owns grammar).
*/
function decodeReviewerAdmittedInputs(getFlag: (name: string) => unknown): ReviewerAdmittedInputs {
let authorityRefs: readonly string[] | undefined;
const rawAuthorityRefs = getFlag("ak-review-authority-refs");
if (rawAuthorityRefs !== undefined) {
if (typeof rawAuthorityRefs !== "string") {
throw new Error("Reviewer authority refs transport error: flag value must be a string");
}
let parsed: unknown;
try {
parsed = JSON.parse(rawAuthorityRefs);
} catch (error) {
throw new Error(
`Reviewer authority refs transport error: JSON decode failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (!Array.isArray(parsed) || parsed.some((ref) => typeof ref !== "string")) {
throw new Error("Reviewer authority refs transport error: expected a JSON array of strings");
}
authorityRefs = Object.freeze(parsed as string[]);
}
let ticketNumber: number | undefined;
const rawTicketNumber = getFlag("ak-review-ticket-number");
// Shape-invalid flag values do not abort: omit typed candidate; branch→commit→degrade continues.
if (typeof rawTicketNumber === "string" && /^[1-9]\d*$/.test(rawTicketNumber)) {
ticketNumber = Number(rawTicketNumber);
}
const baseRevision = getFlag("ak-review-base");
if (typeof baseRevision !== "string" || !baseRevision.trim()) {
throw new Error("Reviewer role requires --ak-review-base");
}
const rawLens = getFlag("ak-review-lens");
if (rawLens !== "completeness" && rawLens !== "correctness") {
throw new Error("Reviewer role requires --ak-review-lens completeness|correctness");
}
return Object.freeze({
baseRevision,
lens: rawLens,
...(authorityRefs === undefined ? {} : { authorityRefs }),
...(ticketNumber === undefined ? {} : { ticketNumber }),
});
}
/**
* Parent system-prompt assembly for the shared activation envelope.
* Verification cadence lives in soul/quality-law only (ADR 0073: no machine copy).
*/
function assembleReviewerParentSystemPrompt(input: {
baseSystemPrompt: string;
soul: string;
}): string {
return [
input.baseSystemPrompt,
"",
"",
input.soul,
"",
].join("\n");
}
import {
CODER_OUTPUT_TOOL_NAME,
createCoderRoleRuntime,
createFixerRoleRuntime,
FIXER_FLAG_DEFINITIONS,
FIXER_OUTPUT_TOOL_NAME,
FIXER_PHASES,
type CoderSkillExpansionEvidenceMissingResult,
} from "./worker-role.ts";
/** One envelope map for Gatekeeper non-pass and other correct submission rejects (#525). */
type SubmissionNonPassResult =
| GatekeeperNonPassResult
| CoderSkillExpansionEvidenceMissingResult;
import { JUDGE_OUTPUT_TOOL_NAME } from "./package-contracts/judge-output.ts";
import { REVIEWER_OUTPUT_TOOL_NAME } from "./package-contracts/reviewer-output.ts";
import { DOCTOR_OUTPUT_TOOL_NAME } from "./doctor-contracts.ts";
import { MERGER_OUTPUT_TOOL_NAME } from "./merger-contracts.ts";
import { createMergerRoleRuntime, type MergerRoleDependencies } from "./merger-role.ts";
export {
buildNavigatorInfrastructureFailureFact,
classifyPackagedRoleTerminalResult,
extractInfrastructureFailureEvidence,
hasNavigatorInfrastructureFailureBase,
isAcceptedPackagedRoleTerminalResult,
isDurablePackagedRoleTerminalResult,
isNavigatorInfrastructureFailureFact,
NAVIGATOR_INFRASTRUCTURE_FAILURE_EVIDENCE_KEYS,
NAVIGATOR_INFRASTRUCTURE_FAILURE_KIND,
type NavigatorInfrastructureFailureFact,
type PackagedRoleTerminalClassification,
} from "./navigator-invocation-identity.ts";
export { activationTraceRecordSchema, namedActivationCause } from "./activation-trace.ts";
export type { ActivationTraceRecord, ActivationTraceWriter } from "./activation-trace.ts";
export {
ActivationGitRepositoryRequiredError,
ActivationLedgerError,
ActivationSessionFileMissingError,
activationBookDirectory,
durableSessionPointer,
resolveActivationLedgerHome,
resolveBookKeyFromGit,
} from "./activation-ledger.ts";
export type {
ActivationSessionManager,
ActivationSessionPointer,
} from "./activation-ledger.ts";
export {
TOOL_EXECUTION_UPDATE_HEARTBEAT,
TOOL_EXECUTION_UPDATE_THROTTLE_MS,
createToolExecutionObservationFace,
isProducingToolUpdate,
systemToolExecutionObservationMonoNow,
toolExecutionObservationRecordSchema,
validateToolExecutionObservationRecord,
writeToolExecutionObservationRecord,
} from "./tool-execution-observation.ts";
export type { ToolExecutionObservationRecord, ToolExecutionObservationWriter } from "./tool-execution-observation.ts";
import {
NOTARY_OUTPUT_TOOL,
INSPECTOR_OUTPUT_TOOL,
GatekeeperDecisionError,
createGatekeeperOutputTool,
runGatekeeper,
gateOfficerForSubject,
} from "./gatekeeper-role.ts";
export {
NOTARY_OUTPUT_TOOL,
INSPECTOR_OUTPUT_TOOL,
GatekeeperDecisionError,
createGatekeeperOutputTool,
runGatekeeper,
gateOfficerForSubject,
};
export type { GatekeeperResult, GatekeeperSubject, GatekeeperNonPassResult, GateOfficer, RunGatekeeperOptions } from "./gatekeeper-role.ts";
import { ParentQueueReaskError } from "./submission-errors.ts";
export {
DOCTOR_EVIDENCE_TOOL_NAME,
DOCTOR_OUTPUT_TOOL_NAME,
} from "./doctor-role.ts";
export type { DoctorCase, DoctorCaseCost, DoctorSubmission, DoctorOutput, DoctorFinding } from "./doctor-contracts.ts";
export { validateDoctorSubmissionShape, validateDoctorOutput, DoctorEvidenceStore } from "./doctor-contracts.ts";
export { loadDoctorCase } from "./doctor-evidence.ts";
export {
JUDGE_OUTPUT_TOOL_NAME,
type JudgeVerdict,
} from "./judge-role.ts";
export { ENGINE_DETOUR_TOOL_NAME, AK_ROLE_ENGINE_ENV } from "./engine-detour.ts";
export {
REVIEWER_OUTPUT_TOOL_NAME,
type ReviewerIntent,
} from "./reviewer-role.ts";
export {
CODER_OUTPUT_TOOL_NAME,
CODER_SKILL_EXPANSION_EVIDENCE_MISSING_CODE,
CoderSkillExpansionEvidenceMissingError,
FIXER_FLAG_DEFINITIONS,
FIXER_OUTPUT_TOOL_NAME,
FIXER_PHASES,
type CoderOutput,
type CoderSkillExpansionEvidenceMissingResult,
type FixerOutput,
type WorkerOutput,
} from "./worker-role.ts";
export { fixerOutputSchema, validateFixerOutput } from "./package-contracts/fixer-output.ts";
export type { FixerBlocker, FixerClassResult, FixerPhase, FixerTestEvidence } from "./package-contracts/fixer-output.ts";
export { fixerPrerequisiteSchema, fixerPrerequisitesSchema, parseFixerPrerequisites, validateFixerPrerequisites } from "./package-contracts/fixer-packet.ts";
export type { FixerInvocationInput, FixerPrerequisite } from "./package-contracts/fixer-packet.ts";
export { AUDIT_ESCALATION_KIND, buildAuditEscalationResult, disposeComplianceDecision, isAuditEscalationResult, projectAuditEscalation } from "./audit-escalation.ts";
export type { AuditEscalationResult, AuditEscalationToolResult, ComplianceDecisionHandlers } from "./audit-escalation.ts";
export {
AUDITOR_SOUL_ROLES,
AK_ROLE_AUDITOR_SUBJECT_ENV,
loadAuditorSoul,
loadAuditorSoulFromSubjectInput,
resolveAuditorSubject,
} from "./auditor-soul.ts";
export type { AuditorSoulRole } from "./auditor-soul.ts";
export { JUDGE_AUDIT_TOOL_NAME, SOUL_AUDIT_TOOL_NAME } from "./judge-auditor.ts";
export { DOCTOR_AUDIT_TOOL_NAME, createPiDoctorAuditor } from "./doctor-auditor.ts";
export type { ComplianceDecision } from "./compliance-transport.ts";
export {
COLLECTOR_OBSERVE_TOOL,
COLLECTOR_OUTPUT_TOOL,
COLLECTOR_READ_TOOL,
COLLECTOR_REQUEST_TOOL,
COLLECTOR_WAIT_TOOL,
} from "./collector-role.ts";
export type { CollectorReceipt } from "./package-contracts/collector-output.ts";
export type { CollectorGitHubTransport } from "./collector-github.ts";
export type { CollectorClock } from "./collector-evidence.ts";
export * from "./navigator-attendance.ts";
export { MERGER_INPUT_FLAG, createMergerRoleRuntime } from "./merger-role.ts";
export { MERGER_OUTPUT_TOOL_NAME, mergerInputSchema, mergerOutputSchema, validateMergerInput, validateMergerOutput } from "./merger-contracts.ts";
export type { MergerInput, MergerMaterial, MergerOutput } from "./merger-contracts.ts";
export { createProductionMergerGitState } from "./merger-git-state.ts";
export type { MergerGitState, ActiveMergerGitState } from "./merger-git-state.ts";
export type { MergerRoleDependencies } from "./merger-role.ts";
type WorkerArmable = {
activate(context?: HostContext): Promise;
armSubmissionGate(cwd: string, parent: { getSessionFile(): string | undefined }): void;
};
type ActivationRuntime = {
event: { reason: string };
context: HostContext;
judge: { activate(): Promise };
fixer: WorkerArmable;
coder: WorkerArmable;
reviewer: {
activate(
context: HostContext,
admitted: ReviewerAdmittedInputs,
): Promise;
};
/** Envelope decodes Reviewer transport flags inside the activation stage. */
decodeReviewerAdmitted(): ReviewerAdmittedInputs;
/** Envelope stores live Reviewer activation for agent_start prompt assembly. */
bindReviewerParent(activation: ReviewerActivation): void;
collector: {
activate(context: HostContext, event: { reason: string }): Promise;
};
doctor: { activate(): Promise };
notary: {
activate(admitted?: import("./notary-role.ts").NotaryAdmittedTicket): Promise;
};
/** Envelope decodes Notary ticket flag inside the activation stage (ADR 0018). */
decodeNotaryAdmitted(): import("./notary-role.ts").NotaryAdmittedTicket | undefined;
countersign: { activate(): Promise };
gleanerLeft: { activate(): Promise };
inspector: { activate(): Promise };
gatekeeper: { activate(): Promise };
navigator: { activate(): Promise };
auditor: { activate(): Promise };
diarist: { activate(): Promise };
secretariat: { activate(): Promise };
merger(): Promise;
};
function activationStage(role: PackagedRole, runtime: ActivationRuntime): { id: string; run(): Promise } {
switch (role) {
case "judge": return { id: "load-and-install", run: async () => runtime.judge.activate() };
case "fixer": return { id: "load-and-install", run: async () => runtime.fixer.activate() };
case "coder": return { id: "load-and-install", run: async () => runtime.coder.activate(runtime.context) };
case "reviewer": return { id: "load-and-install", run: async () => {
const admitted = runtime.decodeReviewerAdmitted();
const activation = await runtime.reviewer.activate(runtime.context, admitted);
runtime.bindReviewerParent(activation);
} };
case "collector": return { id: "load-and-install", run: async () => runtime.collector.activate(runtime.context, runtime.event) };
case "doctor": return { id: "load-and-install", run: async () => runtime.doctor.activate() };
case "notary": return {
id: "load-and-install",
run: async () => {
// Envelope owns ticket flag read (ADR 0018); role receives admitted value only.
await runtime.notary.activate(runtime.decodeNotaryAdmitted());
},
};
case "countersign": return { id: "load-and-install", run: async () => runtime.countersign.activate() };
case "gleaner-left": return { id: "load-and-install", run: async () => runtime.gleanerLeft.activate() };
case "inspector": return { id: "load-and-install", run: async () => runtime.inspector.activate() };
case "gatekeeper": return { id: "load-and-install", run: async () => runtime.gatekeeper.activate() };
case "navigator": return { id: "load-and-install", run: async () => runtime.navigator.activate() };
case "auditor": return { id: "load-and-install", run: async () => runtime.auditor.activate() };
case "diarist": return { id: "load-and-install", run: async () => runtime.diarist.activate() };
case "secretariat": return { id: "load-and-install", run: async () => runtime.secretariat.activate() };
case "merger": return { id: "prepare-git-and-install", run: async () => runtime.merger() };
}
}
function validateActivationTraceRecord(record: unknown): ActivationTraceRecord {
if (!Value.Check(activationTraceRecordSchema, record)) {
throw new TypeError("Activation trace record does not match its closed contract");
}
return record as ActivationTraceRecord;
}
async function emitActivationTrace(
writeTrace: (record: ActivationTraceRecord) => void | Promise,
record: unknown,
): Promise {
await writeTrace(validateActivationTraceRecord(record));
}
async function executeActivationStage(
role: string,
stage: { id: string; run(): Promise },
infrastructure: { clock(): string; writeTrace(record: ActivationTraceRecord): void | Promise },
): Promise {
try {
await stage.run();
} catch (activationError) {
try {
await emitActivationTrace(infrastructure.writeTrace, {
role,
stageId: stage.id,
status: "failed",
timestamp: infrastructure.clock(),
cause: namedActivationCause(activationError),
});
} catch (traceError) {
throw new AggregateError([activationError, traceError], `Activation stage ${stage.id} failed and its failure trace could not be emitted`);
}
throw activationError;
}
}
export function writeActivationTraceRecord(
record: ActivationTraceRecord,
write: typeof writeSync = writeSync,
): void {
writeStderrJsonlRecord(record, write);
}
export class ActivationBarrierError extends Error {
readonly code = "AK_ACTIVATION_NOT_ADMITTED";
constructor(role: unknown) {
super(`Workflow role ${String(role)} activation did not complete`);
this.name = "ActivationBarrierError";
}
}
export const WORKFLOW_ROLES = PACKAGED_ROLE_REGISTRY.map(({ role }) => role) as Array<(typeof PACKAGED_ROLE_REGISTRY)[number]["role"]>;
export const ROLE_FLAG = {
name: "ak-role",
definition: {
description: `Activate a packaged workflow role: ${WORKFLOW_ROLES.slice(0, -1).join(", ")}, or ${WORKFLOW_ROLES.at(-1)}`,
type: "string" as const,
},
} as const;
/** Host-neutral in-process role help for Navigator prepare (Pi and Grok share this). */
export function formatNavigatorRoleHelp(role: NavigatorTargetRole): string {
const metadata = packagedRoleMetadata(role);
const lines = [
`Usage: ak-role ${role}`,
ROLE_FLAG.definition.description,
];
if (metadata?.inputFlag !== undefined) {
lines.push(` --${metadata.inputFlag} ${role} input material`);
}
if (metadata?.phaseFlag !== undefined) {
lines.push(
` --${metadata.phaseFlag} ${role} phase: ${(metadata.phases.filter((p) => p !== null) as string[]).join(" | ")}`,
);
}
lines.push(`Public next-command form: ak-role ${role}`);
return lines.join("\n");
}
type NavigatorAttendanceDependency = Omit &
Partial>;
export type RoleRuntimeDependencies = {
/** Package root for packaged engine-note resolution (#879). */
packageRoot?: string;
/**
* Composition-root adapter table for nested gate officer summons (#969).
* Production leaves unset (default pi + packaged externals). Tests inject
* faux nested hosts so prepareRoleEnvelope.requireGatekeeperPass is bitten
* without reimplementing the envelope summon closure.
*/
hostAdapters?: readonly import("./public-cli/role-turn-host-resolution.ts").NamedRoleTurnHostAdapter[];
/** Non-identity opening materials delivered in the ordinary role brief. */
loadRoleReferenceMaterials?(role: PackagedRole): Promise;
loadJudgeSoul(): Promise;
loadFixerSoul?(): Promise;
loadFixPacket?(path: string): Promise;
loadCoderSoul?(): Promise;
loadCoderTask?(path: string): Promise;
loadReviewerSoul?(): Promise;
loadCollectorSoul?(): Promise;
/** #677: optional packaged seed for first-use general bot handbook. */
loadCollectorHandbookSeed?(): Promise;
createCollectorTransport?(): CollectorGitHubTransport;
loadDoctorSoul?(): Promise;
loadNotarySoul?(): Promise;
loadNotarySourceRun?(path: string): Promise;
loadCountersignSoul?(): Promise;
loadGleanerLeftSoul?(): Promise;
loadInspectorSoul?(): Promise;
loadGatekeeperSoul?(): Promise;
loadNavigatorSoul?(): Promise;
loadAuditorSoul?(): Promise;
loadDiaristSoul?(): Promise;
loadSecretariatSoul?(): Promise;
loadDoctorCase?(path: string): Promise;
loadMergerSoul?(): Promise;
loadMergerInput?(path: string): Promise;
auditDoctorCompliance?(options: { context: HostContext; signal?: AbortSignal }): Promise;
createCollectorClock?(): CollectorClock;
createNavigatorAttendance?(options: { context: HostContext; role: string; phase: NavigatorPhase; subjectKey: string; subject: string; authority: string; contextError?: unknown; invocationId: string; onEvent: (event: import("./navigator-attendance.ts").NavigatorEvent, report: import("./navigator-attendance.ts").NavigatorReport) => void | Promise }): NavigatorAttendanceDependency | Promise;
loadNavigatorWorkContext?(options: { context: HostContext; role: string; phase: NavigatorPhase; getFlag?: (name: string) => unknown }): Promise;
loadCanonicalSkillBinding?(
name: "tdd" | "ak-cross-m-review",
): Promise;
activationClock?(): string;
activationTraceWriter?: (record: ActivationTraceRecord) => void | Promise;
/** Wall-clock ISO timestamps for tool-execution observation records; defaults to activationClock/Date. */
toolExecutionObservationClock?(): string;
/** Monotonic ms clock for update throttling; defaults to performance.now (not Date.now). */
toolExecutionObservationMonoNow?(): number;
toolExecutionObservationWriter?: ToolExecutionObservationWriter;
};
function abortContext(ctx: { abort(): void }): void {
ctx.abort();
}
function failInfrastructure(error: unknown, ctx: { mode: string; abort(): void }): never {
abortContext(ctx);
if (ctx.mode === "print" || ctx.mode === "json") process.exitCode = 1;
throw error;
}
/**
* Envelope-owned pending infrastructure failure: closed fact + original Error evidence.
* tool_result projects once from this record; settlement consumes durable details as-is.
*/
type PendingInfrastructureFailure = {
readonly details: Record;
};
function buildPendingInfrastructureFailure(error: unknown): PendingInfrastructureFailure {
return {
details: {
...buildNavigatorInfrastructureFailureFact(),
...extractInfrastructureFailureEvidence(error),
},
};
}
function navigatorPhase(roleHost: RoleHost, role: string): NavigatorPhase {
const metadata = packagedRoleMetadata(role);
if (metadata === undefined || metadata.phases[0] === null) return null;
const phaseFlag = packagedRolePhaseFlag(role);
const requested = phaseFlag === undefined ? undefined : roleHost.getFlag(phaseFlag);
return requested === "apply" ? "apply" : "plan";
}
function navigatorOutputTool(role: string): string | undefined {
return packagedRoleOutputTool(role);
}
export function publicNavigatorSettlement(role: string, phase: NavigatorPhase, event: { toolName: string; isError?: unknown; details: unknown }): NavigatorSettlement | undefined {
// One shared classifier owns terminal discriminant (lifecycle + settlement + extractors).
if (event.toolName !== navigatorOutputTool(role)) return undefined;
const classification = classifyPackagedRoleTerminalResult(event);
if (classification.kind === "nonterminal") return undefined;
if (classification.kind === "infrastructure") {
return { kind: "role_infrastructure_failure", role, phase };
}
// accepted/human — project role/phase status; classifier already rejected infra/contradiction.
const details = typeof event.details === "object" && event.details !== null && !Array.isArray(event.details)
? event.details as Record
: {};
// Live Navigator consumes only the audit-owned projection; persisted/replayed
// records are re-authenticated by settlement against retained audit evidence.
if (isAuditEscalationProjection(event.details)) {
return { kind: "human_decision", role, phase, status: "audit_escalation" };
}
const status = typeof details.status === "string"
? details.status
: typeof details.judgeStatus === "string" ? details.judgeStatus
: typeof details.countersignStatus === "string" ? details.countersignStatus
: typeof details.secretariatStatus === "string" ? details.secretariatStatus : undefined;
if (status !== undefined && status === "escalate") {
return { kind: "human_decision", role, phase, status };
}
return { kind: "accepted", role, phase, ...(status === undefined ? {} : { status }) };
}
export async function projectClosedSubmissionLifecycle(
closed: import("./submission-ledger.ts").ClosedSubmission,
context: HostContext,
phase: NavigatorPhase,
recordAccepted: () => void,
settle: (settlement: NavigatorSettlement | undefined) => Promise,
): Promise {
recordAccepted();
const closure = {
toolName: navigatorOutputTool(closed.role)!,
isError: false,
details: closed.accepted,
};
context.sessionManager.appendCustomEntry?.("ak-role-submission-closure", closure);
await settle(publicNavigatorSettlement(closed.role, phase, closure));
}
/**
* Optional pre-accept hook on the shared filed-officer envelope (ADR 0075).
* May return a details projection (envelope-owned machine facts recorded next to
* the submitted parameters); undefined keeps the parameters as submitted.
*/
type FiledOfficerBeforeAccept = (input: {
readonly toolCallId: string;
readonly parameters: unknown;
readonly signal: AbortSignal | undefined;
readonly ctx: HostContext;
}) => Promise;
/**
* Shared registration envelope for filed officers (ADR 0018 / #572):
* activate, tool register, before_agent_start prompt, inventory check.
* Role module keeps label/soul/spec shape only; sole-final barrier is ledger-owned.
* Optional beforeAccept is the sole extension seam (e.g. countersign Notary gate).
*/
function createFiledOfficerRuntime(
roleHost: RoleHost,
spec: {
role: string;
tool: { name: string; label: string; description: string; promptSnippet: string; parameters: unknown };
acceptedText: string;
soulTag: string;
beforeAccept?: FiledOfficerBeforeAccept;
},
dependencies: { loadSoul(): Promise },
) {
let soul: string | undefined;
let registered = false;
return {
async activate() {
const loaded = (await dependencies.loadSoul()).trim();
if (loaded.length === 0) throw new Error(`${spec.role} soul is empty`);
soul = loaded;
if (!registered) {
registered = true;
roleHost.registerTool({
name: spec.tool.name,
label: spec.tool.label,
description: spec.tool.description,
promptSnippet: spec.tool.promptSnippet,
parameters: spec.tool.parameters as never,
async execute(toolCallId, parameters, signal, _onUpdate, ctx): Promise> {
if (soul === undefined) throw new Error(`${spec.role} 职分未装载`);
const projected =
spec.beforeAccept === undefined
? undefined
: await spec.beforeAccept({ toolCallId, parameters, signal, ctx });
// Accept-as-is + terminate only. Shape is not an admission gate
// (第 0 条 / ADR 0055); sole-final barrier is ledger-owned (#575).
return {
content: [{ type: "text" as const, text: spec.acceptedText }],
details: projected === undefined ? parameters : projected,
terminate: true as const,
};
},
});
roleHost.on("before_agent_start", (event) => {
if (soul === undefined) throw new Error(`${spec.role} 职分未装载`);
const tail = `\n\n<${spec.soulTag}_soul>\n${soul}\n${spec.soulTag}_soul>`;
return { systemPrompt: `${event.systemPrompt}${tail}` };
});
}
const all = roleHost.getAllTools().map((tool) => tool.name);
if (all.filter((name) => name === spec.tool.name).length !== 1) {
throw new Error(`${spec.role} required tool collision or missing: ${spec.tool.name}`);
}
},
};
}
export function createGleanerLeftRoleRuntime(
roleHost: RoleHost,
dependencies: GleanerLeftRuntimeDependencies,
) {
return createFiledOfficerRuntime(
roleHost,
{
role: "gleaner-left",
tool: GLEANER_LEFT_TOOL_SPEC,
acceptedText: GLEANER_LEFT_ACCEPTED_TEXT,
soulTag: "gleaner-left",
},
dependencies,
);
}
function decodeGleanerLeftBase(getFlag: (name: string) => unknown): string {
const baseRevision = getFlag(GLEANER_LEFT_BASE_FLAG.name);
if (typeof baseRevision !== "string" || !baseRevision.trim()) {
throw new Error("Gleaner-left role requires --ak-gleaner-left-base");
}
return baseRevision;
}
export function createInspectorRoleRuntime(
roleHost: RoleHost,
dependencies: InspectorRuntimeDependencies,
) {
return createFiledOfficerRuntime(
roleHost,
{
role: "inspector",
tool: INSPECTOR_TOOL_SPEC,
acceptedText: INSPECTOR_ACCEPTED_TEXT,
soulTag: "inspector",
},
dependencies,
);
}
/** #639: direct Gatekeeper public seat on the shared filed-officer envelope. */
export function createGatekeeperRoleRuntime(
roleHost: RoleHost,
dependencies: GatekeeperRuntimeDependencies,
) {
return createFiledOfficerRuntime(
roleHost,
{
role: "gatekeeper",
tool: GATEKEEPER_TOOL_SPEC,
acceptedText: GATEKEEPER_ACCEPTED_TEXT,
soulTag: "gatekeeper",
},
dependencies,
);
}
/** #639: direct Navigator public seat on the shared filed-officer envelope. */
export function createNavigatorRoleRuntime(
roleHost: RoleHost,
dependencies: NavigatorRuntimeDependencies,
) {
return createFiledOfficerRuntime(
roleHost,
{
role: "navigator",
tool: NAVIGATOR_TOOL_SPEC,
acceptedText: NAVIGATOR_ACCEPTED_TEXT,
soulTag: "navigator",
},
dependencies,
);
}
/** #675: public 审刑院 seat on the shared filed-officer envelope. */
export function createAuditorRoleRuntime(
roleHost: RoleHost,
dependencies: AuditorRuntimeDependencies,
) {
const base = createFiledOfficerRuntime(
roleHost,
{
role: "auditor",
tool: AUDITOR_TOOL_SPEC,
acceptedText: AUDITOR_ACCEPTED_TEXT,
soulTag: "auditor",
},
dependencies,
);
return {
async activate() {
await base.activate();
// Same tools whether nested or direct (#675): dossier tool always registered.
// Source run: only the shared --source-run input face (never own-run fallback).
const { createAuditorDossierTool, AUDITOR_DOSSIER_TOOL_NAME } =
await import("./auditor-dossier-tool.ts");
const { AK_ROLE_AUDITOR_SOURCE_RUN_ENV } = await import("./auditor-soul.ts");
const already = roleHost.getAllTools().some((tool) => tool.name === AUDITOR_DOSSIER_TOOL_NAME);
if (already) return;
const sourceRun =
typeof process.env[AK_ROLE_AUDITOR_SOURCE_RUN_ENV] === "string"
&& process.env[AK_ROLE_AUDITOR_SOURCE_RUN_ENV].trim() !== ""
? process.env[AK_ROLE_AUDITOR_SOURCE_RUN_ENV].trim()
: undefined;
roleHost.registerTool(createAuditorDossierTool(sourceRun) as never);
},
};
}
/**
* LLM typed court-target assertion from diarist output (ADR 0075 / #779).
* null/absent = true-unbound; positive safe integer = ticket N.
* Shape-only read of the typed field — not content judgment of free text.
* Any other shape fails honestly — never washes into unbound.
*/
function readDiaristTicketAssertion(
submitted: Record | undefined,
): { kind: "true-unbound" } | { kind: "ticket"; ticketNumber: number } | { kind: "invalid" } {
if (submitted === undefined || !("ticketNumber" in submitted)) {
return { kind: "true-unbound" };
}
const raw = submitted.ticketNumber;
if (raw === null) return { kind: "true-unbound" };
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 1) {
return { kind: "ticket", ticketNumber: raw };
}
if (typeof raw === "string" && /^[1-9]\d*$/.test(raw)) {
const n = Number(raw);
if (Number.isSafeInteger(n) && n >= 1) {
return { kind: "ticket", ticketNumber: n };
}
}
return { kind: "invalid" };
}
/** Shared durable coordinates for role tools that summon another public role. */
function readRoleRunCoordinates(ctx: HostContext, label: string): {
readonly runDirectory: string;
readonly projectRoot: string;
readonly home: string;
readonly admitted: Record;
} {
const runDirectory = runDirectoryFromHostContext(ctx);
if (runDirectory === undefined) throw new Error(`${label} requires AK_ROLE_RUN_DIR`);
const admittedPath = join(runDirectory, "admitted-request.json");
const admitted = JSON.parse(readFileSync(admittedPath, "utf8")) as Record;
if (typeof admitted.projectRoot !== "string" || admitted.projectRoot.trim() === "") {
throw new Error(`${label} admitted-request missing projectRoot (${admittedPath})`);
}
return {
runDirectory,
projectRoot: admitted.projectRoot,
home: homeFromRunDirectory(runDirectory),
admitted,
};
}
/** Run coordinates + optional pre-bound ticket from durable pages (#779). */
function readDiaristRunCoordinates(ctx: HostContext): {
readonly runDirectory: string;
readonly projectRoot: string;
readonly home: string;
readonly boundTicketNumber?: number;
} {
const coordinates = readRoleRunCoordinates(ctx, "diarist accept");
const bound =
typeof coordinates.admitted.ticketNumber === "number" &&
Number.isSafeInteger(coordinates.admitted.ticketNumber) &&
coordinates.admitted.ticketNumber >= 1
? coordinates.admitted.ticketNumber
: undefined;
return {
runDirectory: coordinates.runDirectory,
projectRoot: coordinates.projectRoot,
home: coordinates.home,
...(bound === undefined ? {} : { boundTicketNumber: bound }),
};
}
/** Plain-language re-ask when diarist bounds cannot be used (#901 / reask-not-explode). */
const DIARIST_BOUNDS_REASK =
"边界无法使用。请重交 sessions:每卷 path + ranges,每端以原生 id 或本轮行号二选一指名。" as const;
/**
* #708 / #779 / #901: 起居郎 public seat on the shared filed-officer envelope.
* LLM judges ticket + dialogue bounds; mechanical layer reprojects the unique
* records.jsonl. Unusable bounds reask via ParentQueueReaskError. Machine
* facts never come from model self-report (锚定宪法).
*/
export function createDiaristRoleRuntime(
roleHost: RoleHost,
dependencies: DiaristRuntimeDependencies,
) {
return createFiledOfficerRuntime(
roleHost,
{
role: "diarist",
tool: DIARIST_TOOL_SPEC,
acceptedText: DIARIST_ACCEPTED_TEXT,
soulTag: "diarist",
beforeAccept: async ({ parameters, ctx }) => {
const submitted =
parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)
? (parameters as Record)
: undefined;
const assertion = readDiaristTicketAssertion(submitted);
const coords = readDiaristRunCoordinates(ctx);
// #836 7.3: pre-bound ticket is material for the LLM, not an override.
const ticketNumber = assertion.kind === "ticket" ? assertion.ticketNumber : undefined;
if (ticketNumber !== undefined) {
if (coords.boundTicketNumber === undefined) {
await bindTicketNumberOnRunDirectory(coords.runDirectory, ticketNumber);
}
const sessions = projectDiaristSessions(parameters);
if (sessions === undefined) {
throw new ParentQueueReaskError(DIARIST_BOUNDS_REASK);
}
let facts;
try {
facts = await commitDiaristProjection({
ticketNumber,
cwd: coords.projectRoot,
home: coords.home,
sessions,
});
} catch (error) {
// Bound/session input failures → reask via typed identity (not message prefix).
// Unexpected infrastructure keeps its own identity (do not wash).
if (error instanceof ParentQueueReaskError) throw error;
if (error instanceof TicketProvenanceInputError) {
throw new ParentQueueReaskError(
`${DIARIST_BOUNDS_REASK}\n${error.message}`,
);
}
throw error;
}
}
return parameters;
},
},
dependencies,
);
}
/** Countersign status words the queue reads (#753). */
const COUNTERSIGN_QUEUE_STATUSES = new Set(["converged", "continue", "escalate"]);
/**
* Plain-language re-ask when countersignStatus is not a known queue word.
* Back to countersign itself — notary is not summoned (#753).
*/
const COUNTERSIGN_STATUS_REASK =
"countersignStatus 不是 converged、continue、escalate 三态之一。请重新交卷,status 写明其一。" as const;
/**
* #969 authorized non-pi hosts: Secretariat converged is a candidate ticket —
* AK-side submission gate summons 给事中. pi keeps mid-turn summon tool only.
*/
const SECRETARIAT_SUBMISSION_GATE_HOSTS = new Set(["codex", "claude", "grok-build"]);
/** Secretariat status words the non-pi submission gate reads (#969). */
const SECRETARIAT_QUEUE_STATUSES = new Set(["converged", "escalate"]);
/**
* Plain-language re-ask when non-pi secretariatStatus is not a known queue word.
* Back to secretariat itself — 给事中 is not summoned (#969 / ADR 0055).
*/
const SECRETARIAT_STATUS_REASK =
"secretariatStatus 不是 converged、escalate 之一。请重新交卷,status 写明其一。" as const;
/**
* #924 Secretariat on the shared filed-officer envelope + non-terminating
* summon-countersign tool (auditor dossier extension pattern).
* Nested countersign lifecycle stays on summonPublicRole (ADR 0018).
* #924 / 第 0 条 (pi): output tool records the receipt as submitted — no status
* shape/value reject or reask; legality is content-layer / downstream.
* #969 (codex/claude/grok-build): converged arms 既有交卷闸 → 给事中; escalate
* skips the gate; other values reask secretariat (ADR 0055).
*/
export function createSecretariatRoleRuntime(
roleHost: RoleHost,
dependencies: SecretariatRuntimeDependencies,
hostActions?: import("./host-contracts.ts").HostGatekeeperActions,
) {
let parentInstruction = "";
// #969: non-pi submission gate only — pi mid-turn summon path stays untouched.
const beforeAccept: FiledOfficerBeforeAccept | undefined =
hostActions !== undefined && roleHost.requireGatekeeperPass !== undefined
? async ({ toolCallId, parameters, signal, ctx }) => {
const host =
typeof ctx.host === "string" && ctx.host.trim() !== ""
? ctx.host.trim()
: undefined;
if (host === undefined || !SECRETARIAT_SUBMISSION_GATE_HOSTS.has(host)) {
return undefined;
}
const record =
parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)
? (parameters as Record)
: undefined;
const status =
record !== undefined && typeof record.secretariatStatus === "string"
? record.secretariatStatus
: undefined;
if (status === undefined || !SECRETARIAT_QUEUE_STATUSES.has(status)) {
throw new ParentQueueReaskError(SECRETARIAT_STATUS_REASK);
}
if (status === "escalate") {
// Parent escalate → throw to caller as-is; 给事中 does not attend (#969).
return undefined;
}
try {
const pass = await roleHost.requireGatekeeperPass!({
context: ctx,
subject: { kind: "secretariat_verdict" },
...(signal === undefined ? {} : { signal }),
hostActions,
toolCallId,
// #879: this-turn typed payload — identity-bound at submit site.
submission: parameters,
});
// #969: book 给事中 署 snapshot for seat settlement projection (receipt + runId).
// Ledger accepted stays LLM params (#836); public terminal reads this entry.
if (
pass !== undefined
&& pass !== null
&& typeof pass === "object"
&& "receipt" in pass
) {
const {
SECRETARIAT_GATE_OFFICER_ENTRY_TYPE,
} = await import("./secretariat-contracts.ts");
const runId =
typeof pass.runId === "string" && pass.runId.trim() !== ""
? pass.runId
: undefined;
ctx.sessionManager.appendCustomEntry?.(SECRETARIAT_GATE_OFFICER_ENTRY_TYPE, {
officer: "countersign",
receipt: pass.receipt,
...(runId === undefined ? {} : { runId }),
});
}
return undefined;
} catch (error) {
// #969: 给事中上呈 ends parent with officer receipt (no retry / 不擅改).
if (
error instanceof GatekeeperDecisionError
&& error.result.status === "escalate"
) {
const receipt = error.result.receipt;
const receiptRecord =
receipt !== null && typeof receipt === "object" && !Array.isArray(receipt)
? (receipt as Record)
: undefined;
const runId =
typeof error.result.runId === "string" && error.result.runId.trim() !== ""
? error.result.runId
: undefined;
// Durable on every host: envelope persists custom entries only
// (toolResult rows are memory-only on headless/ACP — #617/#959).
const {
SECRETARIAT_GATE_OFFICER_ENTRY_TYPE,
} = await import("./secretariat-contracts.ts");
ctx.sessionManager.appendCustomEntry?.(SECRETARIAT_GATE_OFFICER_ENTRY_TYPE, {
officer: "countersign",
receipt,
...(runId === undefined ? {} : { runId }),
});
const { buildAuditEscalationResult } = await import("./audit-escalation.ts");
return buildAuditEscalationResult(
{
status: "escalate",
officer: "countersign",
...(receiptRecord !== undefined
&& Object.prototype.hasOwnProperty.call(receiptRecord, "decisionGate")
? { decisionGate: receiptRecord.decisionGate }
: {}),
},
receipt,
);
}
throw error;
}
}
: undefined;
const base = createFiledOfficerRuntime(
roleHost,
{
role: "secretariat",
tool: SECRETARIAT_OUTPUT_TOOL_SPEC,
acceptedText: SECRETARIAT_ACCEPTED_TEXT,
soulTag: "secretariat",
...(beforeAccept === undefined ? {} : { beforeAccept }),
},
dependencies,
);
let summonRegistered = false;
return {
async activate() {
await base.activate();
if (!summonRegistered) {
summonRegistered = true;
// Capture parent prompt for default summon instruction (envelope already
// owns soul inject; this handler only records the assignment text).
roleHost.on("before_agent_start", (event) => {
if (typeof event.prompt === "string" && event.prompt.trim() !== "") {
parentInstruction = event.prompt;
}
});
roleHost.registerTool({
name: SECRETARIAT_SUMMON_COUNTERSIGN_TOOL_NAME,
label: SECRETARIAT_SUMMON_COUNTERSIGN_TOOL_SPEC.label,
description: SECRETARIAT_SUMMON_COUNTERSIGN_TOOL_SPEC.description,
promptSnippet: SECRETARIAT_SUMMON_COUNTERSIGN_TOOL_SPEC.promptSnippet,
parameters: SECRETARIAT_SUMMON_COUNTERSIGN_TOOL_SPEC.parameters as never,
async execute(
_toolCallId,
parameters: SecretariatSummonCountersignParameters,
signal,
_onUpdate,
ctx,
): Promise> {
const fromArgs =
typeof parameters?.instruction === "string"
? parameters.instruction.trim()
: "";
// #924: instruction comes from tool args or parent prompt only — no
// unauthored default summons prose (票号写在传召里; no fabricated fallback).
const instruction =
fromArgs !== "" ? fromArgs : parentInstruction.trim();
const correlationId = (() => {
const runDirectory = runDirectoryFromHostContext(ctx);
if (runDirectory === undefined) return undefined;
return runIdFromRunDirectory(runDirectory);
})();
const summon = dependencies.summonCountersign;
const summoned = summon === undefined
? await (async () => {
// #987 Result 7 / #747: mid-turn summon resumes by parentRunPath
// (secretariat run dir), same key as summonGateOfficer countersign.
// Board ticket is bind-only — never the resume lookup.
const coordinates = readRoleRunCoordinates(ctx, "secretariat summons");
const { summonPublicRole } = await import("./public-role-summons.ts");
const { readBoardTicketNumber } = await import("./run-ticket-number.ts");
const parentTicket = await readBoardTicketNumber(coordinates.runDirectory);
return summonPublicRole({
role: "countersign",
argv: ["--project", coordinates.projectRoot, "--", instruction],
cwd: coordinates.projectRoot,
home: coordinates.home,
parentRunPath: coordinates.runDirectory,
...(parentTicket === undefined
? {}
: { boundTicketNumber: parentTicket }),
...(signal === undefined ? {} : { signal }),
...(correlationId === undefined ? {} : { correlationId }),
...(dependencies.packageRoot === undefined
? {}
: { packageRoot: dependencies.packageRoot }),
...(dependencies.hostAdapters === undefined
? {}
: { hostAdapters: dependencies.hostAdapters }),
});
})()
: await summon({
instruction,
cwd: ctx.cwd,
...(signal === undefined ? {} : { signal }),
...(correlationId === undefined ? {} : { correlationId }),
...(dependencies.home === undefined ? {} : { home: dependencies.home }),
...(dependencies.packageRoot === undefined
? {}
: { packageRoot: dependencies.packageRoot }),
});
const details = projectSecretariatSummonResult(summoned);
// #953 / #775: parent-visible text from typed details via readableGateItem
// sole source — payloads + receipt (latest) already carry 传话 facts.
return {
content: [
{
type: "text" as const,
text: readableGateItem(details),
},
],
details,
};
},
});
}
const packageRequired = [
SECRETARIAT_OUTPUT_TOOL_NAME,
SECRETARIAT_SUMMON_COUNTERSIGN_TOOL_NAME,
] as const;
const all = roleHost.getAllTools().map((tool) => tool.name);
for (const name of packageRequired) {
if (all.filter((item) => item === name).length !== 1) {
throw new Error(`secretariat required tool collision or missing: ${name}`);
}
}
// Host-neutral minimum package surface (#924 G6): declare package tools via
// setActiveTools without hardcoding host builtin names. Preserve any host
// surface already visible on getAllTools/getActiveTools so body rewrite
// (public gh path) stays reachable on hosts that expose it.
const priorActive = roleHost.getActiveTools();
const hostSurface = priorActive.length > 0 ? priorActive : all;
const nextActive = [
...new Set([...hostSurface, ...packageRequired]),
];
roleHost.setActiveTools(nextActive);
const active = roleHost.getActiveTools();
for (const name of packageRequired) {
if (!active.includes(name)) {
throw new Error(`secretariat failed to activate required tool ${name}`);
}
}
},
};
}
export function createCountersignRoleRuntime(
roleHost: RoleHost,
dependencies: CountersignRuntimeDependencies,
hostActions?: import("./host-contracts.ts").HostGatekeeperActions,
) {
// Notary inner gate difference only — lifecycle stays on the shared envelope.
// Pointer-only summons: officer self-fetches from run dossier (#632 / ADR 0079).
// #753 queue: read countersignStatus only — escalate skips gate (thrown to caller);
// unreadable status returns to countersign; else notary inner gate.
const beforeAccept: FiledOfficerBeforeAccept | undefined =
hostActions !== undefined && roleHost.requireGatekeeperPass !== undefined
? async ({ toolCallId, parameters, signal, ctx }) => {
const record =
parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)
? (parameters as Record)
: undefined;
const status =
record !== undefined && typeof record.countersignStatus === "string"
? record.countersignStatus
: undefined;
if (status === undefined || !COUNTERSIGN_QUEUE_STATUSES.has(status)) {
// Parent status unreadable → back to countersign itself; do not summon notary,
// do not forge an officer bounce face (#753).
throw new ParentQueueReaskError(COUNTERSIGN_STATUS_REASK);
}
if (status === "escalate") {
// Parent escalate → throw to caller as-is; notary does not attend (#753).
return undefined;
}
await roleHost.requireGatekeeperPass!({
context: ctx,
subject: { kind: "countersign_verdict" },
...(signal === undefined ? {} : { signal }),
hostActions,
toolCallId,
// #879: this-turn typed payload — identity-bound at submit site.
submission: parameters,
});
}
: undefined;
return createFiledOfficerRuntime(
roleHost,
{
role: "countersign",
tool: COUNTERSIGN_TOOL_SPEC,
acceptedText: COUNTERSIGN_ACCEPTED_TEXT,
soulTag: "countersign",
...(beforeAccept === undefined ? {} : { beforeAccept }),
},
dependencies,
);
}
/**
* #959: last assistant text parts as navigator prose exit.
* Tool-call-only messages yield undefined — those already ride the tool path.
*/
function lastAssistantProse(
messages: readonly { role: string; content?: readonly { type: string; text?: string }[] }[],
): string | undefined {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
const texts: string[] = [];
for (const part of message.content) {
if (part.type === "text" && typeof part.text === "string" && part.text.length > 0) {
texts.push(part.text);
}
}
if (texts.length === 0) continue;
return texts.join("");
}
return undefined;
}
export function createRoleRuntimeExtension(
dependencies: RoleRuntimeDependencies,
): (envelopeHost: RoleEnvelopeHost) => void {
return (envelopeHost) => {
let projectClosedSubmission: (closed: import("./submission-ledger.ts").ClosedSubmission, context: HostContext) => Promise = async () => {
throw new Error("角色终局投射接缝尚未初始化");
};
const roleHost = createSubmissionLedgerHost(
envelopeHost.host,
new Map(PACKAGED_ROLE_REGISTRY.map(({ role, outputTool }) => [outputTool, role])),
failInfrastructure,
async (closed, context) => projectClosedSubmission(closed, context),
);
roleHost.registerFlag(ROLE_FLAG.name, ROLE_FLAG.definition);
// Reviewer transport flags: shared envelope owns registration (ADR 0018).
for (const flag of REVIEWER_TRANSPORT_FLAGS) {
roleHost.registerFlag(flag.name, flag.definition);
}
for (const flag of NOTARY_TRANSPORT_FLAGS) {
roleHost.registerFlag(flag.name, flag.definition);
}
roleHost.registerFlag(
INSPECTOR_SOURCE_RUN_FLAG.name,
INSPECTOR_SOURCE_RUN_FLAG.definition,
);
for (const flag of GLEANER_LEFT_TRANSPORT_FLAGS) {
roleHost.registerFlag(flag.name, flag.definition);
}
// Collector transport flags: shared envelope owns registration (ADR 0018 / #676 E).
for (const flag of COLLECTOR_TRANSPORT_FLAGS) {
roleHost.registerFlag(flag.name, flag.definition);
}
// Station-child identity (#840): omit navigator attendance. One flag.
roleHost.registerFlag(STATION_CHILD_FLAG.name, STATION_CHILD_FLAG.definition);
// Register model only. Pi never sets ak-engine — resolveEngineName must
// fall through to child-process env. An empty default would block that.
roleHost.registerFlag(ENGINE_MODEL_FLAG_NAME, {
description: "本次劳务引擎模型",
type: "string",
default: "",
});
let admitted = false;
let selectedRole: PackagedRole | undefined;
let roleReferenceMaterials = "";
/** Live Reviewer parent activation for envelope agent_start prompt assembly. */
let activeReviewerParent: ReviewerActivation | undefined;
/** Envelope-owned Reviewer Skill expansion state (ADR 0018 — not a role-module facade). */
let reviewerOriginalRequest: string | undefined;
let reviewerExpansionCaptured = false;
let navigatorAttendance: NavigatorAttendanceDependency | undefined;
// #351: session-lifecycle owner for periodic OAuth refresh (orthogonal to role admission).
let pendingNavigatorPresentation: { event: import("./navigator-attendance.ts").NavigatorEvent; report: import("./navigator-attendance.ts").NavigatorReport } | undefined;
let pendingNavigatorSettlement: Promise | undefined;
let navigatorWorkContext: NavigatorWorkContext | undefined;
let navigatorSessionParent: string | undefined;
let navigatorCwd: string | undefined;
/** toolCallId → fact+evidence; one-shot projected onto durable tool_result (#475). */
const pendingInfrastructureFailures = new Map();
// Envelope-owned execute→tool_result bridge for submission non-pass (ADR 0018 / #525).
const pendingSubmissionNonPassByToolCallId = new Map();
let engineDetourRegistered = false;
// #288 primary-session thin adapter. The policy is the sole budget owner;
// terminating-tool rejections and mechanical delivery requests share two turns.
let receiptDelivery = createReceiptDeliveryPolicy();
let noReceiptRecorded = false;
// Public-run fetch observation (in-process-session statusAwareFetch face).
let priorFetch: typeof globalThis.fetch | undefined;
let fetchWrapped = false;
/** Envelope-owned: abort/teardown attendance without re-blocking the parent court (#959). */
const disposeNavigatorAttendanceNonBlocking = (
attendance: NavigatorAttendanceDependency | undefined,
): void => {
if (attendance === undefined) return;
const recordDisposeFailure = (error: unknown): void => {
const diagnostic = error instanceof Error ? error.message : String(error);
try {
sitianReport({
level: "event",
kind: "navigator-dispose-failure",
cwd: navigatorCwd,
sessionParent: navigatorSessionParent,
payload: { diagnostic },
source: "role-runtime",
});
} catch (recordError) {
try {
envelopeHost.appendEntry?.("ak-navigator-dispose-failure", {
diagnostic,
recordFailure: recordError instanceof Error ? recordError.message : String(recordError),
});
} catch {
// Failure already diagnosed; recording must not create unhandled rejection (#959).
}
}
};
// Evaluate dispose inside try: Promise.resolve(dispose()) throws sync before .then attaches.
let pending: void | Promise;
try {
pending = attendance.dispose();
} catch (error) {
recordDisposeFailure(error);
return;
}
void Promise.resolve(pending).then(undefined, recordDisposeFailure);
};
const settleNavigatorProjection = async (settlement: NavigatorSettlement | undefined) => {
const attendance = navigatorAttendance;
if (settlement === undefined || attendance === undefined) return;
const workContext = navigatorWorkContext;
const pending = (async () => {
// ADR 0052 / #959: every settlement that starts a post-role feed host round
// (accepted, human_decision, role_infrastructure_failure) shares one grace
// and the same honest unavailable projection — no unbounded parallel branch.
const settlePromise = attendance.settle(settlement);
// Attach catch immediately so a late rejection after grace timeout cannot
// surface as unhandledRejection / stale-ctx after session dispose (#675).
void settlePromise.catch(() => undefined);
const raced = await raceNavigatorGrace(settlePromise, NAVIGATOR_POST_ROLE_GRACE_MS);
if (raced.status !== "timeout") return;
if (pendingNavigatorPresentation === undefined) {
const routePlaybookReadFailure = attendance.knownRoutePlaybookReadFailure?.();
const report: NavigatorReport = {
disposition: "unavailable",
unavailableReason: "Navigator exceeded post-role delivery grace",
unavailableSource: "unknown",
unavailableCause: "unknown",
...(routePlaybookReadFailure === undefined ? {} : { routePlaybookReadFailure }),
};
const event: NavigatorEvent = {
version: 1,
disposition: "unavailable",
invocationId: "post-role-grace-timeout",
role: settlement.role,
phase: settlement.phase,
subjectKey: workContext?.subjectKey ?? "",
unavailableReason: "Navigator exceeded post-role delivery grace",
unavailableSource: "unknown",
unavailableCause: "unknown",
...(routePlaybookReadFailure === undefined ? {} : { routePlaybookReadFailure }),
};
pendingNavigatorPresentation = { event, report };
}
// 过时不候: do not await sidecar teardown — parent court must close.
disposeNavigatorAttendanceNonBlocking(attendance);
})();
pendingNavigatorSettlement = pending;
await pending;
};
projectClosedSubmission = async (closed, context) => projectClosedSubmissionLifecycle(
closed,
context,
navigatorPhase(roleHost, closed.role),
() => receiptDelivery.recordAccepted(),
settleNavigatorProjection,
);
roleHost.on("input", (event) => {
const text = event.text;
const role = roleHost.getFlag(ROLE_FLAG.name);
if (role !== undefined && !admitted) return { action: "handled" as const };
// Reviewer: recover original request; Pi argv may already carry native form.
if (
role === "reviewer"
&& admitted
&& activeReviewerParent !== undefined
&& reviewerOriginalRequest === undefined
) {
reviewerOriginalRequest =
roleHost.capabilities?.skillOriginalRequest?.(
activeReviewerParent.skillBinding.name,
text,
) ?? text;
}
return { action: "continue" as const };
});
// Reference law/guides use the existing typed reading-material channel;
// they are not rewritten into operator dialogue or the identity Soul.
roleHost.on("before_agent_start", () => roleReferenceMaterials === ""
? undefined
: { readingMaterial: { kind: "role-reference-materials", content: roleReferenceMaterials } });
roleHost.on("before_agent_start", async (event, ctx) => {
const role = roleHost.getFlag(ROLE_FLAG.name);
const prompt = event.prompt;
if (role === undefined) return;
if (!admitted || selectedRole !== role) {
failInfrastructure(new ActivationBarrierError(role), ctx);
}
// Notary session bound: envelope-owned lifecycle write (ADR 0018 / #582).
// Ticket flag register/read + session entry live here; role projects admitted bound only.
if (role === "notary") {
const bound = projectNotaryBoundFromFlags((name) => roleHost.getFlag(name));
if (bound !== undefined) {
ctx.sessionManager.appendCustomEntry?.(NOTARY_SESSION_BOUND_ENTRY, bound);
}
}
if (navigatorAttendance !== undefined && navigatorWorkContext !== undefined && navigatorWorkContext.contextError === undefined) {
// Flagged roles already have a concrete packet/task/case/review input.
// A bare Judge (and other bare packaged entrypoint) gets its concrete
// user task at this seam; do not copy the assembled system prompt.
// Replacement is keyed by typed subject provenance, never prose prefixes.
// Soft session_start placeholders (no materials yet) recover here; hard
// contextError from true loader failures stays poisoned and honest.
if (navigatorWorkContext.subjectProvenance === "placeholder") {
// Navigator's own input bootstrap, not a rewrite of another role's
// output payload (#836 targets the latter): the human's own raw
// prompt bytes, copied verbatim into Navigator's work context when
// nothing else has supplied a subject yet.
const subject = prompt.trim();
if (subject !== "") {
const root = subjectPath(ctx.sessionManager.getSessionDir(), ctx.cwd);
const subjectProvenance = "user_prompt" satisfies NavigatorSubjectProvenance;
const priorAuthority = navigatorWorkContext.authority;
const authority = typeof priorAuthority === "string" && priorAuthority.trim() !== ""
? priorAuthority
: subject;
navigatorWorkContext = {
subjectKey: navigatorSubjectKey(root, subject, subjectProvenance),
subject,
authority,
subjectProvenance,
};
await navigatorAttendance.setWorkContext(navigatorWorkContext);
}
}
}
navigatorAttendance?.prepare();
// Envelope-owned Reviewer expansion capture + parent prompt assembly (no role-module callback).
if (role === "reviewer" && activeReviewerParent !== undefined) {
if (!reviewerExpansionCaptured) {
if (reviewerOriginalRequest !== undefined) {
activeReviewerParent.skillBinding.captureExpansion(
roleHost.capabilities?.skillExpansion(prompt),
reviewerOriginalRequest,
);
}
reviewerExpansionCaptured = true;
}
return {
systemPrompt: assembleReviewerParentSystemPrompt({
baseSystemPrompt: event.systemPrompt,
soul: activeReviewerParent.soul,
}),
};
}
// #676 E / J1: collector materials + drift gates share this envelope hook (no parallel register).
if (role === "collector" && activeCollector !== undefined) {
if (!collectorFirstDispatchDone) {
collectorFirstDispatchDone = true;
activeCollector.ledger.recordActivation(activeCollector.clock);
}
return {
systemPrompt: collectorBusiness.assembleMaterials(activeCollector, event.systemPrompt),
};
}
});
// #879: engine coordinates ride readingMaterial only when station-child
// officer dialogue replaced the ordinary transport prompt (no double fold).
roleHost.on("before_agent_start", () => {
const role = selectedRole ?? roleHost.getFlag(ROLE_FLAG.name);
if (typeof role !== "string" || !isOfficerReviewSeat(role)) return;
if (roleHost.getFlag(STATION_CHILD_FLAG.name) !== true) return;
const engine = resolveEngineName((name) => roleHost.getFlag(name));
if (engine === undefined || dependencies.packageRoot === undefined) return;
const engineModel = resolveEngineModel((name) => roleHost.getFlag(name));
const material = engineSessionMaterialFromOptions({
engine,
...(engineModel === undefined ? {} : { engineModel }),
packageRoot: dependencies.packageRoot,
});
if (material === undefined) return;
return {
readingMaterial: {
kind: "engine-session-material" as const,
name: material.name,
...(material.model === undefined ? {} : { model: material.model }),
...(material.materialPath === undefined ? {} : { materialPath: material.materialPath }),
},
};
});
roleHost.on("before_agent_start", () => {
const path = roleHost.getFlag(INSPECTOR_SOURCE_RUN_FLAG.name);
if (typeof path !== "string" || path.trim() === "") return;
return {
readingMaterial: {
kind: "inspector-parent-binding" as const,
sourceRunPath: path,
},
};
});
// #879: single shared owner of station-child 0081 case-dossier → readingMaterial fold.
// post-admission freezes under run/attachments/case-dossier/; this handler alone
// projects it onto the existing agent-start materials face (Pi + envelope collect).
// Role modules must not re-read the freeze (ADR 0018 lifecycle; no duplicate fold).
roleHost.on("before_agent_start", async (_event, ctx) => {
const runDir = runDirectoryFromHostContext(ctx);
if (runDir === undefined) return;
const { loadCaseDossierReadingMaterial } = await import(
"./public-cli/case-dossier-delivery.ts"
);
const caseDossier = await loadCaseDossierReadingMaterial(runDir);
if (caseDossier === undefined) return;
return { readingMaterial: caseDossier };
});
roleHost.on("tool_result", async (event) => {
const role = selectedRole;
if (role === undefined) return;
// #676 E / J1: collector operational bookkeeping on the shared tool_result seam.
if (role === "collector" && activeCollector !== undefined) {
collectorBusiness.onToolResult(activeCollector, event);
}
const pendingInfra = pendingInfrastructureFailures.get(event.toolCallId);
const isRoleInfrastructureFailure = pendingInfra !== undefined;
if (pendingInfra !== undefined) pendingInfrastructureFailures.delete(event.toolCallId);
// One-shot project fact + typed evidence so live settlement and durable session agree.
const infrastructureDetails = pendingInfra?.details;
const classified = infrastructureDetails === undefined
? event
: { ...event, details: infrastructureDetails };
const isOutputTool = event.toolName === navigatorOutputTool(role);
const outputClassification = isOutputTool ? classifyPackagedRoleTerminalResult(classified) : undefined;
if (isRoleInfrastructureFailure || outputClassification?.kind === "infrastructure") {
receiptDelivery.stopForInfrastructure();
} else if (isOutputTool && outputClassification?.kind === "nonterminal" && event.isError) {
const reason = (event.content ?? [])
.map((part) => part.type === "text" && "text" in part ? part.text : "")
.join("")
.trim();
receiptDelivery.recordRejected(reason);
}
// Accepted/human terminal projection belongs exclusively to typed ledger
// closure. tool_result retains only infrastructure settlement.
const settlement = isRoleInfrastructureFailure || outputClassification?.kind === "infrastructure"
? publicNavigatorSettlement(role, navigatorPhase(roleHost, role), classified)
: undefined;
await settleNavigatorProjection(settlement);
// Persist typed infrastructure-failure fact onto the role session toolResult so
// exact-session restart shares the same durable completion classification.
if (infrastructureDetails !== undefined) {
return { isError: true };
}
// Submission non-pass: throw kept message text for the model; project the
// envelope-bound structured result onto session details at this tool_result seam.
const submissionNonPass = pendingSubmissionNonPassByToolCallId.get(event.toolCallId);
if (submissionNonPass !== undefined) {
pendingSubmissionNonPassByToolCallId.delete(event.toolCallId);
return { details: submissionNonPass, isError: true };
}
});
// Queue receipt delivery before `agent_settled`: that event means Pi has
// already decided no queued continuation will run, so a triggerTurn there is
// too late for print/json sessions. `agent_end` is the last production seam
// whose queued next turn is consumed before settlement.
roleHost.on("agent_end", async (event, ctx) => {
const lastMessage = event.messages.at(-1);
if (lastMessage?.role === "assistant"
&& (lastMessage.stopReason === "error" || lastMessage.stopReason === "aborted")) {
// Abort after an already-recorded receipt must not un-accept or催交.
if (receiptDelivery.nextAction() !== "accepted") {
receiptDelivery.stopForInfrastructure();
}
return;
}
const role = selectedRole ?? roleHost.getFlag(ROLE_FLAG.name);
// #959: navigator prose exit — final assistant text is the receipt.
// No typed-tool 催交; no JSON required. Tool path still wins when already accepted.
if (role === "navigator" && receiptDelivery.nextAction() !== "accepted") {
const prose = lastAssistantProse(event.messages);
if (prose !== undefined && prose.trim() !== "") {
const accepted = { prose };
await sealAcceptedSubmission({
context: ctx,
role: "navigator",
accepted,
toolCallId: `navigator-prose-exit:${randomUUID()}`,
});
await projectClosedSubmission(
{ role: "navigator", kind: "accepted", accepted },
ctx,
);
return;
}
// Attended with neither tool nor prose → honest no_receipt (not typed 催交).
// Exhaust delivery budget without sending the typed prompt so facts() stays lawful.
if (!noReceiptRecorded) {
const runPointer = runDirectoryFromHostContext(ctx);
if (runPointer !== undefined) {
noReceiptRecorded = true;
while (receiptDelivery.nextAction() === "request-delivery") {
receiptDelivery.recordDeliveryRequest();
}
const facts = receiptDelivery.facts({ runPointer, attemptPointer: `current:${runPointer}` });
envelopeHost.appendEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
try {
sitianReport({
level: "event",
kind: "no-receipt-lifecycle",
cwd: ctx.cwd,
sessionParent: ctx.sessionManager.getSessionFile(),
payload: facts,
source: "role-runtime",
});
} catch {}
}
}
return;
}
if (receiptDelivery.nextAction() === "request-delivery") {
receiptDelivery.recordDeliveryRequest();
// Keep the package-owned continuation off the public input lifecycle:
// receipt delivery must not be mistaken for later caller input.
envelopeHost.appendEntry("ak-receipt-delivery-request");
try {
sitianReport({
level: "event",
kind: "receipt-delivery",
cwd: ctx.cwd,
sessionParent: ctx.sessionManager.getSessionFile(),
payload: { type: "ak-receipt-delivery-request" },
source: "role-runtime",
});
} catch {}
envelopeHost.sendMessage({
customType: "ak-receipt-delivery-prompt",
content: RECEIPT_DELIVERY_PROMPT,
display: false,
}, { triggerTurn: true, deliverAs: "followUp" });
} else if (receiptDelivery.nextAction() === "no-receipt" && !noReceiptRecorded) {
const runPointer = runDirectoryFromHostContext(ctx);
if (runPointer !== undefined) {
noReceiptRecorded = true;
const facts = receiptDelivery.facts({ runPointer, attemptPointer: `current:${runPointer}` });
envelopeHost.appendEntry(NO_RECEIPT_LIFECYCLE_ENTRY_TYPE, facts);
try {
sitianReport({
level: "event",
kind: "no-receipt-lifecycle",
cwd: ctx.cwd,
sessionParent: ctx.sessionManager.getSessionFile(),
payload: facts,
source: "role-runtime",
});
} catch {}
}
}
});
roleHost.on("agent_settled", async () => {
if (pendingNavigatorSettlement !== undefined) {
await pendingNavigatorSettlement;
}
// Do not auto-drain Navigator on ordinary mid-turn agent_settled.
// Multi-turn roles (#162 coder) fire agent_settled after every tool turn;
// discarding a healthy/in-flight prepare there forces a cold prepare on the
// final accepted terminal and races the post-role grace. Keep preparation
// until an accepted/human/infrastructure tool_result settlement (or dispose).
// Provider death without any role tool_result leaves preparation held; the
// next explicit settlement or session end owns it — not mid-turn churn.
pendingNavigatorSettlement = undefined;
const presentation = pendingNavigatorPresentation;
pendingNavigatorPresentation = undefined;
if (presentation === undefined) return;
await envelopeHost.sendMessage({
customType: NAVIGATOR_EVENT_TYPE,
content: formatNavigatorReport(presentation.report),
display: true,
details: presentation.event,
}, { triggerTurn: false });
});
roleHost.on("session_shutdown", async () => {
// #351: stop OAuth keepalive first so shutdown yields zero further ticks.
envelopeHost.stopKeepalive();
if (fetchWrapped && priorFetch !== undefined) {
globalThis.fetch = priorFetch;
priorFetch = undefined;
fetchWrapped = false;
}
// #676 J4: collector fatal latch must surface nonzero exit on shutdown (envelope-owned).
if (
selectedRole === "collector"
&& activeCollector !== undefined
&& activeCollector.ledger.fatal
) {
if (process.exitCode === undefined || process.exitCode === 0) {
process.exitCode = 1;
}
}
// Flush any still-pending affirmative attendance before teardown.
// Grace-timeout paths normally emit on agent_settled; abort can skip that hook.
const presentation = pendingNavigatorPresentation;
pendingNavigatorPresentation = undefined;
if (presentation !== undefined) {
try {
await envelopeHost.sendMessage({
customType: NAVIGATOR_EVENT_TYPE,
content: formatNavigatorReport(presentation.report),
display: true,
details: presentation.event,
}, { triggerTurn: false });
} catch {
// Teardown must not mask the original role failure cause.
}
}
// Same non-blocking dispose as post-role grace: awaiting here re-blocked the
// parent court for 43–270s after unavailable was already projected (#959 reopen).
const attendanceToDispose = navigatorAttendance;
navigatorAttendance = undefined;
pendingNavigatorSettlement = undefined;
disposeNavigatorAttendanceNonBlocking(attendanceToDispose);
pendingInfrastructureFailures.clear();
pendingSubmissionNonPassByToolCallId.clear();
observationFace.reset();
});
const hostActions = {
failInfrastructure(error: unknown, ctx: HostContext, toolCallId?: string): never {
if (toolCallId !== undefined) {
pendingInfrastructureFailures.set(toolCallId, buildPendingInfrastructureFailure(error));
}
failInfrastructure(error, ctx);
},
bindSubmissionNonPass(toolCallId: string, result: SubmissionNonPassResult): void {
pendingSubmissionNonPassByToolCallId.set(toolCallId, result);
},
};
const judge = createJudgeRoleRuntime(
roleHost,
{
loadSoul: dependencies.loadJudgeSoul,
},
hostActions,
);
const fixer = createFixerRoleRuntime(
roleHost,
{
async loadSoul() {
if (dependencies.loadFixerSoul === undefined) {
throw new Error("fixer soul loader is not configured");
}
return dependencies.loadFixerSoul();
},
async loadPacket(path) {
if (dependencies.loadFixPacket === undefined) {
throw new Error("Fixer packet loader is not configured");
}
return dependencies.loadFixPacket(path);
},
},
hostActions,
);
const coder = createCoderRoleRuntime(
roleHost,
{
async loadSoul() {
if (dependencies.loadCoderSoul === undefined) {
throw new Error("coder soul loader is not configured");
}
return dependencies.loadCoderSoul();
},
async loadTask(path) {
if (dependencies.loadCoderTask === undefined) {
throw new Error("Coder task loader is not configured");
}
return dependencies.loadCoderTask(path);
},
...(dependencies.loadCanonicalSkillBinding === undefined
? {}
: {
loadCanonicalSkillBinding: (name: "tdd") =>
dependencies.loadCanonicalSkillBinding!(name),
}),
},
hostActions,
);
const reviewer = createReviewerRoleRuntime(
roleHost,
{
async loadSoul() {
if (dependencies.loadReviewerSoul === undefined) {
throw new Error("reviewer soul loader is not configured");
}
return dependencies.loadReviewerSoul();
},
async loadCanonicalSkillBinding(name) {
if (dependencies.loadCanonicalSkillBinding === undefined) {
throw new Error("Reviewer runtime dependencies are not configured");
}
return dependencies.loadCanonicalSkillBinding(name);
},
},
hostActions,
);
const doctor = createDoctorRoleRuntime(roleHost, {
async loadSoul() { if (!dependencies.loadDoctorSoul) throw new Error("Doctor runtime dependencies are not configured"); return dependencies.loadDoctorSoul(); },
async loadCase(path) { if (!dependencies.loadDoctorCase) throw new Error("Doctor runtime dependencies are not configured"); return dependencies.loadDoctorCase(path); },
async auditCompliance(options) { if (!dependencies.auditDoctorCompliance) throw new Error("Doctor runtime dependencies are not configured"); return dependencies.auditDoctorCompliance(options); },
}, hostActions);
const notary = createNotaryRoleRuntime(roleHost, {
async loadSoul() {
if (!dependencies.loadNotarySoul) throw new Error("Notary runtime dependencies are not configured");
return dependencies.loadNotarySoul();
},
async loadSourceRunLocator(path) {
if (!dependencies.loadNotarySourceRun) throw new Error("Notary runtime dependencies are not configured");
return dependencies.loadNotarySourceRun(path);
},
}, hostActions);
const countersign = createCountersignRoleRuntime(roleHost, {
async loadSoul() {
if (!dependencies.loadCountersignSoul) throw new Error("Countersign runtime dependencies are not configured");
return dependencies.loadCountersignSoul();
},
}, hostActions);
const gleanerLeftRuntime = createGleanerLeftRoleRuntime(roleHost, {
async loadSoul() {
if (!dependencies.loadGleanerLeftSoul) throw new Error("Gleaner-left runtime dependencies are not configured");
return dependencies.loadGleanerLeftSoul();
},
});
const gleanerLeft = {
async activate() {
decodeGleanerLeftBase((name) => envelopeHost.host.getFlag(name));
return gleanerLeftRuntime.activate();
},
};
const inspector = createInspectorRoleRuntime(roleHost, {
async loadSoul() {
if (!dependencies.loadInspectorSoul) throw new Error("Inspector runtime dependencies are not configured");
return dependencies.loadInspectorSoul();
},
});
const gatekeeper = createGatekeeperRoleRuntime(roleHost, {
async loadSoul() {
if (!dependencies.loadGatekeeperSoul) throw new Error("Gatekeeper runtime dependencies are not configured");
return dependencies.loadGatekeeperSoul();
},
});
const navigator = createNavigatorRoleRuntime(roleHost, {
async loadSoul() {
if (!dependencies.loadNavigatorSoul) throw new Error("Navigator runtime dependencies are not configured");
return dependencies.loadNavigatorSoul();
},
});
const auditor = createAuditorRoleRuntime(roleHost, {
async loadSoul() {
if (!dependencies.loadAuditorSoul) throw new Error("Auditor runtime dependencies are not configured");
return dependencies.loadAuditorSoul();
},
});
const diarist = createDiaristRoleRuntime(
roleHost,
{
async loadSoul() {
if (!dependencies.loadDiaristSoul) throw new Error("Diarist runtime dependencies are not configured");
return dependencies.loadDiaristSoul();
},
},
);
const secretariat = createSecretariatRoleRuntime(roleHost, {
async loadSoul() {
if (!dependencies.loadSecretariatSoul) throw new Error("Secretariat runtime dependencies are not configured");
return dependencies.loadSecretariatSoul();
},
...(dependencies.packageRoot === undefined
? {}
: { packageRoot: dependencies.packageRoot }),
...(dependencies.hostAdapters === undefined
? {}
: { hostAdapters: dependencies.hostAdapters }),
}, hostActions);
const merger = createMergerRoleRuntime(roleHost, {
async loadSoul() { if (!dependencies.loadMergerSoul) throw new Error("Merger runtime dependencies are not configured"); return dependencies.loadMergerSoul(); },
async loadInput(path) { if (!dependencies.loadMergerInput) throw new Error("Merger runtime dependencies are not configured"); return dependencies.loadMergerInput(path); },
});
// #676 E: shared envelope owns collector lifecycle (mode/fork, tool surface,
// event gates). Role module supplies business activate/tools/materials only.
let activeCollector: CollectorActivation | undefined;
let collectorFirstDispatchDone = false;
const collectorBusiness = createCollectorRoleRuntime(
roleHost,
{
async loadSoul() {
if (dependencies.loadCollectorSoul === undefined) {
throw new Error("collector soul loader is not configured");
}
return dependencies.loadCollectorSoul();
},
createTransport() {
if (dependencies.createCollectorTransport === undefined) {
throw new Error("Collector GitHub transport is not configured");
}
return dependencies.createCollectorTransport();
},
createLedger(config, collectorClock, context) {
const append = context.sessionManager?.appendCustomEntry;
return createCollectorLedger(config, {
clock: collectorClock,
...(append === undefined
? {}
: {
journal: {
append(customType, data) {
context.sessionManager?.appendCustomEntry?.(customType, data);
},
},
}),
dossierEntries: context.sessionManager?.getEntries?.() ?? [],
});
},
...(dependencies.createCollectorClock === undefined
? {}
: { createClock: dependencies.createCollectorClock }),
...(dependencies.loadCollectorHandbookSeed === undefined
? {}
: { loadHandbookSeed: dependencies.loadCollectorHandbookSeed }),
},
hostActions,
);
// #676 J1: business tools + tool_call gate register only behind admission/activation.
// before_agent_start / tool_result collector branches live on the shared envelope hooks above.
//
// #676 J4 dispositions for guards removed from the role-private collector module:
// - skills/contextFiles/appendSystemPrompt fail-closed → shared before_agent_start (above).
// - ambient skill/prompt/template commands → activate (below).
// - session_shutdown fatal exitCode → shared session_shutdown (above).
// - subsequent-input latchFatal + fixed-kickoff rewrite → intentionally not migrated:
// #676 materials-first multi-turn abolished the single-shot fixed kickoff; multi-turn
// observe/request/wait requires later inputs. Judge r1: fixed-kickoff equality delete is authorized.
// - tool sourceInfo path override check → intentionally not migrated: depended on
// packageExtensionPath deleted under ADR 0018 / #676 E envelope ownership; uniqueness
// + setActiveTools inventory checks remain on activate.
let collectorToolCallRegistered = false;
const collector = {
async activate(context: HostContext, event: { reason: string }) {
activeCollector = undefined;
collectorFirstDispatchDone = false;
// Envelope-owned mode / fork-reload gates (not role-private lifecycle).
if (context.mode !== "print" && context.mode !== "json") {
throw new Error(
`Collector supports only print or json mode (got ${context.mode})`,
);
}
if (event.reason === "fork" || event.reason === "reload") {
throw new Error(
`Collector does not support session_start reason ${event.reason}`,
);
}
// Business tools behind admission barrier (inert-without-role invariant).
// First activation: fail closed if a required name is already occupied.
// Later activations reuse the once-registered tools (registerBusinessTools is idempotent).
const preExisting = roleHost.getAllTools();
const alreadyRegistered = COLLECTOR_REQUIRED_TOOLS.every((required) =>
preExisting.some((tool) => tool.name === required),
);
if (!alreadyRegistered) {
for (const required of COLLECTOR_REQUIRED_TOOLS) {
const prior = preExisting.filter((tool) => tool.name === required);
if (prior.length > 0) {
throw new Error(`Collector required tool name collision: ${required}`);
}
}
}
collectorBusiness.registerBusinessTools(() => activeCollector);
const allTools = roleHost.getAllTools();
for (const required of COLLECTOR_REQUIRED_TOOLS) {
const matches = allTools.filter((tool) => tool.name === required);
if (matches.length === 0) {
throw new Error(`Collector required tool missing: ${required}`);
}
if (matches.length > 1) {
throw new Error(`Collector required tool name collision: ${required}`);
}
}
roleHost.setActiveTools([...COLLECTOR_REQUIRED_TOOLS]);
const active = new Set(roleHost.getActiveTools());
for (const required of COLLECTOR_REQUIRED_TOOLS) {
if (!active.has(required)) {
throw new Error(`Collector failed to activate required tool ${required}`);
}
}
for (const name of active) {
if (!(COLLECTOR_REQUIRED_TOOLS as readonly string[]).includes(name)) {
throw new Error(`Collector active tool surface includes unexpected ${name}`);
}
}
// Seat-scoped tool_call gate — registered only after collector admission (no install-time tool_call).
if (!collectorToolCallRegistered) {
collectorToolCallRegistered = true;
roleHost.on("tool_call", (toolEvent) => {
if (activeCollector === undefined || selectedRole !== "collector") return;
if (!(COLLECTOR_REQUIRED_TOOLS as readonly string[]).includes(toolEvent.toolName)) {
return {
block: true,
reason: `通进司禁用工具 ${toolEvent.toolName}`,
};
}
return collectorBusiness.onToolCall(activeCollector, toolEvent);
});
}
const activation = await collectorBusiness.activate(context);
if (activation.ledger.activationRecorded) {
collectorFirstDispatchDone = true;
}
activeCollector = activation;
},
};
const clock = dependencies.activationClock ?? (() => new Date().toISOString());
const writeTrace = dependencies.activationTraceWriter ?? writeActivationTraceRecord;
const observationFace = createToolExecutionObservationFace({
role: () => selectedRole,
admitted: () => admitted,
clock: dependencies.toolExecutionObservationClock ?? clock,
monoNow: dependencies.toolExecutionObservationMonoNow ?? systemToolExecutionObservationMonoNow,
write: dependencies.toolExecutionObservationWriter ?? writeToolExecutionObservationRecord,
});
// ExtensionRunner.emit catches ordinary handler throws, emits extension error, and continues.
// Observation plane failures must still hit the shared infrastructure termination path
// (abort + nonzero print/json exit) with the original cause before that swallow.
const observe = async (
run: () => void | Promise,
ctx: HostContext,
): Promise => {
try {
await run();
} catch (error) {
failInfrastructure(error, ctx);
}
};
roleHost.on("tool_execution_start", async (event, ctx) => {
await observe(() => observationFace.onStart(event), ctx);
});
roleHost.on("tool_execution_update", async (event, ctx) => {
await observe(() => observationFace.onUpdate(event), ctx);
});
roleHost.on("tool_execution_end", async (event, ctx) => {
await observe(() => observationFace.onEnd(event), ctx);
});
// Public Role run: record typed non-success HTTP for error evidence + v1 resume.
// Same observation owner as in-process-session statusAwareFetch → typed-provider-http
// sidecar (settlement already merges observation.httpStatus into knownFailure).
// after_provider_response covers the success-path onResponse face; fetch wrap covers
// non-2xx Responses where openai-completions throws before onResponse (#675).
const recordHttpObservation = async (
status: number,
provider: string,
ctx: HostContext,
): Promise => {
const runDir = runDirectoryFromHostContext(ctx);
if (runDir === undefined) return;
try {
await recordTypedProviderHttpStatus(runDir, { httpStatus: status, provider });
} catch (error) {
if (
status >= 200 &&
status < 300 &&
error instanceof Error &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
) {
return;
}
failInfrastructure(error, ctx);
}
};
roleHost.on("after_provider_response", async (event, ctx) => {
const status = event.status;
if (typeof status !== "number") return;
const fromCtx = ctx.model?.provider;
const provider =
typeof fromCtx === "string" && fromCtx.trim() !== ""
? fromCtx
: "unknown";
await recordHttpObservation(status, provider, ctx);
});
roleHost.on("session_start", async (event, ctx) => {
// Scope fetch observation to this public run (in-process-session statusAwareFetch face).
if (!fetchWrapped && typeof globalThis.fetch === "function") {
priorFetch = globalThis.fetch.bind(globalThis);
const underlying = priorFetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await underlying(input, init);
const runDir = runDirectoryFromHostContext(ctx);
if (
runDir !== undefined
&& typeof response?.status === "number"
&& (response.status < 200 || response.status >= 300)
) {
const provider =
typeof ctx.model?.provider === "string" && ctx.model.provider.trim() !== ""
? ctx.model.provider
: "unknown";
try {
await recordTypedProviderHttpStatus(runDir, {
httpStatus: response.status,
provider,
});
} catch {
// Observation must not break the provider stream (same as in-process-session).
}
}
return response;
}) as typeof globalThis.fetch;
fetchWrapped = true;
}
admitted = false;
selectedRole = undefined;
roleReferenceMaterials = "";
activeReviewerParent = undefined;
reviewerOriginalRequest = undefined;
reviewerExpansionCaptured = false;
receiptDelivery = createReceiptDeliveryPolicy();
noReceiptRecorded = false;
observationFace.reset();
pendingNavigatorPresentation = undefined;
pendingNavigatorSettlement = undefined;
pendingInfrastructureFailures.clear();
pendingSubmissionNonPassByToolCallId.clear();
navigatorWorkContext = undefined;
// #351: OAuth keepalive is orthogonal to --ak-role; start before role early-return
// so role-less sessions (and reload after shutdown stop) still keep tokens alive.
envelopeHost.startKeepalive(ctx);
const rawRole = roleHost.getFlag(ROLE_FLAG.name);
if (rawRole === undefined) return;
const entry = PACKAGED_ROLE_REGISTRY.find(({ role }) => role === rawRole);
if (entry === undefined) {
failInfrastructure(new Error(`Unsupported workflow role: ${String(rawRole)}`), ctx);
}
selectedRole = entry.role;
await navigatorAttendance?.dispose();
navigatorAttendance = undefined;
const runtime: ActivationRuntime = {
event,
context: ctx,
judge,
fixer,
coder,
reviewer,
decodeReviewerAdmitted() {
return decodeReviewerAdmittedInputs((name) => roleHost.getFlag(name));
},
bindReviewerParent(activation) {
activeReviewerParent = activation;
},
decodeNotaryAdmitted() {
const ticketNumber = readNotaryTicketFlag(
roleHost.getFlag(NOTARY_TICKET_FLAG.name),
);
return ticketNumber === undefined ? undefined : { ticketNumber };
},
collector,
doctor,
notary,
countersign,
gleanerLeft,
inspector,
gatekeeper,
navigator,
auditor,
diarist,
secretariat,
merger: async () => {
await merger.activate();
},
};
try {
// Production topology only (ADR 0048/0049): no test-only ledger hooks.
// Admit durable session file first so lifecycle getEntries/appendEntry are truthful:
// deferred SM materialization must not wipe an in-memory principal marker.
// #855: two-face waiting.jsonl write removed — fail-closed book-key + session only.
resolveBookKeyFromGit(ctx.cwd);
durableSessionPointer(ctx.sessionManager);
// Station children (court diarist, inner-gate summons) omit navigator sidecar (#840).
// Top-level public entry legs still attend automatically.
// Navigator seat never re-attaches itself — prepare turns already ARE the navigator
// public activation (prevents summonPublicRole navigator ↔ attendance recursion).
const isStationChild = roleHost.getFlag(STATION_CHILD_FLAG.name) === true;
if (
dependencies.createNavigatorAttendance !== undefined
&& entry.role !== "navigator"
&& !isStationChild
) {
navigatorSessionParent = ctx.sessionManager.getSessionFile();
navigatorCwd = ctx.cwd;
let work: NavigatorWorkContext;
let contextError: unknown;
if (dependencies.loadNavigatorWorkContext === undefined) {
const fallbackSubjectKey = subjectPath(ctx.sessionManager.getSessionDir(), ctx.cwd);
contextError = new Error("Navigator work context loader is not configured");
work = { subjectKey: fallbackSubjectKey, subject: `work subject: ${fallbackSubjectKey}`, authority: "", subjectProvenance: "placeholder" };
} else {
try {
work = await dependencies.loadNavigatorWorkContext({
context: ctx,
role: entry.role,
phase: navigatorPhase(roleHost, entry.role),
getFlag: (name) => roleHost.getFlag(name),
});
contextError = work.contextError;
} catch (error) {
// Contract: README.md#Navigator-attendance — a failed context load continues with a typed placeholder work context; the original cause is retained in contextError for the typed unavailable report.
contextError = navigatorUnavailableError("context", error);
const fallbackSubjectKey = subjectPath(ctx.sessionManager.getSessionDir(), ctx.cwd);
work = { subjectKey: fallbackSubjectKey, subject: `work subject: ${fallbackSubjectKey}`, authority: "", subjectProvenance: "placeholder" };
}
}
navigatorWorkContext = { ...work, ...(contextError === undefined ? {} : { contextError }) };
// Shared envelope owns exact invocation principal from admitted session lifecycle.
// session_start is process activation: resume unfinished principal only when marker
// role/phase/subjectKey still match; mint for contradictory marker, malformed nearest,
// missing marker, or terminal already completed.
const sessionEntries = [...ctx.sessionManager.getEntries()];
const invocationPhase = navigatorPhase(roleHost, entry.role);
const lifecyclePrincipal = resolveLifecycleInvocationPrincipal(sessionEntries, {
role: entry.role,
phase: invocationPhase,
subjectKey: work.subjectKey,
});
const invocationId = lifecyclePrincipal.invocationId;
if (!lifecyclePrincipal.resume) {
const data = {
invocationId,
role: entry.role,
phase: invocationPhase,
subjectKey: work.subjectKey,
};
envelopeHost.appendEntry(NAVIGATOR_INVOCATION_ENTRY, data);
try {
sitianReport({
level: "event",
kind: "attendance",
cwd: ctx.cwd,
sessionParent: ctx.sessionManager.getSessionFile(),
payload: { type: NAVIGATOR_INVOCATION_ENTRY, ...data },
source: "role-runtime",
});
} catch {}
}
navigatorAttendance = await dependencies.createNavigatorAttendance({
context: ctx,
role: entry.role,
phase: invocationPhase,
subjectKey: work.subjectKey,
subject: work.subject,
authority: work.authority,
invocationId,
...(contextError === undefined ? {} : { contextError }),
onEvent: (navigatorEvent, report) => {
pendingNavigatorPresentation = { event: navigatorEvent, report };
},
});
// Warm live help during activation so prepare is not help-bound under load.
// Concrete work context also starts full preparation so session create
// overlaps the role run. Placeholder subjects wait for before_agent_start
// (user prompt may replace the subject key) but still inherit warm help.
navigatorAttendance.warmHelp?.();
if (
navigatorWorkContext.contextError === undefined &&
navigatorWorkContext.subjectProvenance !== "placeholder"
) {
navigatorAttendance.prepare();
}
}
await executeActivationStage(entry.role, activationStage(entry.role, runtime), { clock, writeTrace });
roleReferenceMaterials = await dependencies.loadRoleReferenceMaterials?.(entry.role) ?? "";
// #357 T2 / #378 / #380 / #391 / #818: any role+engine activation registers the package detour tool once.
// Gate is resolveEngineName (RoleHost flag → env fallback) — no per-engine execute branch; no role-module spawn.
if (!engineDetourRegistered) {
engineDetourRegistered = registerEngineDetourTool(roleHost, hostActions);
}
// Secretariat owns a declared active surface. The shared engine detour is
// registered after role activation, so include it here rather than leave
// a newly registered optional tool unreachable until a later reload.
if (entry.role === "secretariat" && engineDetourRegistered) {
roleHost.setActiveTools([
...new Set([...roleHost.getActiveTools(), ENGINE_DETOUR_TOOL_NAME]),
]);
}
// Worker gates ①②: arm records baseline and runs private one-shot hook uninstall (ADR 0070).
// Parent session feeds #216 createRecordSession so baseline/bounce survive resume.
if (entry.role === "coder" || entry.role === "fixer") {
if (entry.role === "coder") coder.armSubmissionGate(ctx.cwd, ctx.sessionManager);
else fixer.armSubmissionGate(ctx.cwd, ctx.sessionManager);
}
admitted = true;
} catch (error) {
failInfrastructure(error, ctx);
}
});
};
}