@akagilnc/pi-workflow-roles 0.1.3749 → 0.1.3771

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 (49) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +3 -2
  3. package/README.zh-CN.md +3 -2
  4. package/dist/acp-host/production-host.js +1389 -751
  5. package/dist/collector-config.js +0 -1
  6. package/dist/collector-github.js +199 -2
  7. package/dist/collector-identity.js +128 -41
  8. package/dist/collector-ledger.js +38 -9
  9. package/dist/collector-receipt.js +19 -7
  10. package/dist/collector-role.js +330 -369
  11. package/dist/collector-target.js +169 -0
  12. package/dist/collector-tool-schemas.js +51 -14
  13. package/dist/package-contracts/collector-output.js +32 -0
  14. package/dist/package-contracts/terminating-infrastructure.js +13 -12
  15. package/dist/pi/role-turn-host.js +1 -2
  16. package/dist/public-cli/github-remote.js +45 -0
  17. package/dist/public-cli/invocation.js +53 -54
  18. package/dist/public-cli/main.js +615 -148
  19. package/dist/public-cli/option-definitions.js +6 -4
  20. package/dist/public-cli/run-lifecycle.js +3 -3
  21. package/dist/public-cli/settlement.js +83 -6
  22. package/dist/role-runtime.js +137 -7
  23. package/dist/submission-correctable-error.js +24 -0
  24. package/extensions/role-runtime.ts +0 -1
  25. package/package.json +1 -1
  26. package/souls/coder.md +11 -6
  27. package/souls/fixer.md +10 -9
  28. package/src/acp-host/role-envelope.ts +8 -22
  29. package/src/collector-config.ts +0 -1
  30. package/src/collector-github.ts +236 -2
  31. package/src/collector-identity.ts +148 -40
  32. package/src/collector-ledger.ts +48 -10
  33. package/src/collector-receipt.ts +33 -14
  34. package/src/collector-role.ts +376 -450
  35. package/src/collector-target.ts +207 -0
  36. package/src/collector-tool-schemas.ts +62 -15
  37. package/src/host-contracts.ts +2 -1
  38. package/src/package-contracts/collector-output.ts +72 -0
  39. package/src/package-contracts/terminating-infrastructure.ts +24 -13
  40. package/src/pi/role-turn-host.ts +1 -2
  41. package/src/public-cli/cli.ts +10 -1
  42. package/src/public-cli/collector-run.ts +3 -2
  43. package/src/public-cli/github-remote.ts +45 -0
  44. package/src/public-cli/invocation.ts +60 -59
  45. package/src/public-cli/option-definitions.ts +6 -4
  46. package/src/public-cli/run-lifecycle.ts +2 -2
  47. package/src/public-cli/settlement.ts +82 -6
  48. package/src/role-runtime.ts +166 -13
  49. package/src/submission-correctable-error.ts +38 -0
@@ -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,
@@ -2,8 +2,11 @@ import type {
2
2
  GitHubMachineIdentity,
3
3
  } from "./collector-github.ts";
4
4
  import type { CollectorEvidenceRecord, HeadRelation } from "./collector-evidence.ts";
5
+ import type { CollectorSubmissionProjection } from "./package-contracts/collector-output.ts";
5
6
  import { CorrectableSubmissionError } from "./submission-correctable-error.ts";
6
7
 
8
+ export type { CollectorSubmissionProjection };
9
+
7
10
  export type CollectorMaterialRef = {
8
11
  kind: "review" | "issue_comment" | "review_comment" | "reaction";
9
12
  id: number;
@@ -21,7 +24,10 @@ export type CollectorMaterialRef = {
21
24
  export type CollectorFinding = {
22
25
  identity: GitHubMachineIdentity | null;
23
26
  source: CollectorMaterialRef;
27
+ /** Short classification label; not the finding summary. */
24
28
  category?: string;
29
+ /** Finding summary for the caller; not a body transcription. */
30
+ summary?: string;
25
31
  pointer: {
26
32
  repository: string;
27
33
  prNumber: number;
@@ -30,6 +36,8 @@ export type CollectorFinding = {
30
36
  authorLogin?: string;
31
37
  kind: CollectorMaterialRef["kind"];
32
38
  authoritativeTime?: string | null;
39
+ /** Corresponding commit when the evidence carries one. */
40
+ commitOid?: string | null;
33
41
  };
34
42
  };
35
43
 
@@ -121,9 +129,9 @@ export class CollectorUnknownEvidenceError extends CorrectableSubmissionError {
121
129
  }
122
130
 
123
131
  /**
124
- * #641 chain①: any malformed findings submission is a model misuse the model
125
- * can correct and resubmit — a branded correctable rejection on every supported
126
- * engine, never a round infrastructure failure.
132
+ * Evidence-binding failure for a finding pointer that resolves to the wrong kind
133
+ * of stored record, lacks a GitHub locator, or has no identity group — not a
134
+ * free-shape gate on the submission envelope.
127
135
  */
128
136
  export class CollectorFindingsValidationError extends CorrectableSubmissionError {
129
137
  constructor(message: string) {
@@ -133,11 +141,45 @@ export class CollectorFindingsValidationError extends CorrectableSubmissionError
133
141
  }
134
142
 
135
143
  /**
136
- * #641 chain①: turn model-submitted finding pointer refs into receipt findings.
137
- * Each pointer must resolve to a stored text-bearing evidence record; the
138
- * machine locator is enriched from the same record so receipt and volume agree
139
- * (指针可解析、开卷相符) by construction. Throws branded correctable
140
- * (non-fatal, model-visible) errors for unresolvable or mis-typed findings.
144
+ * #676 D6: non-OPEN targets keep collected materials and must not fire new review
145
+ * requests. Bounce the request as correctable so the seat can still seal output.
146
+ */
147
+ export class CollectorNonOpenRequestError extends CorrectableSubmissionError {
148
+ constructor(prState: string) {
149
+ super(`通进司请求要求 OPEN 状态的 PR 快照;当前为 ${prState},不再触发新评审,请直接交回已有材料`);
150
+ this.name = "CollectorNonOpenRequestError";
151
+ }
152
+ }
153
+
154
+ function candidateRecord(candidate: unknown): Record<string, unknown> | undefined {
155
+ if (candidate === undefined || candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
156
+ return undefined;
157
+ }
158
+ return candidate as Record<string, unknown>;
159
+ }
160
+
161
+ /** Canonical top-level keys the collector output projection understands. */
162
+ const COLLECTOR_OUTPUT_CANONICAL_KEYS = new Set([
163
+ "findings",
164
+ "unfinishedReasons",
165
+ "infrastructureFailure",
166
+ ]);
167
+
168
+ /** Non-canonical own keys mean content was present but not under a projectable key. */
169
+ function hasNonCanonicalOwnKeys(record: Record<string, unknown>): boolean {
170
+ for (const key of Object.keys(record)) {
171
+ if (!COLLECTOR_OUTPUT_CANONICAL_KEYS.has(key)) return true;
172
+ }
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * #641 chain① / #676: turn model-submitted finding pointer refs into receipt findings.
178
+ * Binds resolvable evidence references only — no pure shape rejection of the
179
+ * candidate envelope. Unknown refs, wrong kinds, missing GitHub locator, and
180
+ * missing identity group stay as binding failures. Unreadable findings content
181
+ * is not washed into "zero findings": projection facts record the gap so
182
+ * downstream can open the session 正本 (第 0 条 / #676 C).
141
183
  */
142
184
  export function enrichCollectorFindings(input: {
143
185
  candidate: unknown;
@@ -146,63 +188,129 @@ export function enrichCollectorFindings(input: {
146
188
  targetHead: string;
147
189
  repository: string;
148
190
  prNumber: number;
149
- }): void {
150
- const candidate = input.candidate;
151
- if (candidate === undefined || candidate === null) return;
152
- if (typeof candidate !== "object" || Array.isArray(candidate)) {
153
- throw new CollectorFindingsValidationError("通进司交件参数必须为对象");
191
+ }): {
192
+ findingsSource: CollectorSubmissionProjection["findingsSource"];
193
+ findingsProjectedCount: number;
194
+ findingsUnprojected: boolean;
195
+ } {
196
+ // Candidate present but not a record — content existed, none projected (#676 C).
197
+ if (input.candidate !== undefined && input.candidate !== null && candidateRecord(input.candidate) === undefined) {
198
+ return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
199
+ }
200
+ const record = candidateRecord(input.candidate);
201
+ if (record === undefined) {
202
+ return { findingsSource: "absent", findingsProjectedCount: 0, findingsUnprojected: false };
154
203
  }
155
- const rawFindings = (candidate as { findings?: unknown }).findings;
156
- if (rawFindings === undefined) return;
204
+ if (!Object.hasOwn(record, "findings")) {
205
+ // Non-canonical top-level content is not "absent" — record the projection gap (#676 C).
206
+ if (hasNonCanonicalOwnKeys(record)) {
207
+ return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
208
+ }
209
+ return { findingsSource: "absent", findingsProjectedCount: 0, findingsUnprojected: false };
210
+ }
211
+ const rawFindings = record["findings"];
157
212
  if (!Array.isArray(rawFindings)) {
158
- throw new CollectorFindingsValidationError("通进司 findings 必须为数组");
213
+ // Key present but not an array — content exists, none projected. No shape bounce.
214
+ return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
159
215
  }
160
- const byEvidenceId = new Map(input.records.map((record) => [record.evidenceId, record]));
216
+
217
+ const byEvidenceId = new Map(input.records.map((evidence) => [evidence.evidenceId, evidence]));
218
+ let projected = 0;
219
+ let unprojected = false;
161
220
  for (const raw of rawFindings) {
162
221
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
163
- throw new CollectorFindingsValidationError("通进司 finding 必须为对象");
222
+ unprojected = true;
223
+ continue;
164
224
  }
165
- const evidenceId = (raw as { evidenceId?: unknown }).evidenceId;
225
+ const item = raw as Record<string, unknown>;
226
+ const evidenceId = item.evidenceId;
166
227
  if (typeof evidenceId !== "string" || evidenceId.length === 0) {
167
- throw new CollectorFindingsValidationError("通进司 finding 缺少可解析的 evidenceId 指针");
228
+ // Present item without a bindable pointer — keep gap fact, do not fabricate.
229
+ unprojected = true;
230
+ continue;
168
231
  }
169
- const record = byEvidenceId.get(evidenceId);
170
- if (record === undefined) {
232
+ const evidence = byEvidenceId.get(evidenceId);
233
+ if (evidence === undefined) {
171
234
  throw new CollectorUnknownEvidenceError(evidenceId);
172
235
  }
173
- if (record.kind !== "review" && record.kind !== "issue_comment" && record.kind !== "review_comment") {
174
- throw new CollectorFindingsValidationError(`通进司 finding 指针指向不可承 finding 的证据种类 ${record.kind}`);
236
+ if (evidence.kind !== "review" && evidence.kind !== "issue_comment" && evidence.kind !== "review_comment") {
237
+ throw new CollectorFindingsValidationError(`通进司 finding 指针指向不可承 finding 的证据种类 ${evidence.kind}`);
175
238
  }
176
- if (record.githubId === undefined) {
239
+ if (evidence.githubId === undefined) {
177
240
  throw new CollectorFindingsValidationError(`通进司 finding 指针证据 ${evidenceId} 缺少 GitHub id`);
178
241
  }
179
- const category = (raw as { category?: unknown }).category;
180
- if (category !== undefined && (typeof category !== "string" || category.trim().length === 0)) {
181
- throw new CollectorFindingsValidationError("通进司 finding category 必须为非空字符串");
182
- }
183
- const identity = record.machineIdentity ?? null;
242
+ const identity = evidence.machineIdentity ?? null;
184
243
  const group = input.groups.find((candidateGroup) => identityKey(candidateGroup.identity) === identityKey(identity));
185
244
  if (group === undefined) {
186
245
  throw new CollectorFindingsValidationError(`通进司 finding 指针证据 ${evidenceId} 无归属身份组`);
187
246
  }
247
+ // Bound finding keeps pointer; non-string category/summary are unprojected field gaps
248
+ // (pointer success ≠ full content projection) — #676 C / 3939511832.
249
+ const category = item.category;
250
+ const summary = item.summary;
251
+ if (Object.hasOwn(item, "category") && typeof category !== "string") unprojected = true;
252
+ if (Object.hasOwn(item, "summary") && typeof summary !== "string") unprojected = true;
188
253
  group.findings.push({
189
254
  identity,
190
255
  source: {
191
- kind: record.kind,
192
- id: record.githubId,
193
- evidenceId: record.evidenceId,
194
- headRelation: headRelationFor(record, input.targetHead),
256
+ kind: evidence.kind,
257
+ id: evidence.githubId,
258
+ evidenceId: evidence.evidenceId,
259
+ headRelation: headRelationFor(evidence, input.targetHead),
195
260
  },
196
- ...(category === undefined ? {} : { category: category.trim() }),
261
+ ...(typeof category === "string" ? { category } : {}),
262
+ ...(typeof summary === "string" ? { summary } : {}),
197
263
  pointer: {
198
264
  repository: input.repository,
199
265
  prNumber: input.prNumber,
200
- commentId: record.githubId,
201
- ...(record.htmlUrl === undefined ? {} : { htmlUrl: record.htmlUrl }),
202
- ...(record.authorLogin === undefined ? {} : { authorLogin: record.authorLogin }),
203
- kind: record.kind,
204
- authoritativeTime: record.authoritativeTime ?? null,
266
+ commentId: evidence.githubId,
267
+ ...(evidence.htmlUrl === undefined ? {} : { htmlUrl: evidence.htmlUrl }),
268
+ ...(evidence.authorLogin === undefined ? {} : { authorLogin: evidence.authorLogin }),
269
+ kind: evidence.kind,
270
+ authoritativeTime: evidence.authoritativeTime ?? null,
271
+ ...(evidence.commitOid === undefined ? {} : { commitOid: evidence.commitOid }),
205
272
  },
206
273
  });
274
+ projected += 1;
275
+ }
276
+ return {
277
+ findingsSource: "array",
278
+ findingsProjectedCount: projected,
279
+ findingsUnprojected: unprojected,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * #676 D6/C: optional unfinished reasons from the model submission.
285
+ * Project readable strings; record when original content could not fully project
286
+ * (do not wash unreadable unfinishedReasons into "none").
287
+ */
288
+ export function extractCollectorUnfinishedReasons(candidate: unknown): {
289
+ reasons: string[] | undefined;
290
+ source: CollectorSubmissionProjection["unfinishedReasonsSource"];
291
+ unprojected: boolean;
292
+ } {
293
+ if (candidate !== undefined && candidate !== null && candidateRecord(candidate) === undefined) {
294
+ return { reasons: undefined, source: "unreadable", unprojected: true };
295
+ }
296
+ const record = candidateRecord(candidate);
297
+ if (record === undefined) {
298
+ return { reasons: undefined, source: "absent", unprojected: false };
207
299
  }
300
+ if (!Object.hasOwn(record, "unfinishedReasons")) {
301
+ // unfinishedReasons absent is fine when only findings (or nothing) present.
302
+ // Non-canonical keys alone are recorded on the findings projection path.
303
+ return { reasons: undefined, source: "absent", unprojected: false };
304
+ }
305
+ const raw = record["unfinishedReasons"];
306
+ if (!Array.isArray(raw)) {
307
+ return { reasons: undefined, source: "unreadable", unprojected: true };
308
+ }
309
+ const reasons = raw.filter((item): item is string => typeof item === "string");
310
+ const unprojected = reasons.length !== raw.length;
311
+ return {
312
+ reasons: reasons.length > 0 ? reasons : undefined,
313
+ source: "array",
314
+ unprojected,
315
+ };
208
316
  }
@@ -25,6 +25,7 @@ import {
25
25
  type GitHubPageDiagnostics,
26
26
  type GitHubPullRequest,
27
27
  } from "./collector-github.ts";
28
+ import { CollectorNonOpenRequestError } from "./collector-identity.ts";
28
29
  import {
29
30
  collectorObserveArgsSchema,
30
31
  collectorOutputArgsSchema,
@@ -38,9 +39,12 @@ export const COLLECTOR_OBSERVE_TOOL = "ak_collector_observe";
38
39
  export const COLLECTOR_READ_TOOL = "ak_collector_read";
39
40
  export const COLLECTOR_REQUEST_TOOL = "ak_collector_request";
40
41
  export const COLLECTOR_WAIT_TOOL = "ak_collector_wait";
42
+ /** #676 A: role-decided target bind — business tool, ledger-booked. */
43
+ export const COLLECTOR_BIND_TARGET_TOOL = "ak_collector_bind_target";
41
44
  export { COLLECTOR_OUTPUT_TOOL };
42
45
 
43
46
  export const COLLECTOR_OPERATIONAL_TOOLS = [
47
+ COLLECTOR_BIND_TARGET_TOOL,
44
48
  COLLECTOR_OBSERVE_TOOL,
45
49
  COLLECTOR_READ_TOOL,
46
50
  COLLECTOR_REQUEST_TOOL,
@@ -157,7 +161,8 @@ export type CollectorTransportFailure = {
157
161
 
158
162
  export type CollectorConfigState = {
159
163
  repository: CollectorRepository;
160
- prNumber: number;
164
+ /** Bound PR target; undefined until explicit flag, admission bind, or role bind-target tool. */
165
+ prNumber: number | undefined;
161
166
  manifest: CollectorManifest;
162
167
  };
163
168
 
@@ -195,6 +200,8 @@ export type CollectorLedger = {
195
200
  assertNotFatal(): void;
196
201
  recordActivation(clock: CollectorClock): void;
197
202
  recordOutputCandidate(): void;
203
+ /** #676 A: role-decided unique PR bind (business fact on the ledger). */
204
+ bindTarget(prNumber: number): void;
198
205
  beginOperational(toolName: string, toolCallId: string): void;
199
206
  completeOperational(toolCallId: string): void;
200
207
  noteCutoffObserved(): void;
@@ -326,6 +333,15 @@ export function createCollectorLedger(
326
333
  const prIdentity = (pr: GitHubPullRequest): string =>
327
334
  `${pr.state}|${pr.headOid}|${pr.updatedAt ?? ""}`;
328
335
 
336
+ const requireBoundPr = (): number => {
337
+ if (config.prNumber === undefined) {
338
+ throw new Error(
339
+ "Collector PR target is unbound; call ak_collector_bind_target with the role-decided issue/PR or pass --pr",
340
+ );
341
+ }
342
+ return config.prNumber;
343
+ };
344
+
329
345
  const fetchObserveSurfaces = async (
330
346
  transport: CollectorGitHubTransport,
331
347
  observedAt: string,
@@ -333,7 +349,7 @@ export function createCollectorLedger(
333
349
  ) => {
334
350
  const owner = config.repository.owner;
335
351
  const repo = config.repository.repo;
336
- const prNumber = config.prNumber;
352
+ const prNumber = requireBoundPr();
337
353
  const signalOpt = signal === undefined ? {} : { signal };
338
354
  const user = await transport.getAuthenticatedUser(signalOpt);
339
355
  const prInitial = await transport.getPullRequest({
@@ -653,6 +669,26 @@ export function createCollectorLedger(
653
669
  outputCandidate = true;
654
670
  },
655
671
 
672
+ bindTarget(prNumber) {
673
+ assertNotFatal();
674
+ if (outputCandidate || pendingOutputCallId !== undefined) {
675
+ throw new Error("通进司已产出输出候选,本局不再受理目标绑定");
676
+ }
677
+ if (!Number.isSafeInteger(prNumber) || prNumber < 1) {
678
+ throw new Error("Collector bind target requires a positive safe-integer PR number");
679
+ }
680
+ if (config.prNumber !== undefined && config.prNumber !== prNumber) {
681
+ throw new Error(
682
+ `Collector target already bound to PR ${config.prNumber}; cannot rebind to ${prNumber}`,
683
+ );
684
+ }
685
+ config.prNumber = prNumber;
686
+ appendJournal("ak-collector-target-bound", {
687
+ prNumber,
688
+ repository: config.repository.canonical,
689
+ });
690
+ },
691
+
656
692
  beginOperational(toolName, toolCallId) {
657
693
  assertNotFatal();
658
694
  if (
@@ -833,7 +869,7 @@ export function createCollectorLedger(
833
869
  completedMono,
834
870
  host: "github.com",
835
871
  repository: config.repository.canonical,
836
- prNumber: config.prNumber,
872
+ prNumber: requireBoundPr(),
837
873
  prState: pr.state,
838
874
  headOid: pr.headOid,
839
875
  complete: true,
@@ -875,10 +911,6 @@ export function createCollectorLedger(
875
911
  if (activationTime === undefined || deadlineTime === undefined) {
876
912
  throw latchFatal("通进司请求需要激活");
877
913
  }
878
- if (pastCutoff(clock)) {
879
- finalObservationRequired = true;
880
- throw latchFatal("通进司请求不在资格截止前");
881
- }
882
914
  if (ledger.unresolvedTransportFailure) {
883
915
  throw latchFatal("通进司请求时存在未恢复的传输失败");
884
916
  }
@@ -896,7 +928,12 @@ export function createCollectorLedger(
896
928
  throw new Error("通进司请求要求最新完整快照");
897
929
  }
898
930
  if (snapshot.prState !== "OPEN") {
899
- throw latchFatal("通进司请求要求 OPEN 状态的 PR 快照");
931
+ // #676 D6: non-OPEN keeps materials; bounce before cutoff fatal so post-deadline materials still seal.
932
+ throw new CollectorNonOpenRequestError(snapshot.prState);
933
+ }
934
+ if (pastCutoff(clock)) {
935
+ finalObservationRequired = true;
936
+ throw latchFatal("通进司请求不在资格截止前");
900
937
  }
901
938
 
902
939
  const { body, marker } = buildCollectorRequestBody({
@@ -918,9 +955,10 @@ export function createCollectorLedger(
918
955
  );
919
956
  }
920
957
 
958
+ const boundPr = requireBoundPr();
921
959
  const attemptKey = [
922
960
  config.repository.canonical,
923
- String(config.prNumber),
961
+ String(boundPr),
924
962
  snapshot.headOid,
925
963
  request.id,
926
964
  ].join("|");
@@ -951,7 +989,7 @@ export function createCollectorLedger(
951
989
  const result = await transport.createIssueComment({
952
990
  owner: config.repository.owner,
953
991
  repo: config.repository.repo,
954
- prNumber: config.prNumber,
992
+ prNumber: boundPr,
955
993
  body,
956
994
  ...(signal === undefined ? {} : { signal }),
957
995
  });