@akagilnc/pi-workflow-roles 0.1.2521 → 0.1.2535

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ import { Type } from "typebox";
2
+
3
+ import { INSPECTOR_OUTPUT_TOOL_NAME } from "./inspector-contracts.ts";
4
+ import { openToolObject } from "./open-tool-schema.ts";
5
+ import { withInfrastructureFailureDeclaration } from "./package-contracts/terminating-infrastructure.ts";
6
+
7
+ export { INSPECTOR_OUTPUT_TOOL_NAME as INSPECTOR_OUTPUT_TOOL };
8
+
9
+ export const inspectorOutputSchema = withInfrastructureFailureDeclaration(openToolObject(Type.Object({
10
+ status: Type.Unknown({ description: "pass | bounce — 取值形状指引,不作拒收依据" }),
11
+ findings: Type.Unknown({ description: "随交卷留存的问题记录" }),
12
+ })));
13
+
14
+ export function projectInspectorReceipt(parameters: unknown) {
15
+ return {
16
+ content: [{ type: "text" as const, text: "给事中回执已受理" }],
17
+ details: parameters,
18
+ terminate: true as const,
19
+ };
20
+ }
@@ -25,6 +25,7 @@ import {
25
25
  type RuntimeReviewerReceiptV2,
26
26
  } from "./reviewer-output.ts";
27
27
  import { isAuditEscalationResult } from "../audit-escalation.ts";
28
+ import { INSPECTOR_OUTPUT_TOOL_NAME } from "../inspector-contracts.ts";
28
29
  import { DOCTOR_ACCEPTED_TEXT, DOCTOR_OUTPUT_TOOL_NAME, validateDoctorSubmissionShape, validateRecordedDoctorOutput, type DoctorOutput, type DoctorSubmission } from "../doctor-contracts.ts";
29
30
  import { MERGER_ACCEPTED_TEXT, MERGER_OUTPUT_TOOL_NAME, validateMergerOutput, type MergerOutput } from "../merger-contracts.ts";
30
31
  import { NOTARY_ACCEPTED_TEXT, NOTARY_OUTPUT_TOOL_NAME, validateRecordedNotaryOutput, type NotaryOutput } from "../notary-contracts.ts";
@@ -82,6 +83,7 @@ export const TERMINATING_TOOL_NAMES = [
82
83
  DOCTOR_OUTPUT_TOOL_NAME,
83
84
  MERGER_OUTPUT_TOOL_NAME,
84
85
  NOTARY_OUTPUT_TOOL_NAME,
86
+ INSPECTOR_OUTPUT_TOOL_NAME,
85
87
  ] as const;
86
88
 
87
89
  export type TerminatingToolName = (typeof TERMINATING_TOOL_NAMES)[number];
@@ -93,7 +95,8 @@ export type AcceptedDetails =
93
95
  | CollectorReceipt
94
96
  | DoctorOutput
95
97
  | MergerOutput
96
- | NotaryOutput;
98
+ | NotaryOutput
99
+ | Readonly<Record<string, unknown>>;
97
100
 
98
101
  export function isTerminatingToolName(
99
102
  name: string,
@@ -119,6 +122,8 @@ export function acceptedTextFor(toolName: TerminatingToolName): string {
119
122
  return MERGER_ACCEPTED_TEXT;
120
123
  case NOTARY_OUTPUT_TOOL_NAME:
121
124
  return NOTARY_ACCEPTED_TEXT;
125
+ case INSPECTOR_OUTPUT_TOOL_NAME:
126
+ return "给事中回执已受理";
122
127
  }
123
128
  }
124
129
 
@@ -165,6 +170,7 @@ export function validateAcceptedDetails(
165
170
  [DOCTOR_OUTPUT_TOOL_NAME]: ["completed", "refused"],
166
171
  [MERGER_OUTPUT_TOOL_NAME]: ["completed", "escalate"],
167
172
  [NOTARY_OUTPUT_TOOL_NAME]: ["pass", "bounce"],
173
+ [INSPECTOR_OUTPUT_TOOL_NAME]: ["pass", "bounce"],
168
174
  };
169
175
  const collectorDiscriminator = toolName === COLLECTOR_OUTPUT_TOOL && Array.isArray(candidate?.groups);
170
176
  const baseDiscriminator = discriminator;
@@ -195,6 +201,8 @@ export function validateAcceptedDetails(
195
201
  return validateMergerOutput(details);
196
202
  case NOTARY_OUTPUT_TOOL_NAME:
197
203
  return validateRecordedNotaryOutput(details);
204
+ case INSPECTOR_OUTPUT_TOOL_NAME:
205
+ return candidate as Readonly<Record<string, unknown>>;
198
206
  }
199
207
  } catch (error) {
200
208
  if (error instanceof Error && error.constructor === Error) throw new AcceptedDetailsContractError(error.message, { cause: error });
@@ -239,7 +247,8 @@ export function acceptedFacts(toolName: TerminatingToolName, details: AcceptedDe
239
247
  case FIXER_OUTPUT_TOOL_NAME:
240
248
  case REVIEWER_OUTPUT_TOOL_NAME:
241
249
  case DOCTOR_OUTPUT_TOOL_NAME:
242
- case NOTARY_OUTPUT_TOOL_NAME: return { status: (details as { status: string }).status };
250
+ case NOTARY_OUTPUT_TOOL_NAME:
251
+ case INSPECTOR_OUTPUT_TOOL_NAME: return { status: (details as { status: string }).status };
243
252
  case JUDGE_OUTPUT_TOOL_NAME: return { status: (details as { judgeStatus: string }).judgeStatus };
244
253
  case MERGER_OUTPUT_TOOL_NAME: {
245
254
  const output = details as unknown as Record<string, unknown>;
@@ -6,6 +6,7 @@ import { CODER_OUTPUT_TOOL_NAME, FIXER_OUTPUT_TOOL_NAME } from "./package-contra
6
6
  import { DOCTOR_OUTPUT_TOOL_NAME } from "./doctor-contracts.ts";
7
7
  import { MERGER_OUTPUT_TOOL_NAME } from "./merger-contracts.ts";
8
8
  import { NOTARY_OUTPUT_TOOL_NAME } from "./notary-contracts.ts";
9
+ import { INSPECTOR_OUTPUT_TOOL_NAME } from "./inspector-contracts.ts";
9
10
 
10
11
  export const PACKAGED_ROLE_REGISTRY = [
11
12
  { role: "judge", phases: [null], outputTool: JUDGE_OUTPUT_TOOL_NAME, inputFlag: undefined, phaseFlag: undefined, activationStage: "load-and-install" },
@@ -17,6 +18,7 @@ export const PACKAGED_ROLE_REGISTRY = [
17
18
  { role: "doctor", phases: [null], outputTool: DOCTOR_OUTPUT_TOOL_NAME, inputFlag: "ak-doctor-case", phaseFlag: undefined, activationStage: "load-and-install" },
18
19
  { role: "merger", phases: [null], outputTool: MERGER_OUTPUT_TOOL_NAME, inputFlag: "ak-merger-input", phaseFlag: undefined, activationStage: "prepare-git-and-install" },
19
20
  { role: "notary", phases: [null], outputTool: NOTARY_OUTPUT_TOOL_NAME, inputFlag: "ak-notary-source-run", phaseFlag: undefined, activationStage: "load-and-install" },
21
+ { role: "inspector", phases: [null], outputTool: INSPECTOR_OUTPUT_TOOL_NAME, inputFlag: undefined, phaseFlag: undefined, activationStage: "load-and-install" },
20
22
  ] as const;
21
23
 
22
24
  export type PackagedRole = (typeof PACKAGED_ROLE_REGISTRY)[number]["role"];
@@ -40,6 +40,7 @@ import {
40
40
  parseDoctorArgv,
41
41
  parseFixerArgv,
42
42
  parseJudgeArgv,
43
+ parseInspectorArgv,
43
44
  parseMergerArgv,
44
45
  parseNotaryArgv,
45
46
  parseReviewerArgv,
@@ -60,6 +61,7 @@ import { runPublicCollector } from "./collector-run.ts";
60
61
  import { runPublicDoctor } from "./doctor-run.ts";
61
62
  import { runPublicFixer, runPublicFixerResume } from "./fixer-run.ts";
62
63
  import { runPublicNotary } from "./notary-run.ts";
64
+ import { runPublicInspector } from "./inspector-run.ts";
63
65
  import { runPublicJudge, runPublicResume } from "./judge-run.ts";
64
66
  import { runPublicMerger, runPublicMergerResume } from "./merger-run.ts";
65
67
  import { runPublicReviewer, runPublicReviewerResume } from "./reviewer-run.ts";
@@ -104,6 +106,7 @@ export const PUBLIC_ROLE_ARGV = {
104
106
  doctor: { parse: parseDoctorArgv, options: optionsForOwner("doctor") },
105
107
  merger: { parse: parseMergerArgv, options: optionsForOwner("merger") },
106
108
  notary: { parse: parseNotaryArgv, options: optionsForOwner("notary") },
109
+ inspector: { parse: parseInspectorArgv, options: optionsForOwner("inspector") },
107
110
  reviewer: { parse: parseReviewerArgv, options: optionsForOwner("reviewer") },
108
111
  /** Deterministic analysis seat (#336) — argv parse only; no LLM admission. */
109
112
  analyst: { parse: parseAnalystArgv, options: optionsForOwner("analyst") },
@@ -1317,6 +1320,23 @@ export async function runAkRole(
1317
1320
  };
1318
1321
  }
1319
1322
 
1323
+ if (parsed.command === "inspector") {
1324
+ const agentDir = resolveAgentDir(env, home);
1325
+ const cwd = env.cwd ?? process.cwd();
1326
+ const config = await loadAndValidateConfig(home, env.packageRoot);
1327
+ const credentials = env.credentials ?? (await loadCredentialProviders(agentDir));
1328
+ const seat = resolveEffectiveSeat(config, "inspector", credentials, invocationFromParsed(parsed));
1329
+ const result = await runPublicInspector(parsed.args, {
1330
+ home, agentDir, packageRoot: env.packageRoot, cwd, credentials,
1331
+ ...(env.correlationId === undefined ? {} : { correlationId: env.correlationId }),
1332
+ ...(env.piRunner === undefined ? {} : { piRunner: env.piRunner }),
1333
+ ...(seat.selection === undefined ? {} : { model: seat.selection }),
1334
+ ...projectSeatEngine(seat),
1335
+ ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
1336
+ }, io, PUBLIC_ROLE_ARGV.inspector.parse);
1337
+ return { exitCode: result.exitCode, ...(result.terminal === undefined ? {} : { terminal: result.terminal }) };
1338
+ }
1339
+
1320
1340
  // Merger public run path: derive active-merge envelope + forced merge-only method (#114).
1321
1341
  if (parsed.command === "merger") {
1322
1342
  const agentDir = resolveAgentDir(env, home);
@@ -42,8 +42,8 @@ export type SeatModelConfig = ModelRef;
42
42
 
43
43
  /**
44
44
  * Persistent seat row (#356/#384/#453): model fields and engine are independent
45
- * axes. Engine-only residual is legal only for notary after model clear so direct
46
- * notary activation keeps its labor engine while province inheritance resumes.
45
+ * axes. Direct Inspector and Notary seats may retain an engine after model clear so
46
+ * officer activation keeps its labor engine while province inheritance resumes.
47
47
  * All other seats keep the baseline provider/model required contract.
48
48
  */
49
49
  export type PersistentSeatConfig = {
@@ -154,9 +154,9 @@ export function setPersistentSeatConfig(
154
154
  /**
155
155
  * Clear a gate officer's persistent model override (#453).
156
156
  * Scope is GateOfficerSeat only — non-province seats have no destructive clear seam.
157
- * Only notary may retain an engine-only residual so direct notary activation keeps
157
+ * Directly callable gate officers may retain an engine-only residual so activation keeps
158
158
  * its labor engine while model resolution returns to startup / province inheritance.
159
- * gatekeeper/inspector drop the whole row. Already-absent seats are a no-op.
159
+ * Gatekeeper drops the whole row. Already-absent seats are a no-op.
160
160
  */
161
161
  export function clearPersistentSeatConfig(
162
162
  config: PublicCliConfig,
@@ -164,13 +164,13 @@ export function clearPersistentSeatConfig(
164
164
  ): PublicCliConfig {
165
165
  const previous = config.seats[seat];
166
166
  if (previous === undefined) return config;
167
- // Engine-only residual ownership is notary-only — never widen to other officers.
168
- if (seat === "notary" && previous.engine !== undefined) {
167
+ // Direct officer engine ownership survives an independent model clear.
168
+ if ((seat === "notary" || seat === "inspector") && previous.engine !== undefined) {
169
169
  return {
170
170
  ...config,
171
171
  seats: {
172
172
  ...config.seats,
173
- notary: { engine: previous.engine },
173
+ [seat]: { engine: previous.engine },
174
174
  },
175
175
  };
176
176
  }
@@ -204,8 +204,8 @@ export function isEngineAxisSeat(seat: string): seat is PublicCallableRole {
204
204
 
205
205
  /**
206
206
  * Set or clear persistent engine on a callable role seat (#356 / #378 / #391 / #453).
207
- * First engine still requires an existing seat row (model, or notary engine residual).
208
- * Clearing engine from a notary engine-only residual drops the empty row; clearing
207
+ * First engine still requires an existing seat row (model, or direct-officer engine residual).
208
+ * Clearing engine from an engine-only residual drops the empty row; clearing
209
209
  * engine from a model+engine row leaves model-only. Seat type is PublicCallableRole
210
210
  * (navigator excluded at the type boundary).
211
211
  */
@@ -427,10 +427,10 @@ function parseSeatModelConfig(value: unknown, seat: string): PersistentSeatConfi
427
427
  // validatePublicCliConfigEngines → assertLegalEngineName (single authority).
428
428
  throw new Error(`config seat ${seat} engine must be a string`);
429
429
  }
430
- // #453: engine-only residual is legal only for notary after model clear.
430
+ // Direct officer engine-only residual remains legal after model clear.
431
431
  // All other seats keep the baseline provider/model required contract.
432
432
  if (!hasProvider) {
433
- if (seat === "notary" && typeof raw.engine === "string") {
433
+ if ((seat === "notary" || seat === "inspector") && typeof raw.engine === "string") {
434
434
  if (raw.thinking !== undefined) {
435
435
  throw new Error(`config seat ${seat} thinking requires provider/model`);
436
436
  }
@@ -0,0 +1,61 @@
1
+ import { engineSessionMaterialFromOptions } from "../package-resources/engine-material.ts";
2
+ import { CliUsageError } from "./cli-errors.ts";
3
+ import {
4
+ admitInspectorInvocation,
5
+ buildInspectorTransportPrompt,
6
+ type AdmittedInspectorInvocation,
7
+ type ParseInspectorArgvResult,
8
+ } from "./invocation.ts";
9
+ import { buildSeatModelCliArgs, type SeatModelConfig } from "./config.ts";
10
+ import { runAdmittedOneShotRole, type OneShotRunEnv } from "./one-shot-dispatch.ts";
11
+ import { presentStructuralRejection, trySettleInspectorTerminalResult } from "./settlement.ts";
12
+ import type { CliIo } from "./cli-io.ts";
13
+ import type { TerminalResult } from "./terminal.ts";
14
+
15
+ export type InspectorRunEnv = OneShotRunEnv & { createRunId?: () => string; extraPiArgs?: readonly string[] };
16
+
17
+ export function buildInspectorActivationExtraArgs(
18
+ admitted: AdmittedInspectorInvocation,
19
+ options: { model?: SeatModelConfig; engine?: string; packageRoot?: string; extraPiArgs?: readonly string[] } = {},
20
+ ): string[] {
21
+ return [
22
+ "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files",
23
+ "--session", admitted.sessionFile, "--session-dir", admitted.sessionDirectory,
24
+ ...(options.extraPiArgs ?? []), "--ak-role", "inspector", "--mode", "json",
25
+ ...buildSeatModelCliArgs(options.model),
26
+ buildInspectorTransportPrompt(admitted, engineSessionMaterialFromOptions(options)),
27
+ ];
28
+ }
29
+
30
+ export async function runPublicInspector(
31
+ argv: readonly string[], env: InspectorRunEnv, io: CliIo,
32
+ parse: (args: readonly string[]) => ParseInspectorArgvResult,
33
+ ): Promise<{ exitCode: number; admitted?: AdmittedInspectorInvocation; terminal?: TerminalResult }> {
34
+ let admitted: AdmittedInspectorInvocation;
35
+ try {
36
+ const parsed = parse(argv);
37
+ admitted = await admitInspectorInvocation({
38
+ home: env.home, cwd: env.cwd, instruction: parsed.instruction, attachmentPaths: parsed.attachmentPaths,
39
+ ...(parsed.project === undefined ? {} : { project: parsed.project }),
40
+ ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
41
+ ...(env.model === undefined ? {} : { model: env.model }),
42
+ });
43
+ } catch (error) {
44
+ if (error instanceof CliUsageError) {
45
+ presentStructuralRejection(error, io);
46
+ return { exitCode: 2 };
47
+ }
48
+ throw error;
49
+ }
50
+ const extraArgs = buildInspectorActivationExtraArgs(admitted, {
51
+ packageRoot: env.packageRoot,
52
+ ...(env.model === undefined ? {} : { model: env.model }),
53
+ ...(env.engine === undefined ? {} : { engine: env.engine }),
54
+ ...(env.extraPiArgs === undefined ? {} : { extraPiArgs: env.extraPiArgs }),
55
+ });
56
+ return runAdmittedOneShotRole({
57
+ admitted, env, io, extraArgs,
58
+ adapters: { trySettle: trySettleInspectorTerminalResult, shouldPresentSettled: () => true },
59
+ ...(env.engine === undefined ? {} : { effectiveEngine: env.engine }),
60
+ });
61
+ }
@@ -111,6 +111,10 @@ export type AdmittedJudgeInvocation = AdmittedRoleInvocationBase & {
111
111
  readonly role: "judge";
112
112
  };
113
113
 
114
+ export type AdmittedInspectorInvocation = AdmittedRoleInvocationBase & {
115
+ readonly role: "inspector";
116
+ };
117
+
114
118
  export type CoderPhase = "plan" | "apply";
115
119
 
116
120
  export type AdmittedCoderInvocation = AdmittedRoleInvocationBase & {
@@ -189,6 +193,7 @@ export type AdmittedMergerInvocation = AdmittedRoleInvocationBase & {
189
193
 
190
194
  export type AdmittedRoleInvocation =
191
195
  | AdmittedJudgeInvocation
196
+ | AdmittedInspectorInvocation
192
197
  | AdmittedCoderInvocation
193
198
  | AdmittedFixerInvocation
194
199
  | AdmittedCollectorInvocation
@@ -377,6 +382,8 @@ export type ParseJudgeArgvResult = {
377
382
  project?: string;
378
383
  };
379
384
 
385
+ export type ParseInspectorArgvResult = ParseJudgeArgvResult;
386
+
380
387
  export type ParseCoderArgvResult = {
381
388
  phase: CoderPhase;
382
389
  instruction: string;
@@ -536,51 +543,41 @@ export function requireAuthorityRef(value: string | undefined): string {
536
543
  * Parse Judge-specific argv after the `judge` token.
537
544
  * Spellings from PUBLIC_OPTION_TABLE.judge; rejects burden family (#342).
538
545
  */
539
- export function parseJudgeArgv(args: readonly string[]): ParseJudgeArgvResult {
546
+ function parseStandardMaterialArgv(
547
+ owner: "judge" | "inspector",
548
+ args: readonly string[],
549
+ ): ParseJudgeArgvResult {
540
550
  const attachmentPaths: string[] = [];
541
551
  let project: string | undefined;
542
552
  const positional: string[] = [];
543
553
  const tokens = [...args];
544
- const definitions = roleOptions("judge");
545
- const options = createTypedOptionConsumer(definitions);
546
-
554
+ const options = createTypedOptionConsumer(roleOptions(owner));
547
555
  while (tokens.length > 0) {
548
- if (tokens[0] === "--") {
549
- tokens.shift();
550
- positional.push(...tokens);
551
- break;
552
- }
556
+ if (tokens[0] === "--") { tokens.shift(); positional.push(...tokens); break; }
553
557
  const taken = options.takeDashed(tokens);
554
558
  if (taken !== undefined) {
555
- if (taken.def.id === "attach") {
556
- attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value));
557
- continue;
558
- }
559
- if (taken.def.id === "project") {
560
- project = requireOptionPath(taken.def.canonical, taken.value);
561
- continue;
562
- }
563
- throw new CliUsageError(`unknown judge option: ${taken.def.canonical}`);
559
+ if (taken.def.id === "attach") attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value));
560
+ else if (taken.def.id === "project") project = requireOptionPath(taken.def.canonical, taken.value);
561
+ else throw new CliUsageError(`unknown ${owner} option: ${taken.def.canonical}`);
562
+ continue;
564
563
  }
565
564
  const token = tokens.shift()!;
566
- // Judge owns burden inference — rejected spellings from REJECTED_PUBLIC_SPELLINGS.
567
- if (isRejectedPublicSpelling("judge", token)) {
568
- throw new CliUsageError(
569
- "judge does not accept a public burden selector; Judge infers its own burden",
570
- );
571
- }
572
- if (token.startsWith("-") && token !== "-") {
573
- throw new CliUsageError(`unknown judge option: ${token}`);
565
+ if (owner === "judge" && isRejectedPublicSpelling("judge", token)) {
566
+ throw new CliUsageError("judge does not accept a public burden selector; Judge infers its own burden");
574
567
  }
568
+ if (token.startsWith("-") && token !== "-") throw new CliUsageError(`unknown ${owner} option: ${token}`);
575
569
  positional.push(token);
576
570
  }
577
-
578
571
  options.assertRequired();
579
- return {
580
- instruction: positional.join(" "),
581
- attachmentPaths,
582
- ...(project === undefined ? {} : { project }),
583
- };
572
+ return { instruction: positional.join(" "), attachmentPaths, ...(project === undefined ? {} : { project }) };
573
+ }
574
+
575
+ export function parseJudgeArgv(args: readonly string[]): ParseJudgeArgvResult {
576
+ return parseStandardMaterialArgv("judge", args);
577
+ }
578
+
579
+ export function parseInspectorArgv(args: readonly string[]): ParseInspectorArgvResult {
580
+ return parseStandardMaterialArgv("inspector", args);
584
581
  }
585
582
 
586
583
  /**
@@ -771,84 +768,65 @@ export type AdmitJudgeInvocationOptions = {
771
768
  * Atomically admit a Judge Role run: freeze Attachments, persist the request,
772
769
  * and reserve session placement under the #78 ledger book.
773
770
  */
774
- export async function admitJudgeInvocation(
771
+ async function admitStandardMaterialInvocation<R extends "judge" | "inspector">(
772
+ role: R,
775
773
  options: AdmitJudgeInvocationOptions,
776
- ): Promise<AdmittedJudgeInvocation> {
777
- // Empty project override must not reach resolve("") → cwd (silent default).
778
- if (options.project !== undefined) {
779
- requireOptionPath("--project", options.project);
780
- }
774
+ ): Promise<AdmittedRoleInvocationBase & { readonly role: R }> {
775
+ if (options.project !== undefined) requireOptionPath("--project", options.project);
781
776
  const projectRoot = resolve(options.project ?? options.cwd);
782
777
  const runId = (options.createRunId ?? uuidv7)();
783
778
  const { ledgerHome, bookKey, runDirectory, sessionDirectory, sessionFile } =
784
- roleRunSessionCoordinates({ cwd: projectRoot, runId, role: "judge", home: options.home });
779
+ roleRunSessionCoordinates({ cwd: projectRoot, runId, role, home: options.home });
785
780
  const attachmentsDirectory = join(runDirectory, "attachments");
786
781
  ensureRealDirectoryTree(ledgerHome, sessionDirectory);
787
782
  ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
788
-
789
- const { attachments, ticketNumber } = await freezeAttachmentsWithTicketNumber(
790
- options.attachmentPaths,
791
- attachmentsDirectory,
792
- );
783
+ const { attachments, ticketNumber } = await freezeAttachmentsWithTicketNumber(options.attachmentPaths, attachmentsDirectory);
793
784
  const ticketFields = ticketAdmissionFields(ticketNumber);
794
-
795
785
  const instruction = options.instruction;
796
- const instructionEmpty = instruction.trim() === "";
797
786
  const admitted = {
798
- role: "judge" as const,
799
- runId,
800
- bookKey,
801
- projectRoot,
802
- runDirectory,
803
- sessionDirectory,
804
- sessionFile,
805
- ...ticketFields,
806
- instruction,
807
- instructionEmpty,
808
- attachments: attachments.map((a) => ({
809
- provenancePath: a.provenancePath,
810
- frozenPath: a.frozenPath,
811
- byteLength: a.byteLength,
812
- sha256: a.sha256,
813
- mediaKind: a.mediaKind,
814
- })),
787
+ role, runId, bookKey, projectRoot, runDirectory, sessionDirectory, sessionFile,
788
+ ...ticketFields, instruction, instructionEmpty: instruction.trim() === "",
789
+ attachments: attachments.map((attachment) => ({ ...attachment })),
815
790
  };
816
791
  const admittedRequestPath = join(runDirectory, "admitted-request.json");
817
792
  await writeFile(admittedRequestPath, `${JSON.stringify(admitted, null, 2)}\n`, "utf8");
818
- await writeRoleInvocationLedger(admitted, admitted.role, options.model);
793
+ await writeRoleInvocationLedger(admitted, role, options.model);
794
+ return { ...admitted, attachments, admittedRequestPath };
795
+ }
819
796
 
820
- return {
821
- role: "judge",
822
- runId,
823
- bookKey,
824
- projectRoot,
825
- instruction,
826
- instructionEmpty,
827
- attachments,
828
- runDirectory,
829
- sessionDirectory,
830
- sessionFile,
831
- admittedRequestPath,
832
- ...ticketFields,
833
- };
797
+ export async function admitJudgeInvocation(options: AdmitJudgeInvocationOptions): Promise<AdmittedJudgeInvocation> {
798
+ return admitStandardMaterialInvocation("judge", options);
834
799
  }
835
800
 
836
- /** Build the Pi prompt transport for an admitted Judge request. */
837
- export function buildJudgeTransportPrompt(
838
- admitted: AdmittedJudgeInvocation,
801
+ export type AdmitInspectorInvocationOptions = AdmitJudgeInvocationOptions;
802
+
803
+ export async function admitInspectorInvocation(options: AdmitInspectorInvocationOptions): Promise<AdmittedInspectorInvocation> {
804
+ return admitStandardMaterialInvocation("inspector", options);
805
+ }
806
+
807
+ function buildStandardMaterialTransportPrompt(
808
+ admitted: AdmittedJudgeInvocation | AdmittedInspectorInvocation,
839
809
  engineMaterial?: EngineSessionMaterial,
840
810
  ): string {
841
811
  const lines: string[] = [admitted.instructionEmpty ? "" : admitted.instruction];
842
812
  if (admitted.attachments.length > 0) {
843
- lines.push("");
844
- lines.push("已受理附件(冻结快照路径):");
845
- for (const attachment of admitted.attachments) {
846
- lines.push(`- ${attachment.frozenPath}`);
847
- }
813
+ lines.push("", "已受理附件(冻结快照路径):", ...admitted.attachments.map((attachment) => `- ${attachment.frozenPath}`));
848
814
  }
849
815
  return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
850
816
  }
851
817
 
818
+ export function buildInspectorTransportPrompt(admitted: AdmittedInspectorInvocation, engineMaterial?: EngineSessionMaterial): string {
819
+ return buildStandardMaterialTransportPrompt(admitted, engineMaterial);
820
+ }
821
+
822
+ /** Build the Pi prompt transport for an admitted Judge request. */
823
+ export function buildJudgeTransportPrompt(
824
+ admitted: AdmittedJudgeInvocation,
825
+ engineMaterial?: EngineSessionMaterial,
826
+ ): string {
827
+ return buildStandardMaterialTransportPrompt(admitted, engineMaterial);
828
+ }
829
+
852
830
  /** Load admitted-request.json written at admission (Navigator work-context seam). */
853
831
  export async function loadAdmittedJudgeRequest(
854
832
  runDirectory: string,
@@ -29,6 +29,7 @@ export type OptionOwner =
29
29
  | "doctor"
30
30
  | "merger"
31
31
  | "notary"
32
+ | "inspector"
32
33
  | "analyst";
33
34
 
34
35
  /**
@@ -369,6 +370,11 @@ const JUDGE_OPTIONS = [
369
370
  bindOwner("judge", SHARED_ATTACH_SEMANTICS),
370
371
  ] as const satisfies readonly PublicOptionDefinition[];
371
372
 
373
+ const INSPECTOR_OPTIONS = [
374
+ bindOwner("inspector", SHARED_PROJECT_SEMANTICS),
375
+ bindOwner("inspector", SHARED_ATTACH_SEMANTICS),
376
+ ] as const satisfies readonly PublicOptionDefinition[];
377
+
372
378
  const CODER_OPTIONS = [
373
379
  {
374
380
  id: "phase",
@@ -721,6 +727,7 @@ export const PUBLIC_OPTION_TABLE = {
721
727
  doctor: DOCTOR_OPTIONS,
722
728
  merger: MERGER_OPTIONS,
723
729
  notary: NOTARY_OPTIONS,
730
+ inspector: INSPECTOR_OPTIONS,
724
731
  analyst: ANALYST_OPTIONS,
725
732
  } as const satisfies Record<OptionOwner, readonly PublicOptionDefinition[]>;
726
733
 
@@ -736,6 +743,7 @@ export const PUBLIC_ROLE_OPTION_OWNERS = [
736
743
  "doctor",
737
744
  "merger",
738
745
  "notary",
746
+ "inspector",
739
747
  "analyst",
740
748
  ] as const satisfies readonly PublicRoleOptionOwner[];
741
749
 
@@ -1097,6 +1105,12 @@ const ROLE_COMMAND_HELP = {
1097
1105
  "ak-role notary --source-run 01a034f1-75bf-71a6-bcf5-d1299145b1a5@judge",
1098
1106
  ],
1099
1107
  },
1108
+ inspector: {
1109
+ command: "inspector",
1110
+ summary: "Direct complexity and test-quality inspection.",
1111
+ usage: ["ak-role inspector [options] [instruction]"],
1112
+ examples: ['ak-role inspector --attach ./change.patch "Review this material."'],
1113
+ },
1100
1114
  analyst: {
1101
1115
  command: "analyst",
1102
1116
  summary: "Deterministic analysis seat (issue / sweep / cohort).",
@@ -19,14 +19,12 @@ export type PublicCallableRole = (typeof PUBLIC_CALLABLE_ROLES)[number];
19
19
  /** Automatic attendance seat — configurable, never a caller-selected command. */
20
20
  export const AUTOMATIC_NAVIGATOR_SEAT = "navigator" as const;
21
21
 
22
- /** Automatic gate seats — configurable model only; never caller commands (#453). */
22
+ /** Automatic province seat — configurable model only; never a caller command. */
23
23
  export const AUTOMATIC_GATEKEEPER_SEAT = "gatekeeper" as const;
24
- export const AUTOMATIC_INSPECTOR_SEAT = "inspector" as const;
25
24
 
26
- /** All automatic configurable seats (no independent public activation command). */
25
+ /** Automatic-only configurable seats. Inspector remains automatic-capable via its callable seat. */
27
26
  export const AUTOMATIC_CONFIGURABLE_SEATS = [
28
27
  AUTOMATIC_GATEKEEPER_SEAT,
29
- AUTOMATIC_INSPECTOR_SEAT,
30
28
  AUTOMATIC_NAVIGATOR_SEAT,
31
29
  ] as const;
32
30
 
@@ -68,7 +68,8 @@ export type RoleRunRecord = {
68
68
  | "doctor"
69
69
  | "reviewer"
70
70
  | "merger"
71
- | "notary";
71
+ | "notary"
72
+ | "inspector";
72
73
  readonly state: RoleRunState;
73
74
  readonly bookKey: string;
74
75
  readonly projectRoot: string;
@@ -179,7 +180,8 @@ export async function readRoleRunState(
179
180
  record.role !== "doctor" &&
180
181
  record.role !== "reviewer" &&
181
182
  record.role !== "merger" &&
182
- record.role !== "notary"
183
+ record.role !== "notary" &&
184
+ record.role !== "inspector"
183
185
  ) {
184
186
  return undefined;
185
187
  }
@@ -523,7 +525,8 @@ const WRITER_LEASE_RECLAIM_ROUNDS = 3;
523
525
  * race repeatedly lost) surfaces the same typed error instead of spinning.
524
526
  *
525
527
  * `onCleanupFailure` receives a non-terminal diagnostic line when release-time
526
- * lock cleanup fails or a contested lock cannot be read. Release stays
528
+ * lock cleanup fails, a contested lock cannot be read, or a stale lock is
529
+ * reclaimed (the #556 orphan-pi residual declaration). Release stays
527
530
  * best-effort (a stale lock is reclaimed by the next acquire's autopsy), but
528
531
  * the true error identity must still land somewhere observable — silent
529
532
  * swallowing is forbidden.
@@ -532,18 +535,24 @@ export async function acquireRunWriterLease(
532
535
  runDirectory: string,
533
536
  onCleanupFailure?: (diagnostic: string) => void,
534
537
  ): Promise<RunWriterLease> {
535
- const reportCleanupFailure = (error: unknown): void => {
536
- // Sink isolation: a throwing onCleanupFailure must not propagate through
537
- // release() — release stays best-effort by contract. The true cleanup
538
- // cause has already been handed to the sink as its argument.
538
+ const reportDiagnostic = (diagnostic: string): void => {
539
+ // Single sink exit for every non-terminal writer-lease diagnostic (#556):
540
+ // a throwing onCleanupFailure must not propagate through release() or
541
+ // acquire() — both are best-effort by contract. Template wording stays at
542
+ // the call sites, never inside this wrapper. The default io.stderr writes
543
+ // raw, so this exit guarantees the line delimiter.
544
+ const line = diagnostic.endsWith("\n") ? diagnostic : `${diagnostic}\n`;
539
545
  try {
540
- onCleanupFailure?.(
541
- `writer lease lock cleanup failed (best-effort continue; stale lock is reclaimed by the next acquire's holder autopsy) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
542
- );
546
+ onCleanupFailure?.(line);
543
547
  } catch {
544
- // diagnostic-sink failure is itself best-effort; never break release().
548
+ // diagnostic-sink failure is itself best-effort; never break acquire()/release().
545
549
  }
546
550
  };
551
+ const reportCleanupFailure = (error: unknown): void => {
552
+ reportDiagnostic(
553
+ `writer lease lock cleanup failed (best-effort continue; stale lock is reclaimed by the next acquire's holder autopsy) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
554
+ );
555
+ };
547
556
  const lockPath = join(runDirectory, WRITER_LOCK_FILE);
548
557
  let lastAutopsy: WriterLockAutopsy = { verdict: "absent" };
549
558
  // The reclaim budget: every successful reclaim is immediately followed by a
@@ -585,6 +594,12 @@ export async function acquireRunWriterLease(
585
594
  `stale writer lease reclaim failed at ${lockPath} (autopsy: ${describeAutopsy(lastAutopsy)}): ${describeErrorIdentity(reclaimError)}`,
586
595
  );
587
596
  }
597
+ // #556 residual declaration, facts only: the pid-only autopsy proves the
598
+ // CLI holder died; its pi child may outlive it and keep writing this run.
599
+ // Declare — do not guard.
600
+ reportDiagnostic(
601
+ `stale writer lease reclaimed at ${lockPath} (holder pid ${lastAutopsy.pid} verified dead): the killed holder may have left an orphaned pi child still writing this run — check for a surviving pi process on this run before continuing`,
602
+ );
588
603
  }
589
604
  throw new RunWriterLeaseHeldError(
590
605
  `role run writer lease stayed contested at ${lockPath} after ${WRITER_LEASE_RECLAIM_ROUNDS} reclaims (last autopsy: ${describeAutopsy(lastAutopsy)})`,
@@ -1150,6 +1165,7 @@ export async function peekRoleRunRole(
1150
1165
  | "reviewer"
1151
1166
  | "merger"
1152
1167
  | "notary"
1168
+ | "inspector"
1153
1169
  | undefined
1154
1170
  > {
1155
1171
  const runDirectory = await findRunDirectoryById(home, runId);