/** * Public Invocation request admission: optional opaque instruction, frozen * Attachments, project default/override (ADR 0052 / #106). */ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { lstat, mkdtemp, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; import { activationBookDirectory, ensureRealDirectoryTree, homeFromRunDirectory, pathContainedIn, resolveActivationLedgerHome, } from "../activation-ledger-topology.ts"; import { resolveBookKeyFromGit } from "../activation-ledger-git.ts"; import { ensureRoleRunDirectory, ensureRoleRunPlacement, roleRunArtifactsDirectory, roleRunPlacement, type RoleRunSubject, } from "../role-run-placement.ts"; import type { DurablePrincipal, DurablePrincipalAuthority, } from "../host-contracts.ts"; import { isSafePositiveTicketNumber, readBoardTicketNumber, requireSafePositiveTicketNumber, } from "../run-ticket-number.ts"; import { rewriteRunDirectoryPathFields, rewriteRunDirectoryPathValue, } from "../role-run-relocation.ts"; import { loadDoctorCase, } from "../doctor-evidence.ts"; import { projectCourtTicketNumbers } from "../diarist-contracts.ts"; import type { DoctorCaseIdentity } from "../doctor-contracts.ts"; import { emptyCollectorManifest, loadCollectorManifest, parseCollectorPrNumber, parseCollectorRepository, type CollectorRepository, } from "../collector-config.ts"; import { resolveCollectorTarget } from "../collector-target.ts"; import { ownerRepoFromGitHubRemoteUrl } from "./github-remote.ts"; import { FixerPacketValidationError, parseFixerPrerequisites, type FixerPrerequisite, } from "../package-contracts/fixer-packet.ts"; import type { FixerPhase } from "../package-contracts/fixer-output.ts"; import { createProductionMergerGitState } from "../merger-git-state.ts"; import type { MergerGitState } from "../merger-git-state.ts"; import { validateMergerInput, type MergerInput, } from "../merger-contracts.ts"; import { sha256Hex } from "../sha256.ts"; import { uuidv7 } from "../uuidv7.ts"; import { NOTARY_FIXED_KICKOFF, type NotarySourceRunLocator, } from "../notary-contracts.ts"; import { NotarySourceRunError, resolveNotarySourceRunLocator, } from "../notary-source-run.ts"; import { appendEngineSessionMaterial, type EngineSessionMaterial, } from "../package-resources/engine-material.ts"; import { CliUsageError } from "./cli-errors.ts"; import { REJECTED_PUBLIC_SPELLINGS, createTypedOptionConsumer, evaluateAnalystModeOptionContract, optionsForOwner, resolveAnalystMode, type OptionOwner, type PublicOptionDefinition, } from "./option-definitions.ts"; import type { PublicThinkingLevel } from "./registry.ts"; export type FrozenAttachment = { /** Original caller path retained only as provenance. */ readonly provenancePath: string; /** Absolute path of the admitted frozen snapshot bytes. */ readonly frozenPath: string; readonly byteLength: number; readonly sha256: string; readonly mediaKind: "regular-file"; }; /** Shared admitted Role run identity (#106 common Invocation + #109 Coder). */ export type AdmittedRoleInvocationBase = { readonly runId: string; readonly bookKey: string; readonly projectRoot: string; /** Opaque instruction bytes as submitted. */ readonly instruction: string; /** True when the caller supplied no nonblank instruction. */ readonly instructionEmpty: boolean; readonly attachments: readonly FrozenAttachment[]; readonly runDirectory: string; /** Host-issued opaque durable principal (coordinates only via authority.decode). */ readonly principal: DurablePrincipal; readonly admittedRequestPath: string; /** * Optional opaque invocation correlation restored from a prior admitted page * (ADR 0049 host channel). Admission does not mint ticket-binding ids. */ readonly correlationId?: string; /** Direct callers retained across same-run resumes, in first-observed order. */ readonly correlationIds?: readonly string[]; /** * Typed ticketNumber after known-identity reuse or notary source-run inheritance * (#635 / #709). Admission does not bind from CLI flag or attachment frontmatter. */ readonly ticketNumber?: number; /** Effective model from invocation identity; restored on resume when no CLI model is given. */ readonly model?: InvocationEffectiveModel; }; export type AdmittedJudgeInvocation = AdmittedRoleInvocationBase & { readonly role: "judge"; }; export type AdmittedCountersignInvocation = AdmittedRoleInvocationBase & { readonly role: "countersign"; /** * #871 typed co-review set for this countersign run (durable run fact). * Main ticket remains `ticketNumber`; this set drives per-ticket court diarist refresh. * Whole-set replace on new typed submission — never union with history. */ courtTicketNumbers?: readonly number[]; /** * Load-time durable set damage diagnostic (#871 B7). Resume must settle as * countersign controlled failure with this text — never structural exit 2 or main-only. */ courtTicketNumbersDamage?: string; /** * Parent run directory (#747 / #987 gate same-parent resume). Persisted on first * mint when gate supplies parentRunPath; independent of ticket-number lookup. */ readonly sourceRunPath?: string; }; export type AdmittedGleanerLeftInvocation = AdmittedRoleInvocationBase & { readonly role: "gleaner-left"; /** Required comparison-base revision for the unanchored merge-candidate diff. */ readonly baseRevision: string; }; export type AdmittedInspectorInvocation = AdmittedRoleInvocationBase & { readonly role: "inspector"; /** Parent run directory (#747 / #879); independent of dialogue instruction. */ readonly sourceRunPath?: string; }; export type AdmittedGatekeeperInvocation = AdmittedRoleInvocationBase & { readonly role: "gatekeeper"; }; export type AdmittedNavigatorInvocation = AdmittedRoleInvocationBase & { readonly role: "navigator"; }; export type AdmittedAuditorInvocation = AdmittedRoleInvocationBase & { readonly role: "auditor"; }; export type AdmittedDiaristInvocation = AdmittedRoleInvocationBase & { readonly role: "diarist"; }; export type AdmittedSecretariatInvocation = AdmittedRoleInvocationBase & { readonly role: "secretariat"; }; export type CoderPhase = "plan" | "apply"; export type AdmittedCoderInvocation = AdmittedRoleInvocationBase & { readonly role: "coder"; /** Explicit plan or default apply — preserved through admission and continuation. */ readonly phase: CoderPhase; /** Durable task file path consumed by internal --ak-coder-task. */ readonly taskPath: string; }; export type AdmittedFixerInvocation = AdmittedRoleInvocationBase & { readonly role: "fixer"; /** Explicit plan or default apply — preserved through admission and continuation. */ readonly phase: FixerPhase; /** Durable opaque instruction path consumed by internal --ak-fix-packet. */ readonly packetPath: string; /** Optional durable prerequisites JSON path for --ak-fixer-prerequisites. */ readonly prerequisitesPath?: string; /** Structurally validated prerequisite declarations frozen at admission. */ readonly prerequisites: readonly FixerPrerequisite[]; }; export type AdmittedCollectorInvocation = AdmittedRoleInvocationBase & { readonly role: "collector"; /** Bound at admission when explicit/--pr or unique head/commit; otherwise role binds. */ readonly prNumber?: number; readonly repository: CollectorRepository; readonly requestManifestPath?: string; readonly manifestDigest: string; /** Wait-window ms (#678 D4); default applied at role activate when omitted. */ readonly waitWindowMs?: number; }; export type AdmittedDoctorInvocation = AdmittedRoleInvocationBase & { readonly role: "doctor"; /** Positive Issue number that owns the retained single-case evidence. */ readonly issueNumber: number; /** Absolute retained runs root passed to internal --ak-doctor-case. */ readonly caseRunsPath: string; /** Structurally exact case identity from loadDoctorCase (no second packet). */ readonly caseIdentity: DoctorCaseIdentity; }; export type AdmittedNotaryInvocation = AdmittedRoleInvocationBase & { readonly role: "notary"; /** Absolute source run directory passed to internal --ak-notary-source-run. */ readonly sourceRunPath: string; /** Typed locator identity bound at admission (self-fetch target). */ readonly sourceRun: NotarySourceRunLocator; }; /** Durable single-axis Reviewer lens. Parallel default is parse-only omission, never admitted. */ export type ReviewerLens = "completeness" | "correctness"; export type AdmittedReviewerInvocation = AdmittedRoleInvocationBase & { readonly role: "reviewer"; /** Required fixed base revision for the pinned review target (ADR 0037). */ readonly baseRevision: string; /** Frozen single-axis shape for an ordinary Reviewer run; reused on resume. */ readonly lens: ReviewerLens; /** * Required durable authority references/URLs frozen at admission. * Projected as Skill-internal `--authority` inputs; never free-text reverse-parse. */ readonly authorityRefs: readonly string[]; }; /** Mechanical materials read from current Git state (may be empty). */ export type DerivedMergerEnvelope = { readonly targetObjectId: string; readonly sourceObjectId: string; readonly expectedConflictPaths: readonly string[]; readonly resolutionScope: readonly string[]; }; export type AdmittedMergerInvocation = AdmittedRoleInvocationBase & { readonly role: "merger"; /** Durable internal merger-input JSON path for --ak-merger-input. */ readonly mergerInputPath: string; /** Adapter-derived mechanical facts (not public packet fields). */ readonly derived: DerivedMergerEnvelope; }; export type AdmittedRoleInvocation = | AdmittedJudgeInvocation | AdmittedCountersignInvocation | AdmittedGleanerLeftInvocation | AdmittedInspectorInvocation | AdmittedGatekeeperInvocation | AdmittedNavigatorInvocation | AdmittedAuditorInvocation | AdmittedDiaristInvocation | AdmittedSecretariatInvocation | AdmittedCoderInvocation | AdmittedFixerInvocation | AdmittedCollectorInvocation | AdmittedDoctorInvocation | AdmittedNotaryInvocation | AdmittedReviewerInvocation | AdmittedMergerInvocation; /** Persistence projection only — not carried on Admitted (opaque principal owns identity). */ type RoleInvocationLedgerSource = Pick< AdmittedRoleInvocationBase, "runId" | "bookKey" | "projectRoot" | "runDirectory" | "correlationId" | "ticketNumber" > & { readonly sessionDirectory: string; readonly sessionFile: string; }; /** Shared admission placement from an injected host authority (no consumer Pi default). */ export type AdmissionPlacement = { readonly principal: DurablePrincipal; readonly sessionDirectory: string; readonly sessionFile: string; readonly runDirectory: string; readonly artifactsDirectory: string; readonly attachmentsDirectory: string; readonly ledgerHome: string; readonly bookKey: string; }; /** Issue a principal from the single authoritative role-run placement. */ export function issueAdmissionPlacement( authority: DurablePrincipalAuthority, request: { readonly cwd: string; readonly runId: string; readonly role: AdmittedRoleInvocation["role"]; /** Identity already asserted by 起居郎; never derive this from CLI parameters. */ readonly subject: RoleRunSubject; readonly home?: string; /** Placement may be computed before a same-ticket lookup without touching disk. */ readonly materialize?: boolean; }, ): AdmissionPlacement { const ledgerHome = resolveActivationLedgerHome(request.home); const bookKey = resolveBookKeyFromGit(request.cwd); const placement = roleRunPlacement(ledgerHome, { bookKey, subject: request.subject, runId: request.runId, role: request.role, }); if (request.materialize !== false) ensureRoleRunPlacement(ledgerHome, placement); return { principal: authority.seal(placement), ...placement, ledgerHome, bookKey, }; } /** * Unique admitted-request.json persistence projection: top-level sessionDirectory/sessionFile * (base wire shape). Memory Admitted keeps only the opaque principal — never dual-carry. */ async function writeAdmittedRequestPersistence( admittedRequestPath: string, body: Record, coordinates: { readonly sessionDirectory: string; readonly sessionFile: string }, ): Promise { const { principal: _omitPrincipal, ...rest } = body; const projection = { ...rest, sessionDirectory: coordinates.sessionDirectory, sessionFile: coordinates.sessionFile, }; await writeFile( admittedRequestPath, `${JSON.stringify(projection, null, 2)}\n`, "utf8", ); } /** * Effective provider/model selection recorded on the invocation identity page. * thinking is present only when the caller/seat supplied it — bare model omits it. * Thinking is opaque pass-through (#683); no local whitelist filter on restore. */ export type InvocationEffectiveModel = { readonly provider: string; readonly model: string; readonly thinking?: PublicThinkingLevel; }; /** Project effective model onto ledger fields; absent thinking stays absent. */ function effectiveModelLedgerFields( model: InvocationEffectiveModel | undefined, ): Record { if (model === undefined) return {}; return { provider: model.provider, model: model.model, ...(model.thinking === undefined ? {} : { thinking: model.thinking }), }; } export { homeFromRunDirectory }; /** * Persist one `invocation.json` identity page for the public run. * Admission is the sole source for every field; this is the only identity * projection and callers never provide an independent ledger shape. * When an effective model is known at admission, provider/model (and thinking * only when supplied) are written onto the same page. */ async function writeRoleInvocationLedger( source: RoleInvocationLedgerSource, role: AdmittedRoleInvocation["role"], effectiveModel?: InvocationEffectiveModel, ): Promise { const identity = { role, runId: source.runId, bookKey: source.bookKey, projectRoot: source.projectRoot, runDirectory: source.runDirectory, sessionDirectory: source.sessionDirectory, sessionFile: source.sessionFile, ...(source.correlationId === undefined ? {} : { correlationId: source.correlationId }), ...(source.ticketNumber === undefined ? {} : { ticketNumber: source.ticketNumber }), ...effectiveModelLedgerFields(effectiveModel), }; await writeFile( join(source.runDirectory, "invocation.json"), `${JSON.stringify(identity, null, 2)}\n`, "utf8", ); } /** * Merge the effective launch model / engine / host onto the existing invocation * identity page (resume / temporary override path — same field shape as admission). * Bare model clears any prior thinking key so absence stays honest. * * Engine axis (#617 Scope 1 / #883): `string` writes, `null` deletes (authoritative * seat projection when the live table has no engine/model), `undefined` preserves * any existing key for non-authoritative partial updates. * Host stays write-if-present (`string` only). */ export async function recordEffectiveInvocationModel( runDirectory: string, model?: InvocationEffectiveModel, engine?: string | null, host?: string, engineModel?: string | null, ): Promise { const ledgerPath = join(runDirectory, "invocation.json"); const current = JSON.parse(await readFile(ledgerPath, "utf8")) as Record< string, unknown >; const next: Record = { ...current }; if (model !== undefined) { next.provider = model.provider; next.model = model.model; if (model.thinking === undefined) { delete next.thinking; } else { next.thinking = model.thinking; } } if (engine === null) { delete next.engine; } else if (engine !== undefined) { next.engine = engine; } if (engineModel === null) { delete next.engineModel; } else if (engineModel !== undefined) { next.engineModel = engineModel; } if (host !== undefined) { next.host = host; } await writeFile( ledgerPath, `${JSON.stringify(next, null, 2)}\n`, "utf8", ); } /** Merge observed launch-time fields into the single existing invocation.json identity page. */ async function mergeInvocationIdentityPage( runDirectory: string, fields: Record, ): Promise { const ledgerPath = join(runDirectory, "invocation.json"); const current = JSON.parse(await readFile(ledgerPath, "utf8")) as Record; await writeFile( ledgerPath, `${JSON.stringify({ ...current, ...fields, }, null, 2)}\n`, "utf8", ); } /** * Persist parent --source-run path onto admitted-request for officer resume lookup (#747). * Reuses the existing notary sourceRunPath key; does not invent a new field name. */ export async function persistAdmittedSourceRunPath( admitted: AdmittedRoleInvocation, sourceRunPath: string, ): Promise { if (sourceRunPath.trim() === "") { throw new Error("persistAdmittedSourceRunPath requires a non-empty sourceRunPath"); } const admittedPath = admitted.admittedRequestPath; const current = JSON.parse(await readFile(admittedPath, "utf8")) as Record< string, unknown >; if (typeof current.sourceRunPath === "string") { if (current.sourceRunPath === sourceRunPath) return; throw new Error( `persistAdmittedSourceRunPath refuses to replace ${current.sourceRunPath} with ${sourceRunPath}`, ); } await writeFile( admittedPath, `${JSON.stringify({ ...current, sourceRunPath }, null, 2)}\n`, "utf8", ); } /** * Bind a post-admission resolved ticketNumber onto the in-memory admitted * object and both durable pages (invocation.json + admitted-request.json). * Used by known-ticket reuse when admission was unbound (#635 / #709). * Never clears an existing binding. */ export async function bindAdmittedTicketNumber( admitted: AdmittedRoleInvocation, ticketNumber: number, ): Promise { if (admitted.ticketNumber !== undefined) { if (admitted.ticketNumber === ticketNumber) return; throw new Error( `bindAdmittedTicketNumber refuses to replace existing ticket #${admitted.ticketNumber} with #${ticketNumber}`, ); } await bindTicketNumberOnRunDirectory(admitted.runDirectory, ticketNumber); (admitted as { ticketNumber?: number }).ticketNumber = ticketNumber; } /** Persist each direct caller observed while a retained run is resumed. */ export async function recordAdmittedCorrelation( admitted: AdmittedRoleInvocation, correlationId: string, ): Promise { const current = JSON.parse( await readFile(admitted.admittedRequestPath, "utf8"), ) as Record; const prior = [ ...(Array.isArray(current.correlationIds) ? current.correlationIds.filter((value): value is string => typeof value === "string" && value.trim() !== "" ) : []), ...(typeof current.correlationId === "string" && current.correlationId.trim() !== "" ? [current.correlationId] : []), ]; const correlationIds = [...new Set([...prior, correlationId])]; await writeFile( admitted.admittedRequestPath, `${JSON.stringify({ ...current, correlationId, correlationIds }, null, 2)}\n`, "utf8", ); await mergeInvocationIdentityPage(admitted.runDirectory, { correlationId, correlationIds, }); const mutable = admitted as { correlationId?: string; correlationIds?: readonly string[]; }; mutable.correlationId = correlationId; mutable.correlationIds = correlationIds; } /** * #871: persist the typed co-review set as a countersign run fact (admitted-request + * invocation.json). Whole-set replace — never union with a prior set. Main ticket * binding stays on `ticketNumber` alone. Projection reuses the sole contract helper; * principal membership is mandatory. */ export async function bindCourtTicketNumbersOnAdmitted( admitted: AdmittedCountersignInvocation, courtTicketNumbers: readonly number[], ): Promise { if (admitted.ticketNumber === undefined) { throw new Error( "bindCourtTicketNumbersOnAdmitted requires a bound principal ticketNumber", ); } const principal = requireSafePositiveTicketNumber( admitted.ticketNumber, "bindCourtTicketNumbersOnAdmitted principal ticketNumber", ); // Strict write path: every member must already be a lawful number (no soft filter). for (const item of courtTicketNumbers) { if (!isSafePositiveTicketNumber(item)) { throw new Error( `bindCourtTicketNumbersOnAdmitted requires safe positive integers, got ${String(item)}`, ); } } const projected = projectCourtTicketNumbers(courtTicketNumbers, { principalTicket: principal, }); if (projected === null || projected.length === 0) { throw new Error("bindCourtTicketNumbersOnAdmitted requires a non-empty ticket set"); } const frozen = Object.freeze([...projected]); const admittedPath = admitted.admittedRequestPath; const current = JSON.parse(await readFile(admittedPath, "utf8")) as Record< string, unknown >; await writeFile( admittedPath, `${JSON.stringify({ ...current, courtTicketNumbers: frozen }, null, 2)}\n`, "utf8", ); await mergeInvocationIdentityPage(admitted.runDirectory, { courtTicketNumbers: frozen, }); admitted.courtTicketNumbers = frozen; } /** Move a settled first-entry run from unbound to its asserted ticket directory. */ export async function relocateAdmittedRunToTicket( admitted: AdmittedRoleInvocation, authority: DurablePrincipalAuthority, heldLease?: { relocate(runDirectory: string): void }, ): Promise<{ oldRunDirectory: string; newRunDirectory: string } | undefined> { if (admitted.ticketNumber === undefined || !admitted.runDirectory.includes(`${sep}unbound${sep}runs${sep}`)) return undefined; const oldRunDirectory = admitted.runDirectory; const ledgerHome = resolveActivationLedgerHome(homeFromRunDirectory(oldRunDirectory)); const target = roleRunPlacement(ledgerHome, { bookKey: admitted.bookKey, subject: { ticketNumber: admitted.ticketNumber }, runId: admitted.runId, role: admitted.role, }); ensureRoleRunDirectory(ledgerHome, dirname(target.runDirectory)); // Host sealing is pure identity projection, but may reject the coordinates. // Keep that failure before the filesystem commit point. const principal = authority.seal(target); // Rename is the only durable commit. Persisted paths are resolved from typed // run identity on read, so online relocation never writes unleased peers or // pretends a directory rename plus page rewrites form one transaction. await rename(oldRunDirectory, target.runDirectory); // rename moved the open lock inode with the directory. Transfer cleanup // ownership immediately after the commit. heldLease?.relocate(target.runDirectory); const admittedRecord = admitted as unknown as Record; rewriteRunDirectoryPathFields( admittedRecord, [ "runDirectory", "admittedRequestPath", "taskPath", "packetPath", "prerequisitesPath", "requestManifestPath", "mergerInputPath", ], oldRunDirectory, target.runDirectory, ); for (const attachment of admitted.attachments) { (attachment as { frozenPath: string }).frozenPath = rewriteRunDirectoryPathValue( attachment.frozenPath, oldRunDirectory, target.runDirectory, ) as string; } (admitted as { principal: DurablePrincipal }).principal = principal; return { oldRunDirectory, newRunDirectory: target.runDirectory }; } /** * Bind ticket identity onto a run directory's durable pages when the admitted * object is not in hand (起居郎 accept hook after LLM assertion, #771). * Idempotent when the same number is already on the pages. */ export async function bindTicketNumberOnRunDirectory( runDirectory: string, ticketNumber: number, ): Promise { requireSafePositiveTicketNumber( ticketNumber, "bindTicketNumberOnRunDirectory", ); const admittedPath = join(runDirectory, "admitted-request.json"); const invocationPath = join(runDirectory, "invocation.json"); const admitted = JSON.parse(await readFile(admittedPath, "utf8")) as Record< string, unknown >; const existing = admitted.ticketNumber; if (typeof existing === "number") { if (existing === ticketNumber) return; throw new Error( `bindTicketNumberOnRunDirectory refuses to replace existing ticket #${existing} with #${ticketNumber}`, ); } // Conflict guard before any write: crash window of bindAdmittedTicketNumber // can leave invocation bound while admitted-request is still unbound — refuse // silent rebind. Read-before-merge; never check the page just overwritten. if (existsSync(invocationPath)) { const invocation = JSON.parse( await readFile(invocationPath, "utf8"), ) as Record; if ( typeof invocation.ticketNumber === "number" && invocation.ticketNumber !== ticketNumber ) { throw new Error( `bindTicketNumberOnRunDirectory refuses to replace invocation ticket #${invocation.ticketNumber} with #${ticketNumber}`, ); } } await writeFile( admittedPath, `${JSON.stringify({ ...admitted, ticketNumber }, null, 2)}\n`, "utf8", ); await mergeInvocationIdentityPage(runDirectory, { ticketNumber }); } /** Add the identity returned by the production Pi launch seam to its existing ledger page. */ export async function recordLaunchedPiIdentity( runDirectory: string, identity: { executable: string; version: string }, ): Promise { await mergeInvocationIdentityPage(runDirectory, { piExecutable: identity.executable, piVersion: identity.version, }); } /** * Observed role-package launch provenance written onto the same invocation.json page. * Values are field observations from the public CLI activation seam — never fixed schema markers. */ export type LaunchedRolePackageIdentity = { /** Canonical absolute path of the selected Internal role entry (extensions/role-runtime.ts). */ readonly roleEntry: string; /** Canonical absolute package root that owns the entry and bin. */ readonly rolePackageRoot: string; /** package.json version of that root as read at launch. */ readonly rolePackageVersion: string; /** How this process crossed into the role runtime (ADR 0052 public CLI). */ readonly entryMode: "public-cli"; }; /** Read the package root that is about to serve this public run (observed values only). */ export async function observeLaunchedRolePackageIdentity( packageRoot: string, selectedRoleEntry: string, ): Promise { const rolePackageRoot = packageRoot; const raw = JSON.parse( await readFile(join(rolePackageRoot, "package.json"), "utf8"), ) as { version?: unknown }; if (typeof raw.version !== "string" || raw.version.trim() === "") { throw new Error( `role package.json at ${rolePackageRoot} does not declare a nonblank version`, ); } return { roleEntry: selectedRoleEntry, rolePackageRoot, rolePackageVersion: raw.version, entryMode: "public-cli", }; } /** Add the role-package identity resolved at the public launch seam to its existing ledger page. */ export async function recordLaunchedRolePackageIdentity( runDirectory: string, identity: LaunchedRolePackageIdentity, ): Promise { await mergeInvocationIdentityPage(runDirectory, { roleEntry: identity.roleEntry, rolePackageRoot: identity.rolePackageRoot, rolePackageVersion: identity.rolePackageVersion, entryMode: identity.entryMode, }); } export type ParseInstructionArgvResult = { instruction: string; attachmentPaths: string[]; project?: string; /** Auditor only — audited subject selecting soul materials (#675 owner). */ subject?: "judge" | "doctor"; /** Auditor source-run locator — same input surface for direct and nested (#675). */ sourceRun?: string; }; /** Judge/Countersign 命令面同形:--project/--attach/opaque instruction。 */ export type ParseJudgeArgvResult = ParseInstructionArgvResult; export type ParseCountersignArgvResult = ParseInstructionArgvResult; export type ParseInspectorArgvResult = ParseInstructionArgvResult; export type ParseGatekeeperArgvResult = ParseInstructionArgvResult; export type ParseNavigatorArgvResult = ParseInstructionArgvResult; export type ParseDiaristArgvResult = ParseInstructionArgvResult; /** Positive ticket number for analyst query-scope face (and shared integer parse). */ export function parsePositiveTicketNumber( raw: string, flag: string, ): number { const trimmed = raw.trim(); if (!ANALYST_TICKET_NUMBER_PATTERN.test(trimmed)) { throw new CliUsageError(`${flag} must be a positive integer, got ${raw}`); } const value = Number(trimmed); if (!Number.isSafeInteger(value) || value < 1) { throw new CliUsageError(`${flag} must be a positive integer, got ${raw}`); } return value; } /** 共享解析体:同形 owner 的 argv → instruction/attachments/project。 */ function parseInstructionArgv( args: readonly string[], owner: "judge" | "countersign" | "inspector" | "gatekeeper" | "navigator" | "auditor" | "diarist" | "secretariat", ): ParseInstructionArgvResult { const attachmentPaths: string[] = []; let project: string | undefined; let subject: "judge" | "doctor" | undefined; let sourceRun: string | undefined; const positional: string[] = []; const tokens = [...args]; const definitions = roleOptions(owner); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "attach") { attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value)); continue; } if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } if (taken.def.id === "subject") { const raw = typeof taken.value === "string" ? taken.value.trim() : ""; if (raw !== "judge" && raw !== "doctor") { throw new CliUsageError( `auditor --subject must be judge|doctor, got ${taken.value ?? "(missing)"}`, ); } subject = raw; continue; } if (taken.def.id === "source-run") { const raw = typeof taken.value === "string" ? taken.value.trim() : ""; if (raw === "") { throw new CliUsageError("auditor --source-run requires a run locator"); } sourceRun = raw; continue; } throw new CliUsageError(`unknown ${owner} option: ${taken.def.canonical}`); } const token = tokens.shift()!; if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown ${owner} option: ${token}`); } positional.push(token); } options.assertRequired(); return { instruction: positional.join(" "), attachmentPaths, ...(project === undefined ? {} : { project }), ...(subject === undefined ? {} : { subject }), ...(sourceRun === undefined ? {} : { sourceRun }), }; } export type ParseCoderArgvResult = { phase: CoderPhase; instruction: string; attachmentPaths: string[]; project?: string; }; export type ParseFixerArgvResult = { phase: FixerPhase; instruction: string; attachmentPaths: string[]; /** Optional path to structurally valid prerequisite JSON array. */ prerequisitesPath?: string; project?: string; }; export type ParseCollectorArgvResult = { /** Explicit --pr when provided; admission resolves from context when absent (#676 D1). */ prNumber?: number; instruction: string; attachmentPaths: string[]; project?: string; repo?: string; requestManifestPath?: string; /** Optional wait-window ms (#678 D4). */ waitWindowMs?: number; }; export type ParseDoctorArgvResult = { issueNumber: number; /** Optional project-relative retained runs root override. */ runs?: string; instruction: string; attachmentPaths: string[]; project?: string; }; export type ParseReviewerArgvResult = { /** Optional caller prose retained only as admitted provenance. */ instruction: string; attachmentPaths: string[]; /** Required fixed base revision for the pinned review target. */ baseRevision: string; /** * Explicit single-axis override. Omitted public `--lens` is the internal * parallel two-axis branch mark only — never persisted on an admitted run. */ lens?: ReviewerLens; /** Repeatable durable authority references/URLs (exact order preserved; at least one). */ authorityRefs: string[]; project?: string; }; export type ParseGleanerLeftArgvResult = { /** Optional caller prose; empty is the lawful path (无锚定). Must not carry direction. */ instruction: string; /** Required comparison-base revision for the unanchored merge-candidate diff. */ baseRevision: string; project?: string; }; export type ParseMergerArgvResult = { instruction: string; attachmentPaths: string[]; project?: string; }; /** * #336/#337/#338/#399 analyst public argv — three live faces on one registration seam. * - issue (default): bare whole-book or --ticket N (cwd git common-dir) * - sweep (#337): optional positional `sweep` and/or --attach paths; * sweep payload rides exactly one typed JSON attachment (not argv/stdin) * - cohort: two labeled issue-number groups * --project-root deleted; --model-groups public face disabled (library kernel retained). */ export type ParseAnalystIssueArgv = { readonly query: "issue"; /** Caller ticket / issue number face (#176 numbering space). */ readonly ticket?: number; }; export type ParseAnalystSweepArgv = { readonly query: "sweep"; /** * Public CLI attachment paths (--attach). Sweep mode only (#337). * Cardinality validated on the sweep run path (exactly one). */ readonly attachmentPaths: readonly string[]; }; export type ParseAnalystCohortArgv = { readonly query: "cohort"; /** Tokens before cwd-book stamping; bare N resolves at run (#412). */ readonly groups: readonly [ { readonly groupLabel: string; readonly issues: readonly AnalystCohortIssueToken[]; }, { readonly groupLabel: string; readonly issues: readonly AnalystCohortIssueToken[]; }, ]; }; export type ParseAnalystArgvResult = | ParseAnalystIssueArgv | ParseAnalystSweepArgv | ParseAnalystCohortArgv; /** Reject missing/blank path values so empty overrides cannot silently degrade. */ function requireOptionPath( flag: string, value: string | undefined, ): string { if (value === undefined || value.trim() === "") { throw new CliUsageError( flag === "--base" ? `${flag} requires a nonempty revision` : `${flag} requires a path`, ); } return value; } /** * Shared Skill-arg token rule for caller-controlled values projected into the * space-joined Skill invocation line. Rejects blank, whitespace (smuggles the * next option), and a leading `-` (read as the next Skill option). Not a * general free-text gate — only the projection admission seam. */ function requireSkillArgToken( value: string | undefined, messages: { empty: string; whitespace: string; optionLike: string }, ): string { if (value === undefined || value.trim() === "") { throw new CliUsageError(messages.empty); } if (/\s/.test(value)) { throw new CliUsageError(messages.whitespace); } if (value.startsWith("-")) { throw new CliUsageError(messages.optionLike); } return value; } /** * Reviewer --base admission: nonempty single Skill-arg token (shared rule with * requireAuthorityRef). Multi-token / option-like values smuggle extra flags * (e.g. `--lens all`, `--authority x`). */ export function requireReviewerBaseRevision(value: string | undefined): string { return requireSkillArgToken(value, { empty: "--base requires a nonempty revision", whitespace: "--base requires a single-token revision", optionLike: "--base requires a single-token revision", }); } /** Shared ReviewerLens predicate — sole interpretation owner for fresh + durable. */ export function isReviewerLens(value: unknown): value is ReviewerLens { return value === "completeness" || value === "correctness"; } /** Admitted single-axis lens enum; public `--lens all` and bare omission are not admitted values. */ export function requireReviewerLens(value: string | undefined): ReviewerLens { const trimmed = (value ?? "").trim(); if (!isReviewerLens(trimmed)) { throw new CliUsageError("--lens requires completeness or correctness"); } return trimmed; } /** True when token is a retained rejected spelling for the owner (#342). */ function isRejectedPublicSpelling(owner: OptionOwner, token: string): boolean { for (const entry of REJECTED_PUBLIC_SPELLINGS) { if (entry.owner !== owner) continue; for (const spelling of entry.spellings) { if (token === spelling || token.startsWith(`${spelling}=`)) return true; } } return false; } /** Role option definitions — sole spelling source for the matching parser. */ function roleOptions(owner: Exclude): readonly PublicOptionDefinition[] { return optionsForOwner(owner); } /** * Public --authority-ref admission grammar (refs-only). * Unique owner for fresh argv and durable resume restore — no string-only parallel. * Accepts durable reference tokens as-is; rejects blank, inline Spec prose * (whitespace-bearing sentences), and option-like leading `-` (Skill-arg boundary). * Does not fetch, normalize, or judge content. */ export function requireAuthorityRef(value: string | undefined): string { return requireSkillArgToken(value, { empty: "--authority-ref requires a nonempty durable reference", whitespace: "--authority-ref requires a durable reference, not inline Spec prose", optionLike: "--authority-ref requires a durable reference, not inline Spec prose", }); } /** * Parse Judge-specific argv after the `judge` token. * Spellings from PUBLIC_OPTION_TABLE.judge; rejects burden family (#342). */ export function parseJudgeArgv(args: readonly string[]): ParseJudgeArgvResult { // Judge owns burden inference — rejected spellings checked per-token. // `--` ends flag parsing: everything after is opaque instruction. const dd = args.indexOf("--"); const preDd = dd === -1 ? args : args.slice(0, dd); for (const token of preDd) { if (isRejectedPublicSpelling("judge", token)) { throw new CliUsageError( "judge does not accept a public burden selector; Judge infers its own burden", ); } } return parseInstructionArgv(args, "judge"); } export function parseCountersignArgv(args: readonly string[]): ParseCountersignArgvResult { return parseInstructionArgv(args, "countersign"); } export function parseInspectorArgv(args: readonly string[]): ParseInspectorArgvResult { return parseInstructionArgv(args, "inspector"); } export function parseGatekeeperArgv(args: readonly string[]): ParseGatekeeperArgvResult { return parseInstructionArgv(args, "gatekeeper"); } export function parseNavigatorArgv(args: readonly string[]): ParseNavigatorArgvResult { return parseInstructionArgv(args, "navigator"); } export type ParseAuditorArgvResult = ParseInstructionArgvResult; export function parseAuditorArgv(args: readonly string[]): ParseAuditorArgvResult { return parseInstructionArgv(args, "auditor"); } export function parseDiaristArgv(args: readonly string[]): ParseDiaristArgvResult { return parseInstructionArgv(args, "diarist"); } export type ParseSecretariatArgvResult = ParseInstructionArgvResult; export function parseSecretariatArgv(args: readonly string[]): ParseSecretariatArgvResult { return parseInstructionArgv(args, "secretariat"); } /** * Parse Coder-specific argv after the `coder` token. * Phase defaults to apply; spellings from PUBLIC_OPTION_TABLE.coder (#342). */ export function parseCoderArgv(args: readonly string[]): ParseCoderArgvResult { const attachmentPaths: string[] = []; let project: string | undefined; const positional: string[] = []; const tokens = [...args]; const definitions = roleOptions("coder"); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "attach") { attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value)); continue; } if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } throw new CliUsageError(`unknown coder option: ${taken.def.canonical}`); } const token = tokens.shift()!; if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown coder option: ${token}`); } positional.push(token); } // Phase aliases + default come solely from the typed coder phase row (#342). const phase = options.consumeLeadingPhase(positional); options.assertRequired(); return { phase, instruction: positional.join(" "), attachmentPaths, ...(project === undefined ? {} : { project }), }; } /** * Parse Fixer-specific argv after the `fixer` token. * Phase defaults to apply; spellings from PUBLIC_OPTION_TABLE.fixer (#342). */ export function parseFixerArgv(args: readonly string[]): ParseFixerArgvResult { const attachmentPaths: string[] = []; let project: string | undefined; let prerequisitesPath: string | undefined; const positional: string[] = []; const tokens = [...args]; const definitions = roleOptions("fixer"); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "attach") { attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value)); continue; } if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } if (taken.def.id === "prerequisites") { prerequisitesPath = requireOptionPath(taken.def.canonical, taken.value); continue; } throw new CliUsageError(`unknown fixer option: ${taken.def.canonical}`); } const token = tokens.shift()!; if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown fixer option: ${token}`); } positional.push(token); } // Phase aliases + default come solely from the typed fixer phase row (#342). const phase = options.consumeLeadingPhase(positional); options.assertRequired(); return { phase, instruction: positional.join(" "), attachmentPaths, ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }), ...(project === undefined ? {} : { project }), }; } async function readRegularFileAttachment( sourcePath: string, ): Promise<{ absolute: string; bytes: Buffer }> { const absolute = isAbsolute(sourcePath) ? sourcePath : resolve(sourcePath); let st; try { st = await lstat(absolute); } catch (error) { throw new CliUsageError( `attachment is not a readable regular file: ${sourcePath}`, { cause: error }, ); } if (!st.isFile() || st.isSymbolicLink()) { throw new CliUsageError( `attachment must be a regular file (not a directory or symlink): ${sourcePath}`, ); } try { return { absolute, bytes: await readFile(absolute) }; } catch (error) { throw new CliUsageError( `attachment is not a readable regular file: ${sourcePath}`, { cause: error }, ); } } export type PreparedAttachment = { readonly absolute: string; readonly snapshotPath: string; }; /** * Snapshot deferred inputs sequentially before identity side effects. The staging * files keep aggregate attachment bytes off heap until their final run is known. */ async function removePreparedAttachmentDirectory(stagingDirectory: string): Promise { await rm(stagingDirectory, { recursive: true, force: true }); } /** Own the complete deferred-snapshot lifetime without masking either failure. */ export async function withPreparedAttachments( attachmentPaths: readonly string[], use: (prepared: readonly PreparedAttachment[]) => Promise, ): Promise { if (attachmentPaths.length === 0) return await use([]); const stagingDirectory = await mkdtemp(join(tmpdir(), "ak-role-attachments-")); const prepared: PreparedAttachment[] = []; let result: T; try { for (let index = 0; index < attachmentPaths.length; index += 1) { const { absolute, bytes } = await readRegularFileAttachment(attachmentPaths[index]!); const snapshotPath = join(stagingDirectory, String(index).padStart(6, "0")); await writeFile(snapshotPath, bytes); prepared.push({ absolute, snapshotPath }); } result = await use(prepared); } catch (primary) { try { await removePreparedAttachmentDirectory(stagingDirectory); } catch (cleanup) { throw new AggregateError( [primary, cleanup], "attachment snapshot operation failed and cleanup also failed", { cause: primary }, ); } throw primary; } await removePreparedAttachmentDirectory(stagingDirectory); return result; } async function freezeAttachmentBytes( provenancePath: string, bytes: Buffer, destinationDir: string, index: number, ): Promise { const frozenPath = join( destinationDir, `${String(index).padStart(2, "0")}-${basename(provenancePath)}`, ); await writeFile(frozenPath, bytes); return { provenancePath, frozenPath, byteLength: bytes.byteLength, sha256: sha256Hex(bytes), mediaKind: "regular-file", }; } async function freezePreparedAttachment( prepared: PreparedAttachment, destinationDir: string, index: number, ): Promise { return freezeAttachmentBytes( prepared.absolute, await readFile(prepared.snapshotPath), destinationDir, index, ); } async function freezeRegularFileAttachment( sourcePath: string, destinationDir: string, index: number, ): Promise<{ attachment: FrozenAttachment; body: Buffer }> { const { absolute, bytes } = await readRegularFileAttachment(sourcePath); return { attachment: await freezeAttachmentBytes(absolute, bytes, destinationDir, index), body: bytes, }; } /** Freeze attachments only — ticket binding is the shared LLM seat path (#635). */ async function freezeAttachments( attachmentPaths: readonly string[], attachmentsDirectory: string, ): Promise { const attachments: FrozenAttachment[] = []; for (let i = 0; i < attachmentPaths.length; i += 1) { const frozen = await freezeRegularFileAttachment( attachmentPaths[i]!, attachmentsDirectory, i, ); attachments.push(frozen.attachment); } return attachments; } /** * Freeze summons attachments into an already-retained run (#637 same-ticket resume). * Writes under attachments/summons-/ so prior freeze names stay intact. * Manual resume never calls this — birth attachments keep their original semantics. */ export async function freezeAttachmentsIntoRun( attachmentPaths: readonly string[], runDirectory: string, summonsKey: string = `s-${Date.now().toString(36)}`, ): Promise { if (attachmentPaths.length === 0) return []; const ledgerHome = resolveActivationLedgerHome(homeFromRunDirectory(runDirectory)); const attachmentsDirectory = join(runDirectory, "attachments", summonsKey); ensureRealDirectoryTree(ledgerHome, attachmentsDirectory); return freezeAttachments(attachmentPaths, attachmentsDirectory); } /** Freeze prepared bytes and metadata through one path for birth or retained runs. */ export async function freezePreparedAttachmentsIntoRun( prepared: readonly PreparedAttachment[], runDirectory: string, summonsKey?: string, ): Promise { if (prepared.length === 0) return []; const ledgerHome = resolveActivationLedgerHome(homeFromRunDirectory(runDirectory)); const attachmentsDirectory = summonsKey === undefined ? join(runDirectory, "attachments") : join(runDirectory, "attachments", summonsKey); ensureRealDirectoryTree(ledgerHome, attachmentsDirectory); const attachments: FrozenAttachment[] = []; for (let index = 0; index < prepared.length; index += 1) { attachments.push(await freezePreparedAttachment(prepared[index]!, attachmentsDirectory, index)); } return attachments; } function ticketAdmissionFields( ticketNumber: number | undefined, ): { ticketNumber?: number } { if (ticketNumber === undefined) return {}; return { ticketNumber: requireSafePositiveTicketNumber( ticketNumber, "assertedTicketNumber", ), }; } export type AdmitJudgeInvocationOptions = { home: string; cwd: string; instruction: string; attachmentPaths: readonly string[]; project?: string; /** Injectable clock/id for tests. */ createRunId?: () => string; principalAuthority: DurablePrincipalAuthority; /** Effective model for this invocation — written onto invocation.json. */ model?: InvocationEffectiveModel; }; export type AdmitInspectorInvocationOptions = AdmitJudgeInvocationOptions & { correlationId?: string; /** Typed identity handed off by 起居郎 or inherited from an already-bound run. */ assertedTicketNumber?: number; }; export type AdmitGatekeeperInvocationOptions = AdmitInspectorInvocationOptions; export type AdmitNavigatorInvocationOptions = AdmitInspectorInvocationOptions; export type AdmitDiaristInvocationOptions = AdmitInspectorInvocationOptions; export type AdmitSecretariatInvocationOptions = AdmitInspectorInvocationOptions; /** * Shared instruction-seat admission for Judge and Inspector: project check, * principal/placement issue, attachment freeze, admitted-request and invocation * ledger write. CorrelationId is projected only when supplied (Inspector). * Ticket binding is post-admission via shared seat LLM path (#635). */ async function admitStandardMaterialInvocation< R extends "judge" | "inspector" | "gatekeeper" | "navigator" | "auditor" | "diarist" | "secretariat", >( role: R, options: AdmitInspectorInvocationOptions, ): Promise { // Empty project override must not reach resolve("") → cwd (silent default). if (options.project !== undefined) { requireOptionPath("--project", options.project); } const projectRoot = resolve(options.project ?? options.cwd); const runId = (options.createRunId ?? uuidv7)(); // Validate asserted ticket before placement so 0/NaN never become subjects. const ticketFields = ticketAdmissionFields(options.assertedTicketNumber); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role, subject: ticketFields.ticketNumber === undefined ? { unbound: true } : { ticketNumber: ticketFields.ticketNumber }, home: options.home, }); const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory); const correlationFields = options.correlationId === undefined ? {} : { correlationId: options.correlationId }; const instruction = options.instruction; const instructionEmpty = instruction.trim() === ""; const admitted = { role, runId, bookKey, projectRoot, runDirectory, principal, ...correlationFields, instruction, instructionEmpty, attachments: attachments.map((a) => ({ provenancePath: a.provenancePath, frozenPath: a.frozenPath, byteLength: a.byteLength, sha256: a.sha256, mediaKind: a.mediaKind, })), ...ticketFields, }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger( { ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model, ); return { role, runId, bookKey, projectRoot, instruction, instructionEmpty, attachments, runDirectory, principal, admittedRequestPath, ...correlationFields, ...ticketFields, }; } /** * Atomically admit a Judge Role run: freeze Attachments, persist the request, * and reserve session placement under the #78 ledger book. */ export async function admitJudgeInvocation( options: AdmitJudgeInvocationOptions, ): Promise { return admitStandardMaterialInvocation("judge", options); } /** * Admit a direct Inspector (台院) run: freeze attachments, persist the request, * and reserve session placement. Same instruction-seat face as Judge (#568). */ export async function admitInspectorInvocation( options: AdmitInspectorInvocationOptions, ): Promise { return admitStandardMaterialInvocation("inspector", options); } /** * Admit a direct Gatekeeper (门下省) run (#639): freeze attachments, persist * the request, reserve session placement — same instruction-seat face. */ export async function admitGatekeeperInvocation( options: AdmitGatekeeperInvocationOptions, ): Promise { return admitStandardMaterialInvocation("gatekeeper", options); } /** * Admit a direct Navigator (游奕使) run (#639): freeze attachments, persist * the request, reserve session placement — same instruction-seat face. */ export async function admitNavigatorInvocation( options: AdmitNavigatorInvocationOptions, ): Promise { return admitStandardMaterialInvocation("navigator", options); } export type AdmitAuditorInvocationOptions = AdmitInspectorInvocationOptions; /** Admit a public 审刑院 run (#675). */ export async function admitAuditorInvocation( options: AdmitAuditorInvocationOptions, ): Promise { return admitStandardMaterialInvocation("auditor", options); } /** * Admit a direct Diarist (起居郎) run (#708): freeze attachments, persist the * request, reserve session placement — same instruction-seat face. */ export async function admitDiaristInvocation( options: AdmitDiaristInvocationOptions, ): Promise { return admitStandardMaterialInvocation("diarist", options); } /** Admit a public Secretariat (中书省) run (#924). */ export async function admitSecretariatInvocation( options: AdmitSecretariatInvocationOptions, ): Promise { return admitStandardMaterialInvocation("secretariat", options); } /** Shared prompt transport for instruction-seat roles (judge/countersign/inspector). */ export function buildInstructionTransportPrompt( admitted: { instruction: string; instructionEmpty: boolean; attachments: readonly { frozenPath: string }[] }, engineMaterial?: EngineSessionMaterial, ): string { const lines: string[] = [admitted.instructionEmpty ? "" : admitted.instruction]; if (admitted.attachments.length > 0) { lines.push(""); lines.push("已受理附件(冻结快照路径):"); for (const attachment of admitted.attachments) { lines.push(`- ${attachment.frozenPath}`); } } return appendEngineSessionMaterial(lines, engineMaterial).join("\n"); } /** Build the Pi prompt transport for an admitted Judge request. */ export function buildJudgeTransportPrompt( admitted: AdmittedJudgeInvocation, engineMaterial?: EngineSessionMaterial, ): string { return buildInstructionTransportPrompt(admitted, engineMaterial); } export function buildSecretariatTransportPrompt( admitted: AdmittedSecretariatInvocation, engineMaterial?: EngineSessionMaterial, ): string { return buildInstructionTransportPrompt(admitted, engineMaterial); } export function buildInspectorTransportPrompt( admitted: AdmittedInspectorInvocation, engineMaterial?: EngineSessionMaterial, ): string { return buildInstructionTransportPrompt(admitted, engineMaterial); } export type AdmitCountersignInvocationOptions = { home: string; cwd: string; instruction: string; attachmentPaths: readonly string[]; project?: string; /** Injectable clock/id for tests. */ createRunId?: () => string; principalAuthority: DurablePrincipalAuthority; /** Effective model for this invocation — written onto invocation.json. */ model?: InvocationEffectiveModel; correlationId?: string; /** Same-ticket lookup may select an existing run before a new run is persisted. */ deferPersistence?: boolean; }; /** * Admit a Countersign run immediately, or reserve its coordinates for deferred * materialization after identity lookup (#572 / ADR 0074 / #863). */ export async function admitCountersignInvocation( options: AdmitCountersignInvocationOptions, ): Promise { // Same shared freeze/coordinate body as judge but role: "countersign" so // the ledger and session coordinates use the correct role from the start. if (options.project !== undefined) { requireOptionPath("--project", options.project); } const projectRoot = resolve(options.project ?? options.cwd); const runId = (options.createRunId ?? uuidv7)(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "countersign", subject: { unbound: true }, home: options.home, materialize: options.deferPersistence !== true, }); const attachments = options.deferPersistence === true ? [] : await freezeAttachments(options.attachmentPaths, attachmentsDirectory); // Ticket binding is LLM-only post-admission (#635); admission stays unbound. const instruction = options.instruction; const instructionEmpty = instruction.trim() === ""; const admitted = { role: "countersign" as const, runId, bookKey, projectRoot, runDirectory, principal, ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }), instruction, instructionEmpty, attachments: attachments.map((a) => ({ provenancePath: a.provenancePath, frozenPath: a.frozenPath, byteLength: a.byteLength, sha256: a.sha256, mediaKind: a.mediaKind, })), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); if (options.deferPersistence !== true) { await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model); } return { role: "countersign", runId, bookKey, projectRoot, instruction, instructionEmpty, attachments, runDirectory, principal, admittedRequestPath, ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }), }; } /** Persist a deferred Countersign admission after same-ticket lookup found no retained run. */ export async function materializeCountersignInvocation( admitted: AdmittedCountersignInvocation, options: Pick & { preparedAttachments: readonly PreparedAttachment[]; }, ): Promise { const placement = issueAdmissionPlacement(options.principalAuthority, { cwd: admitted.projectRoot, runId: admitted.runId, role: "countersign", subject: { unbound: true }, home: options.home, }); const attachments = await freezePreparedAttachmentsIntoRun( options.preparedAttachments, admitted.runDirectory, ); (admitted as { attachments: readonly FrozenAttachment[] }).attachments = attachments; await writeAdmittedRequestPersistence(admitted.admittedRequestPath, admitted, { sessionDirectory: placement.sessionDirectory, sessionFile: placement.sessionFile, }); await writeRoleInvocationLedger( { ...admitted, sessionDirectory: placement.sessionDirectory, sessionFile: placement.sessionFile }, admitted.role, options.model, ); } /** Build the Pi prompt transport for an admitted Countersign request. */ export function buildCountersignTransportPrompt( admitted: AdmittedCountersignInvocation, engineMaterial?: EngineSessionMaterial, ): string { return buildInstructionTransportPrompt(admitted, engineMaterial); } /** Load admitted-request.json written at admission (Navigator work-context seam). */ export async function loadAdmittedJudgeRequest( runDirectory: string, ): Promise<{ instruction: string; instructionEmpty: boolean; attachments: readonly FrozenAttachment[]; } | undefined> { try { const raw = JSON.parse( await readFile(join(runDirectory, "admitted-request.json"), "utf8"), ) as unknown; if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined; const record = raw as Record; if (record.role !== "judge") return undefined; if (typeof record.instruction !== "string") return undefined; if (typeof record.instructionEmpty !== "boolean") return undefined; if (!Array.isArray(record.attachments)) return undefined; return { instruction: record.instruction, instructionEmpty: record.instructionEmpty, attachments: record.attachments as FrozenAttachment[], }; } catch { return undefined; } } export async function ensureRunArtifactsDir(runDirectory: string): Promise { const directory = roleRunArtifactsDirectory(runDirectory); return ensureRoleRunDirectory( resolveActivationLedgerHome(homeFromRunDirectory(runDirectory)), directory, ); } export type AdmitCoderInvocationOptions = { home: string; principalAuthority: DurablePrincipalAuthority; cwd: string; phase: CoderPhase; instruction: string; attachmentPaths: readonly string[]; project?: string; createRunId?: () => string; /** Effective model for this invocation — written onto invocation.json. */ model?: InvocationEffectiveModel; }; /** * Admit a Coder Role run on the common Invocation request. * Nonblank task remains authoritative: blank instruction is a structural reject. * Phase (default apply / explicit plan) is frozen into the admitted request. */ export async function admitCoderInvocation( options: AdmitCoderInvocationOptions, ): Promise { if (options.project !== undefined) { requireOptionPath("--project", options.project); } const instruction = options.instruction; if (instruction.trim() === "") { throw new CliUsageError( "coder requires a nonblank task instruction", ); } if (options.phase !== "plan" && options.phase !== "apply") { throw new CliUsageError("coder phase must be plan or apply"); } const projectRoot = resolve(options.project ?? options.cwd); const runId = (options.createRunId ?? uuidv7)(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "coder", subject: { unbound: true }, home: options.home, }); const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory); const taskPath = join(runDirectory, "task.md"); await writeFile(taskPath, instruction, "utf8"); const admitted = { role: "coder" as const, phase: options.phase, runId, bookKey, projectRoot, runDirectory, principal, instruction, instructionEmpty: false, taskPath, attachments: attachments.map((a) => ({ provenancePath: a.provenancePath, frozenPath: a.frozenPath, byteLength: a.byteLength, sha256: a.sha256, mediaKind: a.mediaKind, })), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model); return { role: "coder", phase: options.phase, runId, bookKey, projectRoot, instruction, instructionEmpty: false, attachments, runDirectory, principal, admittedRequestPath, taskPath, }; } /** * Build the Pi prompt transport for an admitted Coder request. * Task bytes already live at taskPath for --ak-coder-task; the prompt carries * the same instruction plus frozen Attachment paths. */ export function buildCoderTransportPrompt( admitted: AdmittedCoderInvocation, engineMaterial?: EngineSessionMaterial, ): string { const lines: string[] = [admitted.instruction]; if (admitted.attachments.length > 0) { lines.push(""); lines.push("已受理附件(冻结快照路径):"); for (const attachment of admitted.attachments) { lines.push(`- ${attachment.frozenPath}`); } } return appendEngineSessionMaterial(lines, engineMaterial).join("\n"); } export type AdmitFixerInvocationOptions = { home: string; principalAuthority: DurablePrincipalAuthority; cwd: string; phase: FixerPhase; instruction: string; attachmentPaths: readonly string[]; /** Optional caller path to prerequisite JSON array; malformed grammar rejects here. */ prerequisitesPath?: string; project?: string; createRunId?: () => string; /** Effective model for this invocation — written onto invocation.json. */ model?: InvocationEffectiveModel; }; /** * Admit a Fixer Role run on the common Invocation request plus optional prerequisites. * Nonblank instruction remains authoritative. Phase defaults to apply at parse time. * Prerequisite grammar is structural; unmet/insufficient prerequisites stay Fixer judgments. */ export async function admitFixerInvocation( options: AdmitFixerInvocationOptions, ): Promise { if (options.project !== undefined) { requireOptionPath("--project", options.project); } const instruction = options.instruction; if (instruction.trim() === "") { throw new CliUsageError( "fixer requires a nonblank repair instruction", ); } if (options.phase !== "plan" && options.phase !== "apply") { throw new CliUsageError("fixer phase must be plan or apply"); } // Validate/read prerequisites before freezing request materials. let prerequisites: readonly FixerPrerequisite[] = Object.freeze([]); let prerequisitesSource: string | undefined; if (options.prerequisitesPath !== undefined) { const absolutePrereq = isAbsolute(options.prerequisitesPath) ? options.prerequisitesPath : resolve(options.prerequisitesPath); try { prerequisitesSource = await readFile(absolutePrereq, "utf8"); } catch (error) { throw new CliUsageError( `fixer prerequisites path is unreadable: ${options.prerequisitesPath}`, { cause: error }, ); } try { prerequisites = parseFixerPrerequisites(prerequisitesSource); } catch (error) { if (error instanceof FixerPacketValidationError) { throw new CliUsageError(error.message, { cause: error }); } throw error; } } const projectRoot = resolve(options.project ?? options.cwd); const runId = (options.createRunId ?? uuidv7)(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "fixer", subject: { unbound: true }, home: options.home, }); const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory); let prerequisitesPath: string | undefined; if (prerequisitesSource !== undefined) { prerequisitesPath = join(runDirectory, "prerequisites.json"); await writeFile( prerequisitesPath, `${JSON.stringify(prerequisites, null, 2)}\n`, "utf8", ); } const packetPath = join(runDirectory, "fix-packet.md"); await writeFile(packetPath, instruction, "utf8"); const admitted = { role: "fixer" as const, phase: options.phase, runId, bookKey, projectRoot, runDirectory, principal, instruction, instructionEmpty: false, packetPath, ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }), prerequisites: prerequisites.map((entry) => ({ id: entry.id, requirement: entry.requirement, })), attachments: attachments.map((a) => ({ provenancePath: a.provenancePath, frozenPath: a.frozenPath, byteLength: a.byteLength, sha256: a.sha256, mediaKind: a.mediaKind, })), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model); return { role: "fixer", phase: options.phase, runId, bookKey, projectRoot, instruction, instructionEmpty: false, attachments, runDirectory, principal, admittedRequestPath, packetPath, ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }), prerequisites, }; } /** * Build the Pi prompt transport for an admitted Fixer request. * Instruction bytes live at packetPath; prerequisites at optional path. * Diagnosis method is available via package --skill, not forced into this prompt. */ export function buildFixerTransportPrompt( admitted: AdmittedFixerInvocation, engineMaterial?: EngineSessionMaterial, ): string { const lines: string[] = [admitted.instruction]; if (admitted.attachments.length > 0) { lines.push(""); lines.push("已受理附件(冻结快照路径):"); for (const attachment of admitted.attachments) { lines.push(`- ${attachment.frozenPath}`); } } return appendEngineSessionMaterial(lines, engineMaterial).join("\n"); } function parsePositivePrOption(raw: string | undefined): number { if (raw === undefined || raw.trim() === "") throw new CliUsageError("--pr requires a positive pull request number"); try { return parseCollectorPrNumber(raw); } catch (error) { throw new CliUsageError(error instanceof Error ? error.message : String(error), { cause: error }); } } function parseRepoOption(raw: string | undefined): string { if (raw === undefined || raw.trim() === "") throw new CliUsageError("--repo requires owner/repo"); return raw; } export function parseCollectorArgv(args: readonly string[]): ParseCollectorArgvResult { // Spellings from PUBLIC_OPTION_TABLE.collector (#342). const attachmentPaths: string[] = []; let project: string | undefined; let repo: string | undefined; let prNumber: number | undefined; let requestManifestPath: string | undefined; let waitWindowMs: number | undefined; const positional: string[] = []; const tokens = [...args]; const definitions = roleOptions("collector"); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "attach") { attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value)); continue; } if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } if (taken.def.id === "pr") { prNumber = parsePositivePrOption(taken.value); continue; } if (taken.def.id === "repo") { repo = parseRepoOption(taken.value); continue; } if (taken.def.id === "request-manifest") { requestManifestPath = requireOptionPath(taken.def.canonical, taken.value); continue; } if (taken.def.id === "wait-ms") { if (taken.value === undefined || !/^[1-9]\d*$/.test(taken.value.trim())) { throw new CliUsageError("--wait-ms requires a positive safe-integer millisecond value"); } const parsed = Number(taken.value.trim()); if (!Number.isSafeInteger(parsed) || parsed < 1) { throw new CliUsageError("--wait-ms requires a positive safe-integer millisecond value"); } waitWindowMs = parsed; continue; } throw new CliUsageError(`unknown collector option: ${taken.def.canonical}`); } const token = tokens.shift()!; if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown collector option: ${token}`); } positional.push(token); } // Unconditional required from typed table via shared consumer (#342). // #676 D1: --pr is optional; ambiguous targets reject at admission, not by guessing. options.assertRequired(); return { ...(prNumber === undefined ? {} : { prNumber }), instruction: positional.join(" "), attachmentPaths, ...(project === undefined ? {} : { project }), ...(repo === undefined ? {} : { repo }), ...(requestManifestPath === undefined ? {} : { requestManifestPath }), ...(waitWindowMs === undefined ? {} : { waitWindowMs }), }; } /** * Resolve owner/repo from the project's `origin` remote (github.com only). * Supports https and SSH GitHub URL shapes; never scrapes instruction prose. * Missing origin / non-github remote → usage. Git execution failure → true cause (exit 1). */ export function resolveGitHubRemoteRepository( projectRoot: string, ): CollectorRepository { let remoteUrl: string; try { remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }).trim(); } catch (error) { // git remote get-url exit 2 = no such remote; other failures keep true cause. if (isGitRemoteMissing(error)) { throw new CliUsageError( "collector requires a github.com origin remote or an explicit --repo owner/repo", { cause: error }, ); } throw new Error("collector git failed: cannot read origin remote URL", { cause: error instanceof Error ? error : new Error(String(error)), }); } if (remoteUrl.length === 0) { throw new CliUsageError( "collector requires a github.com origin remote or an explicit --repo owner/repo", ); } const ownerRepo = ownerRepoFromGitHubRemoteUrl(remoteUrl); if (ownerRepo === undefined) { throw new CliUsageError( `collector origin remote must be a github.com owner/repo URL, got ${remoteUrl}`, ); } try { return parseCollectorRepository(ownerRepo); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new CliUsageError(detail, { cause: error }); } } /** * git remote get-url: exit 2 = no such remote on common git (missing config). * Other statuses keep true cause — do not broaden into usage (#676 B). */ function isGitRemoteMissing(error: unknown): boolean { if (typeof error !== "object" || error === null) return false; const status = (error as { status?: unknown }).status; return status === 2; } export type AdmitCollectorInvocationOptions = { home: string; principalAuthority: DurablePrincipalAuthority; cwd: string; /** Explicit PR when provided; resolved from context when absent (#676 D1). */ prNumber?: number; instruction?: string; attachmentPaths?: readonly string[]; project?: string; /** Explicit owner/repo override; defaults from project origin remote. */ repo?: string; /** Optional public request configuration; copied into the admitted run. */ requestManifestPath?: string; /** Optional wait-window ms (#678 D4). */ waitWindowMs?: number; createRunId?: () => string; /** Effective model for this invocation — written onto invocation.json. */ model?: InvocationEffectiveModel; }; /** * Admit a Collector Role run: assemble the retained leg manifest from typed * declarations, resolve repository + PR target (#676 D1), and place the session under #78. * Explicit PR is not preflighted for existence; context resolution uses online association. */ export async function admitCollectorInvocation( options: AdmitCollectorInvocationOptions, ): Promise { if (options.project !== undefined) { requireOptionPath("--project", options.project); } let explicitPrNumber: number | undefined; if (options.prNumber !== undefined) { try { explicitPrNumber = parseCollectorPrNumber(options.prNumber); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new CliUsageError(detail, { cause: error }); } } const projectRoot = resolve(options.project ?? options.cwd); let repository: CollectorRepository; if (options.repo !== undefined) { try { repository = parseCollectorRepository(options.repo); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new CliUsageError(detail, { cause: error }); } } else { repository = resolveGitHubRemoteRepository(projectRoot); } // Validate optional request-manifest before freezing request materials. let manifest = emptyCollectorManifest(); let manifestCanonicalJson: string | undefined; if (options.requestManifestPath !== undefined) { try { manifest = await loadCollectorManifest(options.requestManifestPath); manifestCanonicalJson = manifest.canonicalJson; } catch (error) { throw new CliUsageError(error instanceof Error ? error.message : String(error), { cause: error }); } } const manifestDigest = manifest.digest; const runId = (options.createRunId ?? uuidv7)(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "collector", subject: { unbound: true }, home: options.home, }); // #676 A: freeze task materials BEFORE target resolution so the role receives // real instruction + attachments. Admission binds only explicit --pr or unique // head/commit association — task-text scrape is not a target lock. const attachments = await freezeAttachments(options.attachmentPaths ?? [], attachmentsDirectory); const instruction = options.instruction ?? ""; const instructionEmpty = instruction.trim() === ""; const target = await resolveCollectorTarget({ projectRoot, repository, ...(explicitPrNumber === undefined ? {} : { explicitPrNumber }), }); const prNumber = target.kind === "bound" ? target.prNumber : undefined; let requestManifestPath: string | undefined; if (manifestCanonicalJson !== undefined) { requestManifestPath = join(runDirectory, "request-manifest.json"); await writeFile(requestManifestPath, manifestCanonicalJson, "utf8"); } const admitted = { role: "collector" as const, runId, bookKey, projectRoot, runDirectory, principal, instruction, instructionEmpty, ...(prNumber === undefined ? {} : { prNumber }), repository: repository.canonical, repositoryDisplay: repository.display, ...(requestManifestPath === undefined ? {} : { requestManifestPath }), ...(options.waitWindowMs === undefined ? {} : { waitWindowMs: options.waitWindowMs }), manifestDigest, attachments: attachments.map((a) => ({ provenancePath: a.provenancePath, frozenPath: a.frozenPath, byteLength: a.byteLength, sha256: a.sha256, mediaKind: a.mediaKind, })), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model); return { role: "collector", runId, bookKey, projectRoot, instruction, instructionEmpty, attachments, runDirectory, principal, admittedRequestPath, ...(prNumber === undefined ? {} : { prNumber }), repository, ...(requestManifestPath === undefined ? {} : { requestManifestPath }), ...(options.waitWindowMs === undefined ? {} : { waitWindowMs: options.waitWindowMs }), manifestDigest, }; } /** * #676 A: Collector consumes the real call task + frozen attachments so the role * can identify issue/PR from materials via ak_collector_bind_target. Explicit --pr * still wins at admission; unique head/commit association also binds. No fixed * kickoff rewrite of the caller task; no mechanical task-text target lock. */ export function buildCollectorTransportPrompt( admitted: AdmittedCollectorInvocation, engineMaterial?: EngineSessionMaterial, ): string { return buildInstructionTransportPrompt(admitted, engineMaterial); } /** Positive Issue number grammar shared with Doctor case path identity. */ const DOCTOR_ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/; /** Match retained Doctor case runs roots (ADR 0017 / loadDoctorCase). */ /** Canonical Doctor case: `//runs`. Legacy `issues//runs` is read-only compat. */ const DOCTOR_CASE_RUNS_PATH_PATTERN = /\/\.ak-roles\/books\/[^/]+\/(?:issues\/)?([1-9]\d*)\/runs$/; /** * Parse a positive Issue number for public Doctor admission. * Leading zeros and non-integers are structural rejects. */ export function parseDoctorIssueNumber(raw: string): number { const trimmed = raw.trim(); if (!DOCTOR_ISSUE_NUMBER_PATTERN.test(trimmed)) { throw new CliUsageError( `doctor --issue must be a positive integer, got ${raw}`, ); } return Number(trimmed); } /** * Parse Doctor-specific argv after the `doctor` token. * Requires --issue; optional confined --runs override; common --attach/--project. */ export function parseDoctorArgv(args: readonly string[]): ParseDoctorArgvResult { // Spellings from PUBLIC_OPTION_TABLE.doctor (#342). const attachmentPaths: string[] = []; let project: string | undefined; let issueRaw: string | undefined; let runs: string | undefined; const positional: string[] = []; const tokens = [...args]; const definitions = roleOptions("doctor"); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "issue") { if (taken.value === undefined || taken.value.trim() === "") { throw new CliUsageError("doctor --issue requires a positive integer"); } issueRaw = taken.value; continue; } if (taken.def.id === "runs") { if (taken.value === undefined || taken.value.trim() === "") { throw new CliUsageError("doctor --runs requires a path"); } runs = taken.value; continue; } if (taken.def.id === "attach") { attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value)); continue; } if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } throw new CliUsageError(`unknown doctor option: ${taken.def.canonical}`); } const token = tokens.shift()!; if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown doctor option: ${token}`); } positional.push(token); } // Unconditional required (e.g. --issue) from typed table via shared consumer (#342). options.assertRequired(); const issueNumber = parseDoctorIssueNumber(issueRaw!); if (runs !== undefined && runs.trim() === "") { throw new CliUsageError("doctor --runs requires a path"); } return { issueNumber, instruction: positional.join(" "), attachmentPaths, ...(project === undefined ? {} : { project }), ...(runs === undefined ? {} : { runs }), }; } export type AdmitDoctorInvocationOptions = { home: string; principalAuthority: DurablePrincipalAuthority; cwd: string; issueNumber: number; /** Optional project-relative retained runs root override. */ runs?: string; instruction?: string; attachmentPaths?: readonly string[]; project?: string; createRunId?: () => string; /** Effective model for this invocation — written onto invocation.json. */ model?: InvocationEffectiveModel; }; /** * Resolve the retained Doctor case runs root from Issue identity. * Default is the #78 book locator; optional --runs must stay project-confined * and match Doctor case grammar for the same issue number. */ export async function resolveDoctorCaseRunsPath(options: { home: string; projectRoot: string; bookKey: string; issueNumber: number; runs?: string; }): Promise { const ledgerHome = resolveActivationLedgerHome(options.home); // Canonical topology: //runs (docs/dossier-topology.md). const defaultRuns = join( activationBookDirectory(ledgerHome, options.bookKey), String(options.issueNumber), "runs", ); if (options.runs === undefined) { return defaultRuns; } const raw = options.runs.trim(); if (raw === "") { throw new CliUsageError("doctor --runs requires a path"); } // Project-relative only — absolute overrides would bypass confinement. if (isAbsolute(raw)) { throw new CliUsageError( "doctor --runs must be a project-relative path", ); } const resolved = resolve(options.projectRoot, raw); if ( resolved !== options.projectRoot && !pathContainedIn(options.projectRoot, resolved) ) { throw new CliUsageError( "doctor --runs escapes the project root", ); } let real: string; try { real = await realpath(resolved); } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new CliUsageError( `doctor --runs is not a readable retained runs root: ${detail}`, { cause: error }, ); } const normalized = real.split(sep).join("/"); const match = normalized.match(DOCTOR_CASE_RUNS_PATH_PATTERN); if (!match) { throw new CliUsageError( "doctor --runs must be an .ak-roles/books///runs directory", ); } if (Number(match[1]) !== options.issueNumber) { throw new CliUsageError( `doctor --runs issue ${match[1]} does not match --issue ${options.issueNumber}`, ); } return real; } /** * Admit a Doctor Role run: resolve Issue → retained runs root via #78 (or a * confined override), construct the structurally exact case identity through * loadDoctorCase, and place the Doctor session under the book runs lane. * Does not copy session content into a second store. */ export async function admitDoctorInvocation( options: AdmitDoctorInvocationOptions, ): Promise { if (options.project !== undefined) { requireOptionPath("--project", options.project); } if ( !Number.isInteger(options.issueNumber) || options.issueNumber < 1 || !DOCTOR_ISSUE_NUMBER_PATTERN.test(String(options.issueNumber)) ) { throw new CliUsageError( `doctor --issue must be a positive integer, got ${options.issueNumber}`, ); } const projectRoot = resolve(options.project ?? options.cwd); const runId = (options.createRunId ?? uuidv7)(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "doctor", subject: { unbound: true }, home: options.home, }); let caseRunsPath: string; try { caseRunsPath = await resolveDoctorCaseRunsPath({ home: options.home, projectRoot, bookKey, issueNumber: options.issueNumber, ...(options.runs === undefined ? {} : { runs: options.runs }), }); } catch (error) { if (error instanceof CliUsageError) throw error; const detail = error instanceof Error ? error.message : String(error); throw new CliUsageError(detail, { cause: error }); } // Default #78 locator may not exist yet — ensure the empty runs root so // loadDoctorCase can form an empty case and Doctor's refusal owns insufficiency. if (options.runs === undefined) { ensureRealDirectoryTree(ledgerHome, caseRunsPath); } let caseIdentity: DoctorCaseIdentity; try { const patient = await loadDoctorCase(caseRunsPath); if (patient.identity.issueNumber !== options.issueNumber) { throw new CliUsageError( `doctor case issue ${patient.identity.issueNumber} does not match --issue ${options.issueNumber}`, ); } caseIdentity = patient.identity; caseRunsPath = await realpath(caseRunsPath); } catch (error) { if (error instanceof CliUsageError) throw error; const detail = error instanceof Error ? error.message : String(error); throw new CliUsageError( `doctor case could not be constructed from retained evidence: ${detail}`, { cause: error }, ); } const attachments = await freezeAttachments(options.attachmentPaths ?? [], attachmentsDirectory); const instruction = options.instruction ?? ""; const instructionEmpty = instruction.trim() === ""; const admitted = { role: "doctor" as const, runId, bookKey, projectRoot, runDirectory, principal, instruction, instructionEmpty, issueNumber: options.issueNumber, caseRunsPath, caseIdentity, attachments: attachments.map((a) => ({ provenancePath: a.provenancePath, frozenPath: a.frozenPath, byteLength: a.byteLength, sha256: a.sha256, mediaKind: a.mediaKind, })), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model); return { role: "doctor", runId, bookKey, projectRoot, instruction, instructionEmpty, attachments, runDirectory, principal, admittedRequestPath, issueNumber: options.issueNumber, caseRunsPath, caseIdentity, }; } /** Build the Pi prompt transport for an admitted Doctor request. */ export function buildDoctorTransportPrompt( admitted: AdmittedDoctorInvocation, engineMaterial?: EngineSessionMaterial, ): string { const lines: string[] = [admitted.instructionEmpty ? "" : admitted.instruction]; if (admitted.attachments.length > 0) { lines.push(""); lines.push("已受理附件(冻结快照路径):"); for (const attachment of admitted.attachments) { lines.push(`- ${attachment.frozenPath}`); } } return appendEngineSessionMaterial(lines, engineMaterial).join("\n"); } export type ParseNotaryArgvResult = { readonly sourceRun: string; readonly project?: string; }; /** * Parse Notary-specific argv after the `notary` token. * Input contract = zero prompt, zero attachment projection (#448 / #276). */ export function parseNotaryArgv(args: readonly string[]): ParseNotaryArgvResult { let project: string | undefined; let sourceRun: string | undefined; const tokens = [...args]; const definitions = roleOptions("notary"); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); if (tokens.length > 0) { throw new CliUsageError( "notary rejects caller prompt/instruction; only --source-run locator is admitted", ); } break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } if (taken.def.id === "source-run") { if (taken.value === undefined || taken.value.trim() === "") { throw new CliUsageError("notary --source-run requires a run locator"); } sourceRun = taken.value; continue; } throw new CliUsageError(`unknown notary option: ${taken.def.canonical}`); } const token = tokens.shift()!; if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown notary option: ${token}`); } throw new CliUsageError( "notary rejects caller prompt/instruction; only --source-run locator is admitted", ); } options.assertRequired(); if (sourceRun === undefined || sourceRun.trim() === "") { throw new CliUsageError("notary --source-run requires a run locator"); } return { sourceRun, ...(project === undefined ? {} : { project }), }; } export async function admitNotaryInvocation(options: { readonly home: string; readonly principalAuthority: DurablePrincipalAuthority; readonly cwd: string; readonly sourceRun: string; readonly project?: string; readonly runs?: string; readonly createRunId?: () => string; readonly model?: InvocationEffectiveModel; readonly correlationId?: string; }): Promise { if (options.project !== undefined) { requireOptionPath("--project", options.project); } const projectRoot = resolve(options.project ?? options.cwd); let sourceRun: NotarySourceRunLocator; try { sourceRun = await resolveNotarySourceRunLocator({ projectRoot, sourceRun: options.sourceRun, home: options.home, }); } catch (error) { if (error instanceof NotarySourceRunError) { throw new CliUsageError(error.message, { cause: error }); } throw error; } // Notary inherits board identity only — migration-derived stays display/placement. const inheritedTicketNumber = await readBoardTicketNumber(sourceRun.runDirectory); const runId = options.createRunId?.() ?? uuidv7(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "notary", subject: inheritedTicketNumber === undefined ? { unbound: true } : { ticketNumber: inheritedTicketNumber }, home: options.home, }); const ticketFields = ticketAdmissionFields(inheritedTicketNumber); const admitted = { role: "notary" as const, runId, bookKey, projectRoot, runDirectory, principal, instruction: "", instructionEmpty: true, attachments: [] as const, sourceRunPath: sourceRun.runDirectory, sourceRun, ...ticketFields, ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model); return { role: "notary", runId, bookKey, projectRoot, instruction: "", instructionEmpty: true, attachments: [], runDirectory, principal, admittedRequestPath, sourceRunPath: sourceRun.runDirectory, sourceRun, ...ticketFields, ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }), }; } /** Package-owned fixed kickoff only — never caller instruction/attachments. * Ticket rides admitted → activation → agent-start typed bound (not free-text kickoff). */ export function buildNotaryTransportPrompt( _admitted: AdmittedNotaryInvocation, engineMaterial?: EngineSessionMaterial, ): string { return appendEngineSessionMaterial([NOTARY_FIXED_KICKOFF], engineMaterial).join("\n"); } /** * Parse Gleaner-Left argv after the `gleaner-left` token. * Public flags: --project, required --base. No --attach / ticket face (unanchored self-fetch). * Instruction may be empty; callers must not pass directional instruction. */ export function parseGleanerLeftArgv( args: readonly string[], ): ParseGleanerLeftArgvResult { let project: string | undefined; let baseRevision: string | undefined; const positional: string[] = []; const tokens = [...args]; const definitions = roleOptions("gleaner-left"); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } if (taken.def.id === "base") { baseRevision = requireOptionPath(taken.def.canonical, taken.value); continue; } throw new CliUsageError(`unknown gleaner-left option: ${taken.def.canonical}`); } const token = tokens.shift()!; if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown gleaner-left option: ${token}`); } positional.push(token); } options.assertRequired(); return { instruction: positional.join(" "), baseRevision: baseRevision!, ...(project === undefined ? {} : { project }), }; } export type AdmitGleanerLeftInvocationOptions = { home: string; principalAuthority: DurablePrincipalAuthority; cwd: string; instruction: string; baseRevision: string; project?: string; createRunId?: () => string; model?: InvocationEffectiveModel; correlationId?: string; }; /** * Admit a Gleaner-Left Role run on the fixed comparison base. * Empty instruction is the lawful path; no attachment/ticket admission face. */ export async function admitGleanerLeftInvocation( options: AdmitGleanerLeftInvocationOptions, ): Promise { if (options.project !== undefined) { requireOptionPath("--project", options.project); } if (options.baseRevision.trim() === "") { throw new CliUsageError("--base requires a nonempty revision"); } const projectRoot = resolve(options.project ?? options.cwd); const runId = (options.createRunId ?? uuidv7)(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "gleaner-left", subject: { unbound: true }, home: options.home, }); const instruction = options.instruction; const instructionEmpty = instruction.trim() === ""; const admitted = { role: "gleaner-left" as const, runId, bookKey, projectRoot, runDirectory, principal, instruction, instructionEmpty, baseRevision: options.baseRevision, attachments: [] as const, ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger( { ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model, ); return { role: "gleaner-left", runId, bookKey, projectRoot, instruction, instructionEmpty, attachments: [], runDirectory, principal, admittedRequestPath, baseRevision: options.baseRevision, ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }), }; } /** Bound comparison base is a typed fact, not a directional instruction. */ export function buildGleanerLeftTransportPrompt( admitted: AdmittedGleanerLeftInvocation, engineMaterial?: EngineSessionMaterial, ): string { const lines: string[] = [ `左拾遗案已受理。比较基线:${admitted.baseRevision}`, ]; if (!admitted.instructionEmpty) { lines.push("", admitted.instruction); } return appendEngineSessionMaterial(lines, engineMaterial).join("\n"); } export function parseReviewerArgv( args: readonly string[], ): ParseReviewerArgvResult { // Spellings from PUBLIC_OPTION_TABLE.reviewer (#342). No --attach face. const attachmentPaths: string[] = []; const authorityRefs: string[] = []; let project: string | undefined; let baseRevision: string | undefined; let lens: ReviewerLens | undefined; const positional: string[] = []; const tokens = [...args]; const definitions = roleOptions("reviewer"); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } if (taken.def.id === "base") { baseRevision = requireReviewerBaseRevision(taken.value); continue; } if (taken.def.id === "lens") { // Public override is single-axis only; omission stays undefined for the parallel branch. lens = requireReviewerLens(taken.value); continue; } if (taken.def.id === "authority-ref") { authorityRefs.push(requireAuthorityRef(taken.value)); continue; } throw new CliUsageError(`unknown reviewer option: ${taken.def.canonical}`); } const token = tokens.shift()!; if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown reviewer option: ${token}`); } positional.push(token); } // Base and authority are required; omitted lens selects the parallel two-axis mode. options.assertRequired(); return { instruction: positional.join(" "), attachmentPaths, baseRevision: baseRevision!, ...(lens === undefined ? {} : { lens }), authorityRefs, ...(project === undefined ? {} : { project }), }; } export type AdmitReviewerInvocationOptions = { home: string; principalAuthority: DurablePrincipalAuthority; cwd: string; /** Optional caller prose retained only as admitted provenance — never semantic control. */ instruction: string; attachmentPaths: readonly string[]; baseRevision: string; /** Explicit single-axis shape, frozen unchanged at admission and resume. */ lens: ReviewerLens; /** Required durable authority references/URLs; frozen unchanged at admission. */ authorityRefs: readonly string[]; project?: string; createRunId?: () => string; correlationId?: string; /** Effective model for this invocation — written onto invocation.json. */ model?: InvocationEffectiveModel; }; /** * Admit a Reviewer Role run on the fixed base + lens + authority set. * Caller instruction is optional provenance; typed fields project Skill inputs. * authorityRefs are frozen as durable references only — not Spec prose. */ export async function admitReviewerInvocation( options: AdmitReviewerInvocationOptions, ): Promise { if (options.project !== undefined) { requireOptionPath("--project", options.project); } const baseRevision = requireReviewerBaseRevision(options.baseRevision); const lens = requireReviewerLens(options.lens); if (options.authorityRefs.length === 0) { throw new CliUsageError("reviewer requires --authority-ref "); } const authorityRefs = Object.freeze( options.authorityRefs.map((ref) => requireAuthorityRef(ref)), ); const projectRoot = resolve(options.project ?? options.cwd); const runId = (options.createRunId ?? uuidv7)(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "reviewer", subject: { unbound: true }, home: options.home, }); // Public parse already rejects attachments; keep freeze loop for structural symmetry. const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory); const instruction = options.instruction; const instructionEmpty = instruction.trim() === ""; const admitted = { role: "reviewer" as const, runId, bookKey, projectRoot, runDirectory, principal, instruction, instructionEmpty, baseRevision, lens, authorityRefs: [...authorityRefs], ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }), attachments: attachments.map((a) => ({ provenancePath: a.provenancePath, frozenPath: a.frozenPath, byteLength: a.byteLength, sha256: a.sha256, mediaKind: a.mediaKind, })), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model); return { role: "reviewer", runId, bookKey, projectRoot, instruction, instructionEmpty, attachments, runDirectory, principal, admittedRequestPath, baseRevision, lens, authorityRefs, ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }), }; } /** Frozen Skill arg projection shared by initial and resume (never reverse-parsed). */ export function buildReviewerSkillArgProjection( admitted: Pick, ): string { return [ `--base ${admitted.baseRevision}`, `--lens ${admitted.lens}`, ...admitted.authorityRefs.map((ref) => `--authority ${ref}`), ].join(" "); } /** * Build the host-neutral prompt transport for an admitted Reviewer request. * Typed base/lens/authority project to Skill invocation args (never reverse-parsed from prose). * Optional caller words + engine material follow. */ export function buildReviewerTransportPrompt( admitted: AdmittedReviewerInvocation, engineMaterial?: EngineSessionMaterial, ): string { const lines = [buildReviewerSkillArgProjection(admitted)]; if (!admitted.instructionEmpty && admitted.instruction.trim() !== "") { lines.push("", admitted.instruction); } return appendEngineSessionMaterial(lines, engineMaterial).join("\n"); } /** * Parse Merger-specific argv after the `merger` token. * Spellings from PUBLIC_OPTION_TABLE.merger; internal packet fields rejected (#342). */ export function parseMergerArgv(args: readonly string[]): ParseMergerArgvResult { const attachmentPaths: string[] = []; let project: string | undefined; const positional: string[] = []; const tokens = [...args]; const definitions = roleOptions("merger"); const options = createTypedOptionConsumer(definitions); while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.id === "attach") { attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value)); continue; } if (taken.def.id === "project") { project = requireOptionPath(taken.def.canonical, taken.value); continue; } throw new CliUsageError(`unknown merger option: ${taken.def.canonical}`); } const token = tokens.shift()!; // Rejected public spellings (#342) plus other internal packet field faces. if ( isRejectedPublicSpelling("merger", token) || token === "--targetObjectId" || token.startsWith("--targetObjectId=") || token === "--sourceObjectId" || token.startsWith("--sourceObjectId=") || token === "--expectedConflictPaths" || token.startsWith("--expectedConflictPaths=") || token === "--resolutionScope" || token.startsWith("--resolutionScope=") ) { throw new CliUsageError( "merger does not accept public packet fields; the adapter reads Git merge materials", ); } if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown merger option: ${token}`); } positional.push(token); } options.assertRequired(); return { instruction: positional.join(" "), attachmentPaths, ...(project === undefined ? {} : { project }), }; } function mergerMaterialFromUtf8(text: string): MergerInput["materials"]["task"] { const bytes = Buffer.from(text, "utf8"); return Object.freeze({ bytesBase64: bytes.toString("base64"), sha256: sha256Hex(bytes), }); } /** * Read merger materials from current Git state. * No in-progress merge / empty conflict set still yields materials (possibly empty); * code does not gate attendance on merge state (#827). */ export async function deriveMergerEnvelopeFromActiveMerge( projectRoot: string, gitState: MergerGitState = createProductionMergerGitState(projectRoot), ): Promise { const state = await gitState.activeMerge(); const expectedConflictPaths = Object.freeze([...state.unmergedPaths]); const resolutionScope = Object.freeze([...state.unmergedPaths]); return Object.freeze({ targetObjectId: state.targetObjectId, sourceObjectId: state.sourceObjectId, expectedConflictPaths, resolutionScope, }); } export type AdmitMergerInvocationOptions = { home: string; principalAuthority: DurablePrincipalAuthority; cwd: string; instruction: string; attachmentPaths: readonly string[]; project?: string; createRunId?: () => string; /** Test seam; production binds createProductionMergerGitState(projectRoot). */ gitState?: MergerGitState; /** Effective model for this invocation — written onto invocation.json. */ model?: InvocationEffectiveModel; }; /** * Admit a Merger Role run on the common Invocation request. * Git materials (parents, conflicts, scope) are read from the worktree and * handed to the role as assignment materials — including when empty (#827). * Callers never supply public packet fields for those facts. */ export async function admitMergerInvocation( options: AdmitMergerInvocationOptions, ): Promise { if (options.project !== undefined) { requireOptionPath("--project", options.project); } const instruction = options.instruction; if (instruction.trim() === "") { throw new CliUsageError("merger requires a nonblank task instruction"); } const projectRoot = resolve(options.project ?? options.cwd); const derived = await deriveMergerEnvelopeFromActiveMerge( projectRoot, options.gitState ?? createProductionMergerGitState(projectRoot), ); const runId = (options.createRunId ?? uuidv7)(); const { principal, sessionDirectory, sessionFile, runDirectory, attachmentsDirectory, ledgerHome, bookKey, } = issueAdmissionPlacement(options.principalAuthority, { cwd: projectRoot, runId, role: "merger", subject: { unbound: true }, home: options.home, }); const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory); // Intent materials seed primary-source investigation; the method owns the work. const targetLabel = derived.targetObjectId === "" ? "(none observed)" : derived.targetObjectId; const sourceLabel = derived.sourceObjectId === "" ? "(none observed)" : derived.sourceObjectId; const targetIntent = mergerMaterialFromUtf8( `Investigate primary sources for target parent ${targetLabel}. Do not invent intent.`, ); const sourceIntent = mergerMaterialFromUtf8( `Investigate primary sources for source parent ${sourceLabel}. Do not invent intent.`, ); const taskMaterial = mergerMaterialFromUtf8(instruction); const authorityMaterial = mergerMaterialFromUtf8(instruction); const mergerInput = validateMergerInput({ attemptId: runId, targetObjectId: derived.targetObjectId, sourceObjectId: derived.sourceObjectId, materials: { task: taskMaterial, authority: authorityMaterial, targetIntent, sourceIntent, }, expectedConflictPaths: [...derived.expectedConflictPaths], resolutionScope: [...derived.resolutionScope], authorizedChecks: [], }); const mergerInputPath = join(runDirectory, "merger-input.json"); await writeFile( mergerInputPath, `${JSON.stringify(mergerInput, null, 2)}\n`, "utf8", ); const admitted = { role: "merger" as const, runId, bookKey, projectRoot, runDirectory, principal, instruction, instructionEmpty: false, mergerInputPath, derived: { targetObjectId: derived.targetObjectId, sourceObjectId: derived.sourceObjectId, expectedConflictPaths: [...derived.expectedConflictPaths], resolutionScope: [...derived.resolutionScope], }, attachments: attachments.map((a) => ({ provenancePath: a.provenancePath, frozenPath: a.frozenPath, byteLength: a.byteLength, sha256: a.sha256, mediaKind: a.mediaKind, })), }; const admittedRequestPath = join(runDirectory, "admitted-request.json"); await writeAdmittedRequestPersistence(admittedRequestPath, admitted, { sessionDirectory, sessionFile, }); await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model); return { role: "merger", runId, bookKey, projectRoot, instruction, instructionEmpty: false, attachments, runDirectory, principal, admittedRequestPath, mergerInputPath, derived: admitted.derived, }; } /** * Build the host-neutral prompt transport for an admitted Merger request. * Method material binds on RoleTurnRequest.methods; Pi-native `/skill:` form * is adapter-internal only (ADR 0082). */ export function buildMergerTransportPrompt( admitted: AdmittedMergerInvocation, engineMaterial?: EngineSessionMaterial, ): string { const lines: string[] = [admitted.instruction]; if (admitted.attachments.length > 0) { lines.push(""); lines.push("已受理附件(冻结快照路径):"); for (const attachment of admitted.attachments) { lines.push(`- ${attachment.frozenPath}`); } } return appendEngineSessionMaterial(lines, engineMaterial).join("\n"); } const ANALYST_TICKET_NUMBER_PATTERN = /^[1-9]\d*$/; /** * Parse a positive ticket / issue number for public analyst admission. * Leading zeros and non-integers are structural rejects (same face as #176). * `flag` names the actual argv face in diagnostics (cohort group lists reuse this). */ export function parseAnalystTicketNumber( raw: string, flag: string = "--ticket", ): number { const trimmed = raw.trim(); if (!ANALYST_TICKET_NUMBER_PATTERN.test(trimmed)) { throw new CliUsageError( `analyst ${flag} must be a positive integer, got ${raw}`, ); } const value = Number(trimmed); // Digit-only strings beyond MAX_SAFE_INTEGER round or become Infinity — reject. if (!Number.isSafeInteger(value) || value < 1) { throw new CliUsageError( `analyst ${flag} must be a positive integer, got ${raw}`, ); } return value; } /** * One cohort issue token before cwd-book stamping. * - bare N → join cwd book at run time (#412 / #399 ticket口径) * - book:N → explicit cross-book join (last ":" + positive integer RHS) */ export type AnalystCohortIssueToken = | { readonly kind: "bare"; readonly issueNumber: number } | { readonly kind: "book-qualified"; readonly bookKey: string; readonly issueNumber: number; }; /** * Parse one cohort issue token: bare positive integer or `book:N`. * Book keys may contain ":" (e.g. synthetic `root:`) — split on the last * colon only when the RHS is a positive integer token. */ export function parseAnalystCohortIssueToken( raw: string, flag: string, ): AnalystCohortIssueToken { const trimmed = raw.trim(); if (trimmed === "") { throw new CliUsageError( `${flag} requires a comma-separated list of N or book:N`, ); } const sep = trimmed.lastIndexOf(":"); if (sep > 0) { const rhs = trimmed.slice(sep + 1); if (ANALYST_TICKET_NUMBER_PATTERN.test(rhs)) { const bookKey = trimmed.slice(0, sep); if (bookKey.trim() === "") { throw new CliUsageError( `${flag} book:N requires a non-empty book key, got ${raw}`, ); } return { kind: "book-qualified", bookKey, issueNumber: parseAnalystTicketNumber(rhs, flag), }; } } return { kind: "bare", issueNumber: parseAnalystTicketNumber(trimmed, flag), }; } /** * Sole cohort list grammar (#412): split on unescaped commas. `\,` is a literal * comma and `\\` a literal backslash — both round-trip, so any directory-name * book key (ADR 0048) is expressible. Any other `\x` stays literally `\x`, so * pre-existing unescaped input never changes meaning. Colons remain owned by * the token's lastIndexOf(':') rule. */ function splitAnalystCohortIssueListParts(raw: string): string[] { const parts: string[] = []; let current = ""; let escaped = false; for (const ch of raw) { if (escaped) { current += ch === "," || ch === "\\" ? ch : `\\${ch}`; escaped = false; continue; } if (ch === "\\") { escaped = true; continue; } if (ch === ",") { parts.push(current); current = ""; continue; } current += ch; } parts.push(escaped ? `${current}\\` : current); return parts; } function parseAnalystCohortIssueTokenList( raw: string, flag: string, ): AnalystCohortIssueToken[] { const trimmed = raw.trim(); if (trimmed === "") { throw new CliUsageError( `${flag} requires a comma-separated list of N or book:N`, ); } const parts = splitAnalystCohortIssueListParts(trimmed).map((part) => part.trim(), ); if (parts.some((part) => part === "")) { throw new CliUsageError( `${flag} requires a comma-separated list of N or book:N`, ); } return parts.map((part) => parseAnalystCohortIssueToken(part, flag)); } function requireOptionValue( flag: string, value: string | undefined, what: string, ): string { if (value === undefined || value.trim() === "") { throw new CliUsageError(`${flag} requires ${what}`); } return value; } /** * Parse analyst-specific argv after the `analyst` token (#336/#337/#338). * Spellings + mode relation contracts from PUBLIC_OPTION_TABLE.analyst / ANALYST_* (#342). * Mode exclusion, conditional requiredness, cardinality, and at-least-one are * table-driven — do not restate them as parallel handwritten branches here. * Unconditional required:true also goes through the shared consumer. */ export function parseAnalystArgv(args: readonly string[]): ParseAnalystArgvResult { const valueLists = new Map(); const tokens = [...args]; const definitions = roleOptions("analyst"); // Shared typed consumer: dashed + positional take, repeatable, required (#342). const options = createTypedOptionConsumer(definitions); const pushValue = (id: string, value: string): void => { const existing = valueLists.get(id); if (existing === undefined) valueLists.set(id, [value]); else existing.push(value); }; while (tokens.length > 0) { if (tokens[0] === "--") { tokens.shift(); if (tokens.length > 0) { throw new CliUsageError(`unexpected analyst argument: ${tokens[0]}`); } break; } const taken = options.takeDashed(tokens); if (taken !== undefined) { if (taken.def.valueMetavar === null) { pushValue(taken.def.id, ""); continue; } if (taken.def.id === "ticket") { if (taken.value === undefined || taken.value.trim() === "") { throw new CliUsageError("analyst --ticket requires a positive integer"); } pushValue("ticket", taken.value); continue; } if (taken.def.id === "attach") { pushValue( "attach", requireOptionPath(taken.def.canonical, taken.value), ); continue; } if (taken.def.id === "group-a-label" || taken.def.id === "group-b-label") { pushValue( taken.def.id, requireOptionValue(taken.def.canonical, taken.value, "a label"), ); continue; } if ( taken.def.id === "group-a-issues" || taken.def.id === "group-b-issues" ) { pushValue( taken.def.id, requireOptionValue( taken.def.canonical, taken.value, "a comma-separated list of N or book:N", ), ); continue; } throw new CliUsageError(`unknown analyst option: ${taken.def.canonical}`); } const token = tokens.shift()!; // #399: deleted --project-root; disabled --model-groups public face. if (isRejectedPublicSpelling("analyst", token)) { if (token === "--project-root" || token.startsWith("--project-root=")) { throw new CliUsageError( "analyst no longer accepts --project-root (deleted); use bare call for whole book or --ticket N (cwd git common-dir selects the book)", ); } if (token === "--model-groups" || token.startsWith("--model-groups=")) { throw new CliUsageError( "analyst --model-groups public CLI face is disabled; input face is being redesigned for multi-issue comparison (see follow-up ticket)", ); } throw new CliUsageError(`unknown analyst option: ${token}`); } if (token.startsWith("-") && token !== "-") { throw new CliUsageError(`unknown analyst option: ${token}`); } // Positional selectors (e.g. sweep) via shared typed consumer — not a parallel list. const positional = options.takePositional(token); if (positional !== undefined) { pushValue(positional.id, ""); continue; } throw new CliUsageError(`unexpected analyst argument: ${token}`); } const counts = new Map(); for (const [id, values] of valueLists) { counts.set(id, values.length); } options.assertRequired(); const mode = resolveAnalystMode(new Set(counts.keys())); const verdict = evaluateAnalystModeOptionContract(mode, counts); if (!verdict.ok) { throw new CliUsageError(verdict.message); } if (mode === "cohort") { const groupALabel = valueLists.get("group-a-label")![0]!; const groupAIssuesRaw = valueLists.get("group-a-issues")![0]!; const groupBLabel = valueLists.get("group-b-label")![0]!; const groupBIssuesRaw = valueLists.get("group-b-issues")![0]!; return { query: "cohort", groups: [ { groupLabel: groupALabel, issues: parseAnalystCohortIssueTokenList( groupAIssuesRaw, "--group-a-issues", ), }, { groupLabel: groupBLabel, issues: parseAnalystCohortIssueTokenList( groupBIssuesRaw, "--group-b-issues", ), }, ], }; } if (mode === "sweep") { return { query: "sweep", attachmentPaths: valueLists.get("attach") ?? [], }; } const ticketRaw = valueLists.get("ticket")?.[0]; return { query: "issue", ...(ticketRaw === undefined ? {} : { ticket: parseAnalystTicketNumber(ticketRaw) }), }; }