@akagilnc/pi-workflow-roles 0.1.3758 → 0.1.3783

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 (63) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/dist/acp-host/production-host.js +1735 -948
  4. package/dist/collector-config.js +0 -1
  5. package/dist/collector-github.js +199 -2
  6. package/dist/collector-identity.js +128 -41
  7. package/dist/collector-ledger.js +38 -9
  8. package/dist/collector-receipt.js +19 -7
  9. package/dist/collector-role.js +330 -369
  10. package/dist/collector-target.js +169 -0
  11. package/dist/collector-tool-schemas.js +51 -14
  12. package/dist/headless-host/description.js +77 -0
  13. package/dist/headless-host/mcp-relay.mjs +119 -0
  14. package/dist/headless-host/production-host.js +26590 -0
  15. package/dist/host-descriptions.js +44 -3
  16. package/dist/package-contracts/collector-output.js +32 -0
  17. package/dist/package-contracts/terminating-infrastructure.js +13 -12
  18. package/dist/pi/role-turn-host.js +1 -2
  19. package/dist/public-cli/github-remote.js +45 -0
  20. package/dist/public-cli/invocation.js +53 -54
  21. package/dist/public-cli/load-production-external-host.js +19 -0
  22. package/dist/public-cli/load-production-headless-host.js +36 -0
  23. package/dist/public-cli/main.js +857 -310
  24. package/dist/public-cli/option-definitions.js +6 -4
  25. package/dist/public-cli/run-lifecycle.js +3 -3
  26. package/dist/public-cli/settlement.js +83 -6
  27. package/dist/public-role-summons.js +40 -5
  28. package/dist/role-runtime.js +137 -7
  29. package/dist/submission-correctable-error.js +24 -0
  30. package/extensions/role-runtime.ts +0 -1
  31. package/package.json +1 -1
  32. package/scripts/build-package.mjs +28 -0
  33. package/src/acp-host/role-envelope.ts +146 -103
  34. package/src/acp-host/role-turn-host.ts +12 -0
  35. package/src/collector-config.ts +0 -1
  36. package/src/collector-github.ts +236 -2
  37. package/src/collector-identity.ts +148 -40
  38. package/src/collector-ledger.ts +48 -10
  39. package/src/collector-receipt.ts +33 -14
  40. package/src/collector-role.ts +376 -450
  41. package/src/collector-target.ts +207 -0
  42. package/src/collector-tool-schemas.ts +62 -15
  43. package/src/headless-host/description.ts +123 -0
  44. package/src/headless-host/production-host.ts +75 -0
  45. package/src/headless-host/role-turn-host.ts +424 -0
  46. package/src/host-contracts.ts +2 -1
  47. package/src/host-descriptions.ts +53 -6
  48. package/src/package-contracts/collector-output.ts +72 -0
  49. package/src/package-contracts/terminating-infrastructure.ts +24 -13
  50. package/src/pi/role-turn-host.ts +1 -2
  51. package/src/public-cli/cli.ts +12 -3
  52. package/src/public-cli/collector-run.ts +3 -2
  53. package/src/public-cli/github-remote.ts +45 -0
  54. package/src/public-cli/invocation.ts +60 -59
  55. package/src/public-cli/load-production-external-host.ts +29 -0
  56. package/src/public-cli/load-production-headless-host.ts +48 -0
  57. package/src/public-cli/main.ts +5 -0
  58. package/src/public-cli/option-definitions.ts +6 -4
  59. package/src/public-cli/run-lifecycle.ts +2 -2
  60. package/src/public-cli/settlement.ts +82 -6
  61. package/src/public-role-summons.ts +62 -9
  62. package/src/role-runtime.ts +166 -13
  63. package/src/submission-correctable-error.ts +38 -0
@@ -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,
@@ -27,12 +27,9 @@ import {
27
27
  type AcpRoleTurnHostConfig,
28
28
  } from "./role-turn-host.ts";
29
29
  import {
30
- GatekeeperDecisionError,
31
- WorkerCommitReminderError,
32
- WorkerPrefixReminderError,
33
- WorkerUnfinishedReasonReminderError,
34
- } from "../submission-errors.ts";
35
- import { isCorrectableExecuteError } from "../submission-correctable-error.ts";
30
+ isCorrectableExecuteError,
31
+ projectCorrectableExecuteRejection,
32
+ } from "../submission-correctable-error.ts";
36
33
  import {
37
34
  buildNavigatorInfrastructureFailureFact,
38
35
  extractInfrastructureFailureEvidence,
@@ -98,7 +95,8 @@ export function projectAcpActivationFlags(request: RoleTurnRequest): Map<string,
98
95
  }
99
96
  if (activation.role === "collector") {
100
97
  flags.set("ak-collector-repo", activation.repo);
101
- flags.set("ak-collector-pr", activation.pr);
98
+ // #676 D1: pr optional at admission; omit flag when role binds from materials.
99
+ if (activation.pr !== undefined) flags.set("ak-collector-pr", activation.pr);
102
100
  if (activation.requestManifestPath !== undefined) flags.set("ak-collector-request-manifest", activation.requestManifestPath);
103
101
  }
104
102
  return flags;
@@ -133,10 +131,33 @@ export function createComposedAcpRoleTurnHost(
133
131
  });
134
132
  }
135
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
+
136
144
  export async function prepareAcpRoleEnvelope(options: {
137
145
  readonly request: RoleTurnRequest;
138
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
+ */
139
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;
140
161
  /**
141
162
  * Durable principal session path (header layout only).
142
163
  * Production passes DurablePrincipalAuthority.decode(principal).sessionFile so
@@ -145,6 +166,14 @@ export async function prepareAcpRoleEnvelope(options: {
145
166
  readonly sessionFile?: string;
146
167
  }): Promise<AcpPreparedTurn> {
147
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
+ }
148
177
  const flags = projectAcpActivationFlags(request);
149
178
  const tools = new Map<string, HostToolDefinition>();
150
179
  const handlers = new Map<string, Handler[]>();
@@ -405,6 +434,87 @@ export async function prepareAcpRoleEnvelope(options: {
405
434
  await emit("tool_execution_end", { toolCallId, toolName, isError: projected.isError });
406
435
  return projected;
407
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
+
408
518
  function reply(socket: Socket, id: number, result?: unknown, error?: unknown): void {
409
519
  const rpcError = error instanceof Error
410
520
  ? { code: "ak-relay-failure", name: error.name, message: error.message }
@@ -426,7 +536,9 @@ export async function prepareAcpRoleEnvelope(options: {
426
536
  if (rpc.token !== token) { reply(socket, rpc.id, undefined, "unauthorized relay"); return; }
427
537
  try {
428
538
  if (rpc.method === "tools/list") {
429
- 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) => {
430
542
  return { name: tool.name, description: tool.description, inputSchema: tool.parameters };
431
543
  }) });
432
544
  return;
@@ -435,107 +547,24 @@ export async function prepareAcpRoleEnvelope(options: {
435
547
  const params = rpc.params as ToolCallParams | undefined;
436
548
  const name = params?.name;
437
549
  if (typeof name !== "string") throw new Error("MCP tool name is missing");
438
- const tool = tools.get(name);
439
- if (tool === undefined) throw new Error(`Unknown AK tool: ${name}`);
440
- const toolCallId = randomUUID();
441
- calls.push({ toolCallId, toolName: name });
442
- // First-record-then-audit: book the tool-call leaf in memory before execute so
443
- // judge/doctor subject gates see the candidate on parent session books.
444
- {
445
- const message = {
446
- role: "assistant" as const,
447
- content: [{
448
- type: "toolCall",
449
- id: toolCallId,
450
- name,
451
- arguments: params?.arguments ?? {},
452
- }],
453
- };
454
- 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`);
455
554
  }
456
- try {
457
- await emit("tool_execution_start", { toolCallId, toolName: name });
458
- const blocked = (await emit("tool_call", { toolCallId, toolName: name, input: params?.arguments ?? {} }))
459
- .some((value) => typeof value === "object" && value !== null && "block" in value && value.block === true);
460
- if (blocked) {
461
- // Lawful seatbelt/block stays bare RPC rejection — not infrastructure (#593 r3).
462
- reply(socket, rpc.id, undefined, new Error(`AK tool blocked: ${name}`));
463
- return;
464
- }
465
- } catch (error) {
466
- // Pre-execution emit failure (e.g. observation writer) shares the same
467
- // non-correctable infra pathway as execute throws (#593 r3).
468
- const declared = declareRoundInfrastructureFailure(error);
469
- try {
470
- const projected = await projectToolResult(toolCallId, name, {
471
- content: declared.content,
472
- details: declared.details,
473
- isError: true,
474
- });
475
- reply(socket, rpc.id, {
476
- content: projected.content,
477
- isError: true,
478
- });
479
- } catch {
480
- // Slot already filled; durable projection may have partially failed.
481
- reply(socket, rpc.id, {
482
- content: declared.content,
483
- isError: true,
484
- });
485
- }
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("")));
486
558
  return;
487
559
  }
488
- try {
489
- const result = await tool.execute(toolCallId, (params?.arguments ?? {}) as never, undefined, undefined, context);
490
- const projected = await projectToolResult(toolCallId, name, {
491
- content: result.content,
492
- details: result.details,
493
- isError: false,
494
- });
495
- // Candidate only: do not emit turn_end here. Seal waits for the typed ACP
496
- // round boundary (closeRound after session/prompt), so delayed siblings stay
497
- // in the same round instead of becoming silent post-seal anomalies.
498
- reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
499
- } catch (error) {
500
- let content: ContentPart[];
501
- let details: Record<string, unknown>;
502
- if (isCorrectableExecuteError(error)) {
503
- const diagnostic = error instanceof Error ? error.message : String(error);
504
- content = [{ type: "text", text: diagnostic }];
505
- if (error instanceof GatekeeperDecisionError) {
506
- details = { ...error.result };
507
- } else if (
508
- error instanceof WorkerCommitReminderError
509
- || error instanceof WorkerPrefixReminderError
510
- || error instanceof WorkerUnfinishedReasonReminderError
511
- ) {
512
- details = { code: error.code };
513
- } else if (typeof (error as unknown as { code?: unknown }).code === "string") {
514
- details = { code: (error as unknown as { code: string }).code };
515
- } else {
516
- details = { code: error instanceof Error && error.name ? error.name : "correctable-submission-error" };
517
- }
518
- } else {
519
- // Slot-before-abort; projectToolResult may still project durable details.
520
- ({ content, details } = declareRoundInfrastructureFailure(error));
521
- }
522
- // The shared envelope's tool_result handler is the sole classifier:
523
- // it projects either the structured submission non-pass (correctable
524
- // rejection) or the typed infrastructure fact onto the reply.
525
- const projected = await projectToolResult(toolCallId, name, {
526
- content,
527
- details,
528
- isError: true,
529
- });
530
- reply(socket, rpc.id, { content: projected.content, ...(projected.isError ? { isError: true } : {}) });
531
- }
560
+ reply(socket, rpc.id, { content: outcome.content, ...(outcome.isError ? { isError: true } : {}) });
532
561
  } catch (error) { reply(socket, rpc.id, undefined, error); }
533
562
  })();
534
563
  }
535
564
  });
536
565
  }
537
- await listen(server, options.socketPath);
538
566
  const relay = fileURLToPath(new URL("./mcp-relay.mjs", import.meta.url));
567
+ await listen(server, options.socketPath);
539
568
  let disposed = false;
540
569
  // Tools execute in this process (relay is protocol-only). Mirror Pi's child-env
541
570
  // AK_ROLE_RUN_DIR / AK_ROLE_COURT_ATTEMPT injection onto the parent so ledger
@@ -566,8 +595,6 @@ export async function prepareAcpRoleEnvelope(options: {
566
595
  } catch (error) {
567
596
  cleanupFailures.push(error);
568
597
  }
569
- // Server closure is unconditional: a shutdown-handler failure must not leave
570
- // the listening MCP server keeping the completed invocation alive.
571
598
  try {
572
599
  const closeAll = (server as unknown as { closeAllConnections?: () => void }).closeAllConnections;
573
600
  if (typeof closeAll === "function") closeAll.call(server);
@@ -585,6 +612,14 @@ export async function prepareAcpRoleEnvelope(options: {
585
612
  }
586
613
  };
587
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
+
588
623
  const closeRound: AcpPreparedTurn["closeRound"] = async () => {
589
624
  // Typed round boundary: hand the complete call list to the shared ledger once.
590
625
  if (calls.length > 0) {
@@ -668,6 +703,11 @@ export async function prepareAcpRoleEnvelope(options: {
668
703
  else process.env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId;
669
704
  runDirInjected = true;
670
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);
671
711
  return {
672
712
  mcpServers: [{
673
713
  name: `ak-${request.activation.role}`,
@@ -683,6 +723,9 @@ export async function prepareAcpRoleEnvelope(options: {
683
723
  abortSignal: hostAbort.signal,
684
724
  closeRound,
685
725
  dispose,
726
+ jsonSchema,
727
+ terminatingToolName,
728
+ ingestStructuredOutput,
686
729
  };
687
730
  } catch (error) {
688
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. */
@@ -4,7 +4,6 @@ import { readFile } from "node:fs/promises";
4
4
  export const COLLECTOR_HOST = "github.com" as const;
5
5
  export const COLLECTOR_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
6
6
  export const COLLECTOR_REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
7
- export const COLLECTOR_FIXED_KICKOFF = "采集目标已受理,本局开始。";
8
7
 
9
8
  export type CollectorRepository = {
10
9
  display: string;
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
 
4
+ import { parseCollectorPrNumber } from "./collector-config.ts";
5
+
4
6
  export type GitHubPullRequest = {
5
7
  number: number;
6
8
  state: string;
@@ -199,6 +201,232 @@ function parseJson(text: string, label: string): unknown {
199
201
  }
200
202
  }
201
203
 
204
+ /**
205
+ * Shared PR-list payload parse for admission target lookup (#676 D1/D7).
206
+ * Every entry must carry a positive safe-integer number — malformed items fail
207
+ * the response (do not skip then claim uniqueness).
208
+ */
209
+ export function parsePullRequestNumberList(raw: unknown, label: string): number[] {
210
+ if (!Array.isArray(raw)) {
211
+ throw new Error(`GitHub ${label} payload is not a list`);
212
+ }
213
+ const numbers: number[] = [];
214
+ for (const item of raw) {
215
+ if (!isRecord(item)) {
216
+ throw new Error(`GitHub ${label} payload contains a non-object pull request entry`);
217
+ }
218
+ try {
219
+ numbers.push(parseCollectorPrNumber(item["number"]));
220
+ } catch (error) {
221
+ throw new Error(`GitHub ${label} payload contains an invalid pull request number`, {
222
+ cause: error,
223
+ });
224
+ }
225
+ }
226
+ return numbers;
227
+ }
228
+
229
+ /**
230
+ * Online PR association by head owner:ref (state=all). Caller supplies the real
231
+ * head owner from branch context — never assume base repository owner is the fork head.
232
+ * Transport/HTTP/JSON failures throw with true cause (not target-ambiguity wash).
233
+ */
234
+ export async function listPullRequestNumbersByHead(
235
+ runner: GhApiRunner,
236
+ input: {
237
+ readonly owner: string;
238
+ readonly repo: string;
239
+ readonly headOwner: string;
240
+ readonly headRef: string;
241
+ readonly signal?: AbortSignal;
242
+ },
243
+ ): Promise<number[]> {
244
+ const head = `${input.headOwner}:${input.headRef}`;
245
+ const path =
246
+ `/repos/${input.owner}/${input.repo}/pulls?head=${encodeURIComponent(head)}&state=all&per_page=100`;
247
+ const response = await runner(
248
+ ["api", "--hostname", "github.com", "--include", "-X", "GET", path],
249
+ input.signal === undefined ? {} : { signal: input.signal },
250
+ );
251
+ if (response.status < 200 || response.status >= 300) {
252
+ throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
253
+ cause: {
254
+ endpoint: path,
255
+ status: response.status,
256
+ headers: response.headers,
257
+ body: response.bodyText,
258
+ },
259
+ });
260
+ }
261
+ return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
262
+ }
263
+
264
+ /**
265
+ * Online PR association for a commit SHA (fork-safe; works when the commit is on the PR).
266
+ * Transport/HTTP/JSON failures throw with true cause.
267
+ */
268
+ export async function listPullRequestNumbersByCommit(
269
+ runner: GhApiRunner,
270
+ input: {
271
+ readonly owner: string;
272
+ readonly repo: string;
273
+ readonly commitSha: string;
274
+ readonly signal?: AbortSignal;
275
+ },
276
+ ): Promise<number[]> {
277
+ const path = `/repos/${input.owner}/${input.repo}/commits/${encodeURIComponent(input.commitSha)}/pulls`;
278
+ const response = await runner(
279
+ ["api", "--hostname", "github.com", "--include", "-X", "GET", path],
280
+ input.signal === undefined ? {} : { signal: input.signal },
281
+ );
282
+ if (response.status < 200 || response.status >= 300) {
283
+ throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
284
+ cause: {
285
+ endpoint: path,
286
+ status: response.status,
287
+ headers: response.headers,
288
+ body: response.bodyText,
289
+ },
290
+ });
291
+ }
292
+ return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
293
+ }
294
+
295
+ /**
296
+ * Online association for a structured ticket number (#676 D1):
297
+ * - Number is itself a pull request → that PR.
298
+ * - Number is an issue → PRs linked via timeline cross-reference / closed-by.
299
+ * Transport/HTTP/JSON failures throw with true cause.
300
+ * 404 / empty association → [].
301
+ */
302
+ export async function listPullRequestNumbersByTicket(
303
+ runner: GhApiRunner,
304
+ input: {
305
+ readonly owner: string;
306
+ readonly repo: string;
307
+ readonly ticketNumber: number;
308
+ readonly signal?: AbortSignal;
309
+ },
310
+ ): Promise<number[]> {
311
+ const issuePath = `/repos/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
312
+ const issueResponse = await runner(
313
+ ["api", "--hostname", "github.com", "--include", "-X", "GET", issuePath],
314
+ input.signal === undefined ? {} : { signal: input.signal },
315
+ );
316
+ if (issueResponse.status === 404) return [];
317
+ if (issueResponse.status < 200 || issueResponse.status >= 300) {
318
+ throw new Error(`GitHub ${issuePath} failed with HTTP ${issueResponse.status}`, {
319
+ cause: {
320
+ endpoint: issuePath,
321
+ status: issueResponse.status,
322
+ headers: issueResponse.headers,
323
+ body: issueResponse.bodyText,
324
+ },
325
+ });
326
+ }
327
+ const issueRaw = parseJson(issueResponse.bodyText, issuePath);
328
+ if (!isRecord(issueRaw)) {
329
+ throw new Error(`GitHub ${issuePath} payload is not an object`);
330
+ }
331
+ // Issues endpoint returns PRs too — own-key pull_request means the number is the PR.
332
+ if (Object.hasOwn(issueRaw, "pull_request")) {
333
+ return [parseCollectorPrNumber(issueRaw["number"] ?? input.ticketNumber)];
334
+ }
335
+
336
+ // Linked PRs: GraphQL closed-by + cross-referenced PR sources (existing gh runner seam).
337
+ const query = `query($owner: String!, $repo: String!, $number: Int!) {
338
+ repository(owner: $owner, name: $repo) {
339
+ issue(number: $number) {
340
+ closedByPullRequestsReferences(first: 50) { nodes { number } }
341
+ timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
342
+ nodes {
343
+ __typename
344
+ ... on CrossReferencedEvent {
345
+ source { ... on PullRequest { number } }
346
+ }
347
+ ... on ConnectedEvent {
348
+ subject { ... on PullRequest { number } }
349
+ }
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }`;
355
+ const args = [
356
+ "api",
357
+ "graphql",
358
+ "--hostname",
359
+ "github.com",
360
+ "--include",
361
+ "-f",
362
+ `query=${query}`,
363
+ "-f",
364
+ `owner=${input.owner}`,
365
+ "-f",
366
+ `repo=${input.repo}`,
367
+ "-F",
368
+ `number=${input.ticketNumber}`,
369
+ ];
370
+ const gqlResponse = await runner(
371
+ args,
372
+ input.signal === undefined ? {} : { signal: input.signal },
373
+ );
374
+ if (gqlResponse.status < 200 || gqlResponse.status >= 300) {
375
+ throw new Error(`GitHub GraphQL issue→PR failed with HTTP ${gqlResponse.status}`, {
376
+ cause: {
377
+ endpoint: "graphql",
378
+ status: gqlResponse.status,
379
+ headers: gqlResponse.headers,
380
+ body: gqlResponse.bodyText,
381
+ },
382
+ });
383
+ }
384
+ let payload: unknown;
385
+ try {
386
+ payload = JSON.parse(gqlResponse.bodyText);
387
+ } catch (error) {
388
+ throw new Error("GitHub GraphQL issue→PR returned malformed JSON", { cause: error });
389
+ }
390
+ if (!isRecord(payload)) {
391
+ throw new Error("GitHub GraphQL issue→PR payload is not an object");
392
+ }
393
+ if (payload.errors !== undefined) {
394
+ throw new Error(`GitHub GraphQL issue→PR errors: ${JSON.stringify(payload.errors).slice(0, 600)}`, {
395
+ cause: { body: gqlResponse.bodyText, errors: payload.errors },
396
+ });
397
+ }
398
+ const data = payload.data;
399
+ if (!isRecord(data)) return [];
400
+ const repository = data["repository"];
401
+ if (!isRecord(repository)) return [];
402
+ const issue = repository["issue"];
403
+ if (!isRecord(issue)) return [];
404
+ const numbers: number[] = [];
405
+ const closedBy = issue["closedByPullRequestsReferences"];
406
+ if (isRecord(closedBy) && Array.isArray(closedBy["nodes"])) {
407
+ for (const node of closedBy["nodes"]) {
408
+ if (isRecord(node) && typeof node["number"] === "number") {
409
+ numbers.push(parseCollectorPrNumber(node["number"]));
410
+ }
411
+ }
412
+ }
413
+ const timeline = issue["timelineItems"];
414
+ if (isRecord(timeline) && Array.isArray(timeline["nodes"])) {
415
+ for (const node of timeline["nodes"]) {
416
+ if (!isRecord(node)) continue;
417
+ const source = node["source"];
418
+ if (isRecord(source) && typeof source["number"] === "number") {
419
+ numbers.push(parseCollectorPrNumber(source["number"]));
420
+ }
421
+ const subject = node["subject"];
422
+ if (isRecord(subject) && typeof subject["number"] === "number") {
423
+ numbers.push(parseCollectorPrNumber(subject["number"]));
424
+ }
425
+ }
426
+ }
427
+ return [...new Set(numbers)];
428
+ }
429
+
202
430
  let commentFailureEvidence = 0;
203
431
  function commentFailureCause(error: unknown) {
204
432
  return {
@@ -249,13 +477,19 @@ export function normalizePullRequest(raw: unknown): GitHubPullRequest {
249
477
  throw new Error("GitHub pull request payload missing head.sha");
250
478
  }
251
479
  const number = requireNumber(raw["number"], "number");
252
- const state = requireString(raw["state"], "state").toUpperCase();
480
+ // GitHub REST: merged PRs keep state="closed" and set merged/merged_at. Project the
481
+ // real merge fact so receipt/settlement can distinguish merged from merely closed (#676 D6).
482
+ const mergedFlag = raw["merged"] === true
483
+ || (typeof raw["merged_at"] === "string" && raw["merged_at"].length > 0);
484
+ const rawState = requireString(raw["state"], "state").toUpperCase();
485
+ // After toUpperCase the only OPEN spelling is "OPEN"; keep MERGED vs CLOSED distinction.
486
+ const state = mergedFlag ? "MERGED" : rawState;
253
487
  const htmlUrl = typeof raw["html_url"] === "string"
254
488
  ? raw["html_url"]
255
489
  : `https://github.com/unknown/unknown/pull/${number}`;
256
490
  return {
257
491
  number,
258
- state: state === "OPEN" || state === "open" ? "OPEN" : state,
492
+ state,
259
493
  headOid: head["sha"],
260
494
  ...(typeof raw["updated_at"] === "string" ? { updatedAt: raw["updated_at"] } : {}),
261
495
  url: htmlUrl,