@akagilnc/pi-workflow-roles 0.1.3771 → 0.1.3789

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.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/production-host.js +793 -393
  4. package/dist/atomic-write.js +23 -0
  5. package/dist/collector-handbook.js +142 -0
  6. package/dist/collector-ledger.js +39 -47
  7. package/dist/collector-role.js +103 -14
  8. package/dist/collector-tool-schemas.js +25 -1
  9. package/dist/headless-host/description.js +77 -0
  10. package/dist/headless-host/mcp-relay.mjs +119 -0
  11. package/dist/headless-host/production-host.js +26841 -0
  12. package/dist/host-descriptions.js +44 -3
  13. package/dist/public-cli/load-production-external-host.js +19 -0
  14. package/dist/public-cli/load-production-headless-host.js +36 -0
  15. package/dist/public-cli/main.js +244 -234
  16. package/dist/public-cli/option-definitions.js +2 -2
  17. package/dist/public-role-summons.js +40 -5
  18. package/dist/role-runtime.js +3 -0
  19. package/extensions/role-runtime.ts +2 -0
  20. package/package.json +1 -1
  21. package/resources/collector-bot-handbook.md +33 -0
  22. package/scripts/build-package.mjs +28 -0
  23. package/src/acp-host/production-host.ts +4 -0
  24. package/src/acp-host/role-envelope.ts +141 -84
  25. package/src/acp-host/role-turn-host.ts +12 -0
  26. package/src/collector-handbook.ts +194 -0
  27. package/src/collector-ledger.ts +42 -62
  28. package/src/collector-role.ts +117 -11
  29. package/src/collector-tool-schemas.ts +25 -1
  30. package/src/headless-host/description.ts +123 -0
  31. package/src/headless-host/production-host.ts +75 -0
  32. package/src/headless-host/role-turn-host.ts +424 -0
  33. package/src/host-descriptions.ts +53 -6
  34. package/src/public-cli/cli.ts +2 -2
  35. package/src/public-cli/load-production-external-host.ts +29 -0
  36. package/src/public-cli/load-production-headless-host.ts +48 -0
  37. package/src/public-cli/main.ts +5 -0
  38. package/src/public-cli/option-definitions.ts +2 -2
  39. package/src/public-role-summons.ts +62 -9
  40. package/src/role-runtime.ts +5 -0
@@ -483,8 +483,8 @@ const COLLECTOR_OPTIONS = [
483
483
  repeatable: false,
484
484
  form: "option",
485
485
  description: {
486
- en: "Optional request manifest JSON path ({requests:[{id,body}]}).",
487
- zh: "可选 request manifest JSON 路径({requests:[{id,body}]})。",
486
+ en: "Optional request manifest JSON path ({requests:[{id,body}]}). Role may also decide request bodies from handbook/field activity without this file.",
487
+ zh: "可选 request manifest JSON 路径({requests:[{id,body}]})。角色亦可依手册/现场自行判定请求正文,不必依赖本文件。",
488
488
  },
489
489
  },
490
490
  ];
@@ -99,9 +99,9 @@ async function createSummonEnv(options) {
99
99
  const hostName = options.seat.host ?? "pi";
100
100
  let roleTurnHost = piHost;
101
101
  if (hostName !== "pi") {
102
- const { lookupHostDescription } = await import("./host-descriptions.js");
103
- const { loadProductionAcpHostFactory } = await import("./public-cli/load-production-acp-host.js");
104
- if (lookupHostDescription(hostName) === void 0) {
102
+ const { lookupHostFamily } = await import("./host-descriptions.js");
103
+ const { loadProductionExternalHostFactory } = await import("./public-cli/load-production-external-host.js");
104
+ if (lookupHostFamily(hostName) === void 0) {
105
105
  throw new Error(
106
106
  `public role summons host unregistered: host=${hostName} seat=${options.role}`
107
107
  );
@@ -109,7 +109,7 @@ async function createSummonEnv(options) {
109
109
  let hostPromise;
110
110
  roleTurnHost = {
111
111
  executeTurn: async (request) => {
112
- hostPromise ??= loadProductionAcpHostFactory(options.packageRoot, hostName).then(
112
+ hostPromise ??= loadProductionExternalHostFactory(options.packageRoot, hostName).then(
113
113
  (create) => create({
114
114
  packageRoot: options.packageRoot,
115
115
  principalAuthority
@@ -151,7 +151,8 @@ async function summonPublicRole(options) {
151
151
  } = await import("./public-cli/config.js");
152
152
  const credentials = options.credentials ?? await loadCredentialProviders(agentDir);
153
153
  const config = await loadPublicCliConfig(home);
154
- const seat = resolveEffectiveSeat(config, options.role, credentials);
154
+ const invocation = options.host === void 0 ? void 0 : { host: options.host };
155
+ const seat = resolveEffectiveSeat(config, options.role, credentials, invocation);
155
156
  const env = {
156
157
  ...await createSummonEnv({
157
158
  role: options.role,
@@ -263,6 +264,38 @@ async function summonPublicRole(options) {
263
264
  ...stderr === void 0 || stderr === "" ? {} : { stderr }
264
265
  };
265
266
  }
267
+ async function parentInvocationHost(sourceRunDirectory) {
268
+ const { readFile } = await import("node:fs/promises");
269
+ const path = join(sourceRunDirectory, "invocation.json");
270
+ let text;
271
+ try {
272
+ text = await readFile(path, "utf8");
273
+ } catch (error) {
274
+ const code = error instanceof Error && "code" in error ? error.code : void 0;
275
+ throw new Error(
276
+ `parent invocation.json required for gate source run at ${path}: ${code ?? (error instanceof Error ? error.message : String(error))}`,
277
+ { cause: error }
278
+ );
279
+ }
280
+ let raw;
281
+ try {
282
+ raw = JSON.parse(text);
283
+ } catch (error) {
284
+ throw new Error(
285
+ `parent invocation.json unreadable at ${path}: ${error instanceof Error ? error.message : String(error)}`,
286
+ { cause: error }
287
+ );
288
+ }
289
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
290
+ throw new Error(`parent invocation.json has non-object shape at ${path}`);
291
+ }
292
+ const host = raw.host;
293
+ if (host === void 0) return void 0;
294
+ if (typeof host !== "string" || host.trim() === "") {
295
+ throw new Error(`parent invocation.json host must be a non-empty string at ${path}`);
296
+ }
297
+ return host;
298
+ }
266
299
  async function summonGateOfficer(options) {
267
300
  let home = options.home;
268
301
  if (home === void 0) {
@@ -276,6 +309,7 @@ async function summonGateOfficer(options) {
276
309
  submission: options.submission
277
310
  });
278
311
  }
312
+ const parentHost = await parentInvocationHost(options.sourceRunDirectory);
279
313
  const common = {
280
314
  cwd: options.cwd,
281
315
  ...home === void 0 ? {} : { home },
@@ -284,6 +318,7 @@ async function summonGateOfficer(options) {
284
318
  ...options.signal === void 0 ? {} : { signal: options.signal },
285
319
  ...options.reask === void 0 ? {} : { reviewReask: options.reask },
286
320
  ...gateReviewInstruction === void 0 ? {} : { gateReviewInstruction },
321
+ ...parentHost === void 0 ? {} : { host: parentHost },
287
322
  ...options.roleTurnHost === void 0 ? {} : { roleTurnHost: options.roleTurnHost },
288
323
  ...options.createRunId === void 0 ? {} : { createRunId: options.createRunId }
289
324
  };
@@ -1223,6 +1223,9 @@ export function createRoleRuntimeExtension(dependencies) {
1223
1223
  ...(dependencies.createCollectorClock === undefined
1224
1224
  ? {}
1225
1225
  : { createClock: dependencies.createCollectorClock }),
1226
+ ...(dependencies.loadCollectorHandbookSeed === undefined
1227
+ ? {}
1228
+ : { loadHandbookSeed: dependencies.loadCollectorHandbookSeed }),
1226
1229
  }, hostActions);
1227
1230
  // #676 J1: business tools + tool_call gate register only behind admission/activation.
1228
1231
  // before_agent_start / tool_result collector branches live on the shared envelope hooks above.
@@ -40,6 +40,7 @@ import { loadGatekeeperSessionMaterials, loadMainRoleSessionMaterials } from "..
40
40
  const extensionPath = fileURLToPath(import.meta.url);
41
41
  const packageRoot = fileURLToPath(new URL("..", import.meta.url));
42
42
  const navigatorRoutePlaybookPath = fileURLToPath(new URL("../resources/navigator-route-playbook.md", import.meta.url));
43
+ const collectorHandbookSeedPath = fileURLToPath(new URL("../resources/collector-bot-handbook.md", import.meta.url));
43
44
  // #675: nested public summons (gate/auditor) resolve root via env under jiti.
44
45
  if (process.env.AK_ROLE_PACKAGE_ROOT === undefined || process.env.AK_ROLE_PACKAGE_ROOT.trim() === "") {
45
46
  process.env.AK_ROLE_PACKAGE_ROOT = packageRoot;
@@ -122,6 +123,7 @@ export default function roleRuntime(pi: ExtensionAPI): void {
122
123
  createReviewerPinnedGitReader: () => createReviewerPinnedGitReader(),
123
124
  createReviewerIssueFetcher: () => createGhIssueSoftFetcher(),
124
125
  loadCollectorSoul: () => loadMainRoleSessionMaterials("collector"),
126
+ loadCollectorHandbookSeed: () => readFile(collectorHandbookSeedPath, "utf8"),
125
127
  createCollectorTransport: () => createGhCollectorGitHubTransport(),
126
128
  loadDoctorSoul: () => loadMainRoleSessionMaterials("doctor"),
127
129
  loadDoctorCase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.3771",
3
+ "version": "0.1.3789",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,33 @@
1
+ # 通进司 bot 工作记忆(通用)
2
+
3
+ 薄手册:触发方式、自动/手动条件、完成与不可继续的识别线索、官方出处。
4
+ 内容是角色阅读与修正的工作记忆,不是代码状态规则;临时额度/故障不得固化为永久行为。
5
+ 参与者依本仓使用与现场活动辨认,不把下列名称当作全仓固定名单。
6
+
7
+ ## Codex(hosted GitHub connector)
8
+
9
+ - 官方:https://learn.chatgpt.com/docs/third-party/github
10
+ - 常见主动请求:`@codex review`
11
+ - 新建 PR 常有自动评审;是否已在审当前提交以现场 reviews/comments 为准
12
+
13
+ ## CodeRabbit
14
+
15
+ - 官方:https://docs.coderabbit.ai/configuration/auto-review
16
+ - 命令:https://docs.coderabbit.ai/guides/commands
17
+ - 常见主动请求:`@coderabbitai review`;全量:`@coderabbitai full review`
18
+ - 默认常为增量;本仓实际自动/暂停以现场与仓库差异条为准
19
+
20
+ ## Sourcery
21
+
22
+ - 官方:https://docs.sourcery.ai/reviews/ 与 https://docs.sourcery.ai/reviews/commands/
23
+ - 新建/更新与主动请求依文档与本仓现场
24
+
25
+ ## Cursor Bugbot / 自动化
26
+
27
+ - 官方:https://prod.cursor.com/help/ai-features/bugbot 与 automations 文档
28
+ - 内置 Bugbot 与同账号自定义任务须区分;某任务额度耗尽不推断其他任务失效
29
+
30
+ ## 修正规则
31
+
32
+ - 优先引用官方文档;现场差异记入仓库差异条并附 PR/证据指针
33
+ - 未知或暂未响应 ≠ 通过或确定死亡
@@ -114,6 +114,33 @@ export async function buildAcpProductionHost(
114
114
  );
115
115
  }
116
116
 
117
+ /**
118
+ * Deferred generic headless production host artifact (#645). Same peer-free
119
+ * selection face as ACP: loaded only when a headless description-table row is
120
+ * selected.
121
+ */
122
+ export async function buildHeadlessProductionHost(
123
+ outfile = "dist/headless-host/production-host.js",
124
+ ) {
125
+ await mkdir(dirname(outfile), { recursive: true });
126
+ await build({
127
+ entryPoints: ["src/headless-host/production-host.ts"],
128
+ outfile,
129
+ format: "esm",
130
+ platform: "node",
131
+ target: "node20",
132
+ bundle: true,
133
+ packages: "external",
134
+ logLevel: "silent",
135
+ });
136
+ // Shared envelope resolves ./mcp-relay.mjs from import.meta.url of the bundle
137
+ // (#645 headless reuses the ACP relay for AK tools under --mcp-config).
138
+ await copyFile(
139
+ resolve("src/acp-host/mcp-relay.mjs"),
140
+ join(dirname(outfile), "mcp-relay.mjs"),
141
+ );
142
+ }
143
+
117
144
  export async function buildPackageArtifacts() {
118
145
  await build({
119
146
  entryPoints: entries.map((name) => `src/${name}.ts`),
@@ -139,6 +166,7 @@ export async function buildPackageArtifacts() {
139
166
  }
140
167
  await buildPublicAkRoleBin();
141
168
  await buildAcpProductionHost();
169
+ await buildHeadlessProductionHost();
142
170
  }
143
171
 
144
172
  const isMain =
@@ -39,6 +39,9 @@ export type ProductionAcpHostOptions = Readonly<{
39
39
  const navigatorRoutePlaybookPath = fileURLToPath(
40
40
  new URL("../../resources/navigator-route-playbook.md", import.meta.url),
41
41
  );
42
+ const collectorHandbookSeedPath = fileURLToPath(
43
+ new URL("../../resources/collector-bot-handbook.md", import.meta.url),
44
+ );
42
45
 
43
46
  /** Host-neutral packaged role runtime deps for the ACP parent-process envelope. */
44
47
  export function createAcpRoleRuntimeDependencies(packageRoot: string): RoleRuntimeDependencies {
@@ -55,6 +58,7 @@ export function createAcpRoleRuntimeDependencies(packageRoot: string): RoleRunti
55
58
  createReviewerPinnedGitReader: () => createReviewerPinnedGitReader(),
56
59
  createReviewerIssueFetcher: () => createGhIssueSoftFetcher(),
57
60
  loadCollectorSoul: () => loadMainRoleSessionMaterials("collector"),
61
+ loadCollectorHandbookSeed: () => readFile(collectorHandbookSeedPath, "utf8"),
58
62
  createCollectorTransport: () => createGhCollectorGitHubTransport(),
59
63
  loadDoctorSoul: () => loadMainRoleSessionMaterials("doctor"),
60
64
  loadDoctorCase,
@@ -15,7 +15,7 @@ import type {
15
15
  RoleTurnKnownFailure,
16
16
  RoleTurnRequest,
17
17
  } from "../host-contracts.ts";
18
- import { packagedRoleInputFlag, packagedRolePhaseFlag } from "../packaged-role-registry.ts";
18
+ import { packagedRoleInputFlag, packagedRoleOutputTool, packagedRolePhaseFlag } from "../packaged-role-registry.ts";
19
19
  import { stripSkillFrontmatter } from "../package-resources/method-skill.ts";
20
20
  import {
21
21
  createRoleRuntimeExtension,
@@ -131,10 +131,33 @@ export function createComposedAcpRoleTurnHost(
131
131
  });
132
132
  }
133
133
 
134
+ /** JSON Schema draft-07 document for host-native `--json-schema` (headless). */
135
+ export function terminatingToolJsonSchema(parameters: unknown): Readonly<Record<string, unknown>> {
136
+ const cloned = JSON.parse(JSON.stringify(parameters)) as Record<string, unknown>;
137
+ // $schema last so a newer declaration on the tool parameters cannot override draft-07.
138
+ return Object.freeze({
139
+ ...cloned,
140
+ $schema: "http://json-schema.org/draft-07/schema#",
141
+ });
142
+ }
143
+
134
144
  export async function prepareAcpRoleEnvelope(options: {
135
145
  readonly request: RoleTurnRequest;
136
146
  readonly dependencies: RoleRuntimeDependencies;
147
+ /**
148
+ * MCP unix socket path for the protocol-relay child. Always required — AK tools
149
+ * ride this single MCP path; headless structured_output reuses the same
150
+ * terminating-tool ledger path without listing the terminating tool on MCP.
151
+ */
137
152
  readonly socketPath: string;
153
+ /**
154
+ * Whether MCP `tools/list` advertises the role terminating tool.
155
+ * ACP keeps it listed (session-tool receipt). Headless hides it so the host
156
+ * native `--json-schema` / structured_output is the sole schema channel
157
+ * (#750 submission-tool-is-schema-channel) and empty MCP probes cannot
158
+ * pre-empt a later structured_output.
159
+ */
160
+ readonly listTerminatingToolOnMcp?: boolean;
138
161
  /**
139
162
  * Durable principal session path (header layout only).
140
163
  * Production passes DurablePrincipalAuthority.decode(principal).sessionFile so
@@ -143,6 +166,14 @@ export async function prepareAcpRoleEnvelope(options: {
143
166
  readonly sessionFile?: string;
144
167
  }): Promise<AcpPreparedTurn> {
145
168
  const { request } = options;
169
+ if (options.socketPath === "") {
170
+ throw new Error("prepareAcpRoleEnvelope requires socketPath");
171
+ }
172
+ const listTerminatingToolOnMcp = options.listTerminatingToolOnMcp !== false;
173
+ const earlyTerminatingTool = packagedRoleOutputTool(request.activation.role);
174
+ if (earlyTerminatingTool === undefined) {
175
+ throw new Error(`role has no terminating tool: ${request.activation.role}`);
176
+ }
146
177
  const flags = projectAcpActivationFlags(request);
147
178
  const tools = new Map<string, HostToolDefinition>();
148
179
  const handlers = new Map<string, Handler[]>();
@@ -403,6 +434,87 @@ export async function prepareAcpRoleEnvelope(options: {
403
434
  await emit("tool_execution_end", { toolCallId, toolName, isError: projected.isError });
404
435
  return projected;
405
436
  }
437
+ /**
438
+ * Shared terminating/support tool path for MCP relay and headless structured_output.
439
+ * Books the call, runs execute, projects tool_result; does not emit turn_end (closeRound).
440
+ */
441
+ async function invokeAkTool(name: string, args: unknown): Promise<{
442
+ content: ContentPart[];
443
+ isError: boolean;
444
+ blocked?: true;
445
+ }> {
446
+ const tool = tools.get(name);
447
+ if (tool === undefined) throw new Error(`Unknown AK tool: ${name}`);
448
+ const toolCallId = randomUUID();
449
+ calls.push({ toolCallId, toolName: name });
450
+ // First-record-then-audit: book the tool-call leaf in memory before execute so
451
+ // judge/doctor subject gates see the candidate on parent session books.
452
+ sessionEntries.push({
453
+ type: "message",
454
+ message: {
455
+ role: "assistant" as const,
456
+ content: [{
457
+ type: "toolCall",
458
+ id: toolCallId,
459
+ name,
460
+ arguments: args ?? {},
461
+ }],
462
+ },
463
+ });
464
+ try {
465
+ await emit("tool_execution_start", { toolCallId, toolName: name });
466
+ const blocked = (await emit("tool_call", { toolCallId, toolName: name, input: (args ?? {}) as Record<string, unknown> }))
467
+ .some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
468
+ if (blocked) {
469
+ // Lawful seatbelt/block stays bare rejection — not infrastructure (#593 r3).
470
+ return { content: [{ type: "text", text: `AK tool blocked: ${name}` }], isError: true, blocked: true };
471
+ }
472
+ } catch (error) {
473
+ // Pre-execution emit failure shares the non-correctable infra pathway (#593 r3).
474
+ const declared = declareRoundInfrastructureFailure(error);
475
+ try {
476
+ const projected = await projectToolResult(toolCallId, name, {
477
+ content: declared.content,
478
+ details: declared.details,
479
+ isError: true,
480
+ });
481
+ return { content: projected.content, isError: true };
482
+ } catch {
483
+ return { content: declared.content, isError: true };
484
+ }
485
+ }
486
+ try {
487
+ const result = await tool.execute(toolCallId, (args ?? {}) as never, undefined, undefined, context);
488
+ const projected = await projectToolResult(toolCallId, name, {
489
+ content: result.content,
490
+ details: result.details,
491
+ isError: false,
492
+ });
493
+ // Candidate only: seal waits for closeRound after the host round boundary.
494
+ return { content: projected.content, isError: projected.isError };
495
+ } catch (error) {
496
+ let content: ContentPart[];
497
+ let details: Record<string, unknown>;
498
+ if (isCorrectableExecuteError(error)) {
499
+ const projected = projectCorrectableExecuteRejection(error);
500
+ content = [{ type: "text", text: projected.diagnostic }];
501
+ details = projected.details;
502
+ } else {
503
+ // Slot-before-abort; projectToolResult may still project durable details.
504
+ ({ content, details } = declareRoundInfrastructureFailure(error));
505
+ }
506
+ // The shared envelope's tool_result handler is the sole classifier:
507
+ // it projects either the structured submission non-pass (correctable
508
+ // rejection) or the typed infrastructure fact onto the reply.
509
+ const projected = await projectToolResult(toolCallId, name, {
510
+ content,
511
+ details,
512
+ isError: true,
513
+ });
514
+ return { content: projected.content, isError: projected.isError };
515
+ }
516
+ }
517
+
406
518
  function reply(socket: Socket, id: number, result?: unknown, error?: unknown): void {
407
519
  const rpcError = error instanceof Error
408
520
  ? { code: "ak-relay-failure", name: error.name, message: error.message }
@@ -424,7 +536,9 @@ export async function prepareAcpRoleEnvelope(options: {
424
536
  if (rpc.token !== token) { reply(socket, rpc.id, undefined, "unauthorized relay"); return; }
425
537
  try {
426
538
  if (rpc.method === "tools/list") {
427
- reply(socket, rpc.id, { tools: [...tools.values()].map((tool) => {
539
+ const listed = [...tools.values()].filter((tool) =>
540
+ listTerminatingToolOnMcp || tool.name !== earlyTerminatingTool);
541
+ reply(socket, rpc.id, { tools: listed.map((tool) => {
428
542
  return { name: tool.name, description: tool.description, inputSchema: tool.parameters };
429
543
  }) });
430
544
  return;
@@ -433,95 +547,24 @@ export async function prepareAcpRoleEnvelope(options: {
433
547
  const params = rpc.params as ToolCallParams | undefined;
434
548
  const name = params?.name;
435
549
  if (typeof name !== "string") throw new Error("MCP tool name is missing");
436
- const tool = tools.get(name);
437
- if (tool === undefined) throw new Error(`Unknown AK tool: ${name}`);
438
- const toolCallId = randomUUID();
439
- calls.push({ toolCallId, toolName: name });
440
- // First-record-then-audit: book the tool-call leaf in memory before execute so
441
- // judge/doctor subject gates see the candidate on parent session books.
442
- {
443
- const message = {
444
- role: "assistant" as const,
445
- content: [{
446
- type: "toolCall",
447
- id: toolCallId,
448
- name,
449
- arguments: params?.arguments ?? {},
450
- }],
451
- };
452
- sessionEntries.push({ type: "message", message });
550
+ // Headless schema channel owns the terminating receipt — refuse MCP
551
+ // terminating calls so an empty probe cannot book a non-sealable candidate.
552
+ if (!listTerminatingToolOnMcp && name === earlyTerminatingTool) {
553
+ throw new Error(`terminating tool ${name} is schema-channel only on this host`);
453
554
  }
454
- try {
455
- await emit("tool_execution_start", { toolCallId, toolName: name });
456
- const blocked = (await emit("tool_call", { toolCallId, toolName: name, input: params?.arguments ?? {} }))
457
- .some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
458
- if (blocked) {
459
- // Lawful seatbelt/block stays bare RPC rejection — not infrastructure (#593 r3).
460
- reply(socket, rpc.id, undefined, new Error(`AK tool blocked: ${name}`));
461
- return;
462
- }
463
- } catch (error) {
464
- // Pre-execution emit failure (e.g. observation writer) shares the same
465
- // non-correctable infra pathway as execute throws (#593 r3).
466
- const declared = declareRoundInfrastructureFailure(error);
467
- try {
468
- const projected = await projectToolResult(toolCallId, name, {
469
- content: declared.content,
470
- details: declared.details,
471
- isError: true,
472
- });
473
- reply(socket, rpc.id, {
474
- content: projected.content,
475
- isError: true,
476
- });
477
- } catch {
478
- // Slot already filled; durable projection may have partially failed.
479
- reply(socket, rpc.id, {
480
- content: declared.content,
481
- isError: true,
482
- });
483
- }
555
+ const outcome = await invokeAkTool(name, params?.arguments ?? {});
556
+ if (outcome.blocked === true) {
557
+ reply(socket, rpc.id, undefined, new Error(outcome.content.map((p) => p.type === "text" ? p.text : "").join("")));
484
558
  return;
485
559
  }
486
- try {
487
- const result = await tool.execute(toolCallId, (params?.arguments ?? {}) as never, undefined, undefined, context);
488
- const projected = await projectToolResult(toolCallId, name, {
489
- content: result.content,
490
- details: result.details,
491
- isError: false,
492
- });
493
- // Candidate only: do not emit turn_end here. Seal waits for the typed ACP
494
- // round boundary (closeRound after session/prompt), so delayed siblings stay
495
- // in the same round instead of becoming silent post-seal anomalies.
496
- reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
497
- } catch (error) {
498
- let content: ContentPart[];
499
- let details: Record<string, unknown>;
500
- if (isCorrectableExecuteError(error)) {
501
- const projected = projectCorrectableExecuteRejection(error);
502
- content = [{ type: "text", text: projected.diagnostic }];
503
- details = projected.details;
504
- } else {
505
- // Slot-before-abort; projectToolResult may still project durable details.
506
- ({ content, details } = declareRoundInfrastructureFailure(error));
507
- }
508
- // The shared envelope's tool_result handler is the sole classifier:
509
- // it projects either the structured submission non-pass (correctable
510
- // rejection) or the typed infrastructure fact onto the reply.
511
- const projected = await projectToolResult(toolCallId, name, {
512
- content,
513
- details,
514
- isError: true,
515
- });
516
- reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
517
- }
560
+ reply(socket, rpc.id, { content: outcome.content, ...(outcome.isError ? { isError: true } : {}) });
518
561
  } catch (error) { reply(socket, rpc.id, undefined, error); }
519
562
  })();
520
563
  }
521
564
  });
522
565
  }
523
- await listen(server, options.socketPath);
524
566
  const relay = fileURLToPath(new URL("./mcp-relay.mjs", import.meta.url));
567
+ await listen(server, options.socketPath);
525
568
  let disposed = false;
526
569
  // Tools execute in this process (relay is protocol-only). Mirror Pi's child-env
527
570
  // AK_ROLE_RUN_DIR / AK_ROLE_COURT_ATTEMPT injection onto the parent so ledger
@@ -552,8 +595,6 @@ export async function prepareAcpRoleEnvelope(options: {
552
595
  } catch (error) {
553
596
  cleanupFailures.push(error);
554
597
  }
555
- // Server closure is unconditional: a shutdown-handler failure must not leave
556
- // the listening MCP server keeping the completed invocation alive.
557
598
  try {
558
599
  const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections;
559
600
  if (typeof closeAll === "function") closeAll.call(server);
@@ -571,6 +612,14 @@ export async function prepareAcpRoleEnvelope(options: {
571
612
  }
572
613
  };
573
614
 
615
+ const terminatingToolName: string = earlyTerminatingTool;
616
+ async function ingestStructuredOutput(params: unknown): Promise<void> {
617
+ // MCP path may already have invoked the terminating tool this round; skip the
618
+ // duplicate so structured_output + tool-call does not arm non-sole.
619
+ if (calls.some((call) => call.toolName === terminatingToolName)) return;
620
+ await invokeAkTool(terminatingToolName, params ?? {});
621
+ }
622
+
574
623
  const closeRound: AcpPreparedTurn["closeRound"] = async () => {
575
624
  // Typed round boundary: hand the complete call list to the shared ledger once.
576
625
  if (calls.length > 0) {
@@ -654,6 +703,11 @@ export async function prepareAcpRoleEnvelope(options: {
654
703
  else process.env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId;
655
704
  runDirInjected = true;
656
705
 
706
+ const terminating = tools.get(terminatingToolName);
707
+ if (terminating === undefined) {
708
+ throw new Error(`terminating tool not registered after activation: ${terminatingToolName}`);
709
+ }
710
+ const jsonSchema = terminatingToolJsonSchema(terminating.parameters);
657
711
  return {
658
712
  mcpServers: [{
659
713
  name: `ak-${request.activation.role}`,
@@ -669,6 +723,9 @@ export async function prepareAcpRoleEnvelope(options: {
669
723
  abortSignal: hostAbort.signal,
670
724
  closeRound,
671
725
  dispose,
726
+ jsonSchema,
727
+ terminatingToolName,
728
+ ingestStructuredOutput,
672
729
  };
673
730
  } catch (error) {
674
731
  // listen already succeeded; dispose is not yet caller-owned. Release the
@@ -43,6 +43,18 @@ export type AcpPreparedTurn = Readonly<{
43
43
  | { readonly accepted: false; readonly failure: RoleTurnKnownFailure }
44
44
  >;
45
45
  dispose?(): Promise<void>;
46
+ /**
47
+ * Headless CLI family (#645): role terminating-tool schema for host-native
48
+ * `--json-schema`. Present for every prepared turn; ACP ignores it.
49
+ */
50
+ jsonSchema: Readonly<Record<string, unknown>>;
51
+ /** Terminating tool name whose schema is `jsonSchema`. */
52
+ terminatingToolName: string;
53
+ /**
54
+ * Headless CLI family: feed host-native `structured_output` through the same
55
+ * terminating-tool path the MCP relay uses (ledger + gates). ACP ignores it.
56
+ */
57
+ ingestStructuredOutput(params: unknown): Promise<void>;
46
58
  }>;
47
59
 
48
60
  /** Fold structured system-prompt authority into the provider-visible ACP override. */