@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
@@ -3,7 +3,6 @@ import { readFile } from "node:fs/promises";
3
3
  export const COLLECTOR_HOST = "github.com";
4
4
  export const COLLECTOR_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
5
5
  export const COLLECTOR_REPO_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$/;
6
- export const COLLECTOR_FIXED_KICKOFF = "采集目标已受理,本局开始。";
7
6
  function fail(message, cause) {
8
7
  throw new Error(message, cause === undefined ? undefined : { cause });
9
8
  }
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
+ import { parseCollectorPrNumber } from "./collector-config.js";
3
4
  function isRecord(value) {
4
5
  return typeof value === "object" && value !== null && !Array.isArray(value);
5
6
  }
@@ -36,6 +37,196 @@ function parseJson(text, label) {
36
37
  throw new Error(`GitHub ${label} returned malformed JSON`, { cause: error });
37
38
  }
38
39
  }
40
+ /**
41
+ * Shared PR-list payload parse for admission target lookup (#676 D1/D7).
42
+ * Every entry must carry a positive safe-integer number — malformed items fail
43
+ * the response (do not skip then claim uniqueness).
44
+ */
45
+ export function parsePullRequestNumberList(raw, label) {
46
+ if (!Array.isArray(raw)) {
47
+ throw new Error(`GitHub ${label} payload is not a list`);
48
+ }
49
+ const numbers = [];
50
+ for (const item of raw) {
51
+ if (!isRecord(item)) {
52
+ throw new Error(`GitHub ${label} payload contains a non-object pull request entry`);
53
+ }
54
+ try {
55
+ numbers.push(parseCollectorPrNumber(item["number"]));
56
+ }
57
+ catch (error) {
58
+ throw new Error(`GitHub ${label} payload contains an invalid pull request number`, {
59
+ cause: error,
60
+ });
61
+ }
62
+ }
63
+ return numbers;
64
+ }
65
+ /**
66
+ * Online PR association by head owner:ref (state=all). Caller supplies the real
67
+ * head owner from branch context — never assume base repository owner is the fork head.
68
+ * Transport/HTTP/JSON failures throw with true cause (not target-ambiguity wash).
69
+ */
70
+ export async function listPullRequestNumbersByHead(runner, input) {
71
+ const head = `${input.headOwner}:${input.headRef}`;
72
+ const path = `/repos/${input.owner}/${input.repo}/pulls?head=${encodeURIComponent(head)}&state=all&per_page=100`;
73
+ const response = await runner(["api", "--hostname", "github.com", "--include", "-X", "GET", path], input.signal === undefined ? {} : { signal: input.signal });
74
+ if (response.status < 200 || response.status >= 300) {
75
+ throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
76
+ cause: {
77
+ endpoint: path,
78
+ status: response.status,
79
+ headers: response.headers,
80
+ body: response.bodyText,
81
+ },
82
+ });
83
+ }
84
+ return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
85
+ }
86
+ /**
87
+ * Online PR association for a commit SHA (fork-safe; works when the commit is on the PR).
88
+ * Transport/HTTP/JSON failures throw with true cause.
89
+ */
90
+ export async function listPullRequestNumbersByCommit(runner, input) {
91
+ const path = `/repos/${input.owner}/${input.repo}/commits/${encodeURIComponent(input.commitSha)}/pulls`;
92
+ const response = await runner(["api", "--hostname", "github.com", "--include", "-X", "GET", path], input.signal === undefined ? {} : { signal: input.signal });
93
+ if (response.status < 200 || response.status >= 300) {
94
+ throw new Error(`GitHub ${path} failed with HTTP ${response.status}`, {
95
+ cause: {
96
+ endpoint: path,
97
+ status: response.status,
98
+ headers: response.headers,
99
+ body: response.bodyText,
100
+ },
101
+ });
102
+ }
103
+ return parsePullRequestNumberList(parseJson(response.bodyText, path), path);
104
+ }
105
+ /**
106
+ * Online association for a structured ticket number (#676 D1):
107
+ * - Number is itself a pull request → that PR.
108
+ * - Number is an issue → PRs linked via timeline cross-reference / closed-by.
109
+ * Transport/HTTP/JSON failures throw with true cause.
110
+ * 404 / empty association → [].
111
+ */
112
+ export async function listPullRequestNumbersByTicket(runner, input) {
113
+ const issuePath = `/repos/${input.owner}/${input.repo}/issues/${input.ticketNumber}`;
114
+ const issueResponse = await runner(["api", "--hostname", "github.com", "--include", "-X", "GET", issuePath], input.signal === undefined ? {} : { signal: input.signal });
115
+ if (issueResponse.status === 404)
116
+ return [];
117
+ if (issueResponse.status < 200 || issueResponse.status >= 300) {
118
+ throw new Error(`GitHub ${issuePath} failed with HTTP ${issueResponse.status}`, {
119
+ cause: {
120
+ endpoint: issuePath,
121
+ status: issueResponse.status,
122
+ headers: issueResponse.headers,
123
+ body: issueResponse.bodyText,
124
+ },
125
+ });
126
+ }
127
+ const issueRaw = parseJson(issueResponse.bodyText, issuePath);
128
+ if (!isRecord(issueRaw)) {
129
+ throw new Error(`GitHub ${issuePath} payload is not an object`);
130
+ }
131
+ // Issues endpoint returns PRs too — own-key pull_request means the number is the PR.
132
+ if (Object.hasOwn(issueRaw, "pull_request")) {
133
+ return [parseCollectorPrNumber(issueRaw["number"] ?? input.ticketNumber)];
134
+ }
135
+ // Linked PRs: GraphQL closed-by + cross-referenced PR sources (existing gh runner seam).
136
+ const query = `query($owner: String!, $repo: String!, $number: Int!) {
137
+ repository(owner: $owner, name: $repo) {
138
+ issue(number: $number) {
139
+ closedByPullRequestsReferences(first: 50) { nodes { number } }
140
+ timelineItems(first: 100, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
141
+ nodes {
142
+ __typename
143
+ ... on CrossReferencedEvent {
144
+ source { ... on PullRequest { number } }
145
+ }
146
+ ... on ConnectedEvent {
147
+ subject { ... on PullRequest { number } }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ }`;
154
+ const args = [
155
+ "api",
156
+ "graphql",
157
+ "--hostname",
158
+ "github.com",
159
+ "--include",
160
+ "-f",
161
+ `query=${query}`,
162
+ "-f",
163
+ `owner=${input.owner}`,
164
+ "-f",
165
+ `repo=${input.repo}`,
166
+ "-F",
167
+ `number=${input.ticketNumber}`,
168
+ ];
169
+ const gqlResponse = await runner(args, input.signal === undefined ? {} : { signal: input.signal });
170
+ if (gqlResponse.status < 200 || gqlResponse.status >= 300) {
171
+ throw new Error(`GitHub GraphQL issue→PR failed with HTTP ${gqlResponse.status}`, {
172
+ cause: {
173
+ endpoint: "graphql",
174
+ status: gqlResponse.status,
175
+ headers: gqlResponse.headers,
176
+ body: gqlResponse.bodyText,
177
+ },
178
+ });
179
+ }
180
+ let payload;
181
+ try {
182
+ payload = JSON.parse(gqlResponse.bodyText);
183
+ }
184
+ catch (error) {
185
+ throw new Error("GitHub GraphQL issue→PR returned malformed JSON", { cause: error });
186
+ }
187
+ if (!isRecord(payload)) {
188
+ throw new Error("GitHub GraphQL issue→PR payload is not an object");
189
+ }
190
+ if (payload.errors !== undefined) {
191
+ throw new Error(`GitHub GraphQL issue→PR errors: ${JSON.stringify(payload.errors).slice(0, 600)}`, {
192
+ cause: { body: gqlResponse.bodyText, errors: payload.errors },
193
+ });
194
+ }
195
+ const data = payload.data;
196
+ if (!isRecord(data))
197
+ return [];
198
+ const repository = data["repository"];
199
+ if (!isRecord(repository))
200
+ return [];
201
+ const issue = repository["issue"];
202
+ if (!isRecord(issue))
203
+ return [];
204
+ const numbers = [];
205
+ const closedBy = issue["closedByPullRequestsReferences"];
206
+ if (isRecord(closedBy) && Array.isArray(closedBy["nodes"])) {
207
+ for (const node of closedBy["nodes"]) {
208
+ if (isRecord(node) && typeof node["number"] === "number") {
209
+ numbers.push(parseCollectorPrNumber(node["number"]));
210
+ }
211
+ }
212
+ }
213
+ const timeline = issue["timelineItems"];
214
+ if (isRecord(timeline) && Array.isArray(timeline["nodes"])) {
215
+ for (const node of timeline["nodes"]) {
216
+ if (!isRecord(node))
217
+ continue;
218
+ const source = node["source"];
219
+ if (isRecord(source) && typeof source["number"] === "number") {
220
+ numbers.push(parseCollectorPrNumber(source["number"]));
221
+ }
222
+ const subject = node["subject"];
223
+ if (isRecord(subject) && typeof subject["number"] === "number") {
224
+ numbers.push(parseCollectorPrNumber(subject["number"]));
225
+ }
226
+ }
227
+ }
228
+ return [...new Set(numbers)];
229
+ }
39
230
  let commentFailureEvidence = 0;
40
231
  function commentFailureCause(error) {
41
232
  return {
@@ -84,13 +275,19 @@ export function normalizePullRequest(raw) {
84
275
  throw new Error("GitHub pull request payload missing head.sha");
85
276
  }
86
277
  const number = requireNumber(raw["number"], "number");
87
- const state = requireString(raw["state"], "state").toUpperCase();
278
+ // GitHub REST: merged PRs keep state="closed" and set merged/merged_at. Project the
279
+ // real merge fact so receipt/settlement can distinguish merged from merely closed (#676 D6).
280
+ const mergedFlag = raw["merged"] === true
281
+ || (typeof raw["merged_at"] === "string" && raw["merged_at"].length > 0);
282
+ const rawState = requireString(raw["state"], "state").toUpperCase();
283
+ // After toUpperCase the only OPEN spelling is "OPEN"; keep MERGED vs CLOSED distinction.
284
+ const state = mergedFlag ? "MERGED" : rawState;
88
285
  const htmlUrl = typeof raw["html_url"] === "string"
89
286
  ? raw["html_url"]
90
287
  : `https://github.com/unknown/unknown/pull/${number}`;
91
288
  return {
92
289
  number,
93
- state: state === "OPEN" || state === "open" ? "OPEN" : state,
290
+ state,
94
291
  headOid: head["sha"],
95
292
  ...(typeof raw["updated_at"] === "string" ? { updatedAt: raw["updated_at"] } : {}),
96
293
  url: htmlUrl,
@@ -70,9 +70,9 @@ export class CollectorUnknownEvidenceError extends CorrectableSubmissionError {
70
70
  }
71
71
  }
72
72
  /**
73
- * #641 chain①: any malformed findings submission is a model misuse the model
74
- * can correct and resubmit — a branded correctable rejection on every supported
75
- * engine, never a round infrastructure failure.
73
+ * Evidence-binding failure for a finding pointer that resolves to the wrong kind
74
+ * of stored record, lacks a GitHub locator, or has no identity group — not a
75
+ * free-shape gate on the submission envelope.
76
76
  */
77
77
  export class CollectorFindingsValidationError extends CorrectableSubmissionError {
78
78
  constructor(message) {
@@ -81,71 +81,158 @@ export class CollectorFindingsValidationError extends CorrectableSubmissionError
81
81
  }
82
82
  }
83
83
  /**
84
- * #641 chain①: turn model-submitted finding pointer refs into receipt findings.
85
- * Each pointer must resolve to a stored text-bearing evidence record; the
86
- * machine locator is enriched from the same record so receipt and volume agree
87
- * (指针可解析、开卷相符) by construction. Throws branded correctable
88
- * (non-fatal, model-visible) errors for unresolvable or mis-typed findings.
84
+ * #676 D6: non-OPEN targets keep collected materials and must not fire new review
85
+ * requests. Bounce the request as correctable so the seat can still seal output.
86
+ */
87
+ export class CollectorNonOpenRequestError extends CorrectableSubmissionError {
88
+ constructor(prState) {
89
+ super(`通进司请求要求 OPEN 状态的 PR 快照;当前为 ${prState},不再触发新评审,请直接交回已有材料`);
90
+ this.name = "CollectorNonOpenRequestError";
91
+ }
92
+ }
93
+ function candidateRecord(candidate) {
94
+ if (candidate === undefined || candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
95
+ return undefined;
96
+ }
97
+ return candidate;
98
+ }
99
+ /** Canonical top-level keys the collector output projection understands. */
100
+ const COLLECTOR_OUTPUT_CANONICAL_KEYS = new Set([
101
+ "findings",
102
+ "unfinishedReasons",
103
+ "infrastructureFailure",
104
+ ]);
105
+ /** Non-canonical own keys mean content was present but not under a projectable key. */
106
+ function hasNonCanonicalOwnKeys(record) {
107
+ for (const key of Object.keys(record)) {
108
+ if (!COLLECTOR_OUTPUT_CANONICAL_KEYS.has(key))
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+ /**
114
+ * #641 chain① / #676: turn model-submitted finding pointer refs into receipt findings.
115
+ * Binds resolvable evidence references only — no pure shape rejection of the
116
+ * candidate envelope. Unknown refs, wrong kinds, missing GitHub locator, and
117
+ * missing identity group stay as binding failures. Unreadable findings content
118
+ * is not washed into "zero findings": projection facts record the gap so
119
+ * downstream can open the session 正本 (第 0 条 / #676 C).
89
120
  */
90
121
  export function enrichCollectorFindings(input) {
91
- const candidate = input.candidate;
92
- if (candidate === undefined || candidate === null)
93
- return;
94
- if (typeof candidate !== "object" || Array.isArray(candidate)) {
95
- throw new CollectorFindingsValidationError("通进司交件参数必须为对象");
122
+ // Candidate present but not a record — content existed, none projected (#676 C).
123
+ if (input.candidate !== undefined && input.candidate !== null && candidateRecord(input.candidate) === undefined) {
124
+ return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
125
+ }
126
+ const record = candidateRecord(input.candidate);
127
+ if (record === undefined) {
128
+ return { findingsSource: "absent", findingsProjectedCount: 0, findingsUnprojected: false };
129
+ }
130
+ if (!Object.hasOwn(record, "findings")) {
131
+ // Non-canonical top-level content is not "absent" — record the projection gap (#676 C).
132
+ if (hasNonCanonicalOwnKeys(record)) {
133
+ return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
134
+ }
135
+ return { findingsSource: "absent", findingsProjectedCount: 0, findingsUnprojected: false };
96
136
  }
97
- const rawFindings = candidate.findings;
98
- if (rawFindings === undefined)
99
- return;
137
+ const rawFindings = record["findings"];
100
138
  if (!Array.isArray(rawFindings)) {
101
- throw new CollectorFindingsValidationError("通进司 findings 必须为数组");
139
+ // Key present but not an array — content exists, none projected. No shape bounce.
140
+ return { findingsSource: "unreadable", findingsProjectedCount: 0, findingsUnprojected: true };
102
141
  }
103
- const byEvidenceId = new Map(input.records.map((record) => [record.evidenceId, record]));
142
+ const byEvidenceId = new Map(input.records.map((evidence) => [evidence.evidenceId, evidence]));
143
+ let projected = 0;
144
+ let unprojected = false;
104
145
  for (const raw of rawFindings) {
105
146
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
106
- throw new CollectorFindingsValidationError("通进司 finding 必须为对象");
147
+ unprojected = true;
148
+ continue;
107
149
  }
108
- const evidenceId = raw.evidenceId;
150
+ const item = raw;
151
+ const evidenceId = item.evidenceId;
109
152
  if (typeof evidenceId !== "string" || evidenceId.length === 0) {
110
- throw new CollectorFindingsValidationError("通进司 finding 缺少可解析的 evidenceId 指针");
153
+ // Present item without a bindable pointer — keep gap fact, do not fabricate.
154
+ unprojected = true;
155
+ continue;
111
156
  }
112
- const record = byEvidenceId.get(evidenceId);
113
- if (record === undefined) {
157
+ const evidence = byEvidenceId.get(evidenceId);
158
+ if (evidence === undefined) {
114
159
  throw new CollectorUnknownEvidenceError(evidenceId);
115
160
  }
116
- if (record.kind !== "review" && record.kind !== "issue_comment" && record.kind !== "review_comment") {
117
- throw new CollectorFindingsValidationError(`通进司 finding 指针指向不可承 finding 的证据种类 ${record.kind}`);
161
+ if (evidence.kind !== "review" && evidence.kind !== "issue_comment" && evidence.kind !== "review_comment") {
162
+ throw new CollectorFindingsValidationError(`通进司 finding 指针指向不可承 finding 的证据种类 ${evidence.kind}`);
118
163
  }
119
- if (record.githubId === undefined) {
164
+ if (evidence.githubId === undefined) {
120
165
  throw new CollectorFindingsValidationError(`通进司 finding 指针证据 ${evidenceId} 缺少 GitHub id`);
121
166
  }
122
- const category = raw.category;
123
- if (category !== undefined && (typeof category !== "string" || category.trim().length === 0)) {
124
- throw new CollectorFindingsValidationError("通进司 finding category 必须为非空字符串");
125
- }
126
- const identity = record.machineIdentity ?? null;
167
+ const identity = evidence.machineIdentity ?? null;
127
168
  const group = input.groups.find((candidateGroup) => identityKey(candidateGroup.identity) === identityKey(identity));
128
169
  if (group === undefined) {
129
170
  throw new CollectorFindingsValidationError(`通进司 finding 指针证据 ${evidenceId} 无归属身份组`);
130
171
  }
172
+ // Bound finding keeps pointer; non-string category/summary are unprojected field gaps
173
+ // (pointer success ≠ full content projection) — #676 C / 3939511832.
174
+ const category = item.category;
175
+ const summary = item.summary;
176
+ if (Object.hasOwn(item, "category") && typeof category !== "string")
177
+ unprojected = true;
178
+ if (Object.hasOwn(item, "summary") && typeof summary !== "string")
179
+ unprojected = true;
131
180
  group.findings.push({
132
181
  identity,
133
182
  source: {
134
- kind: record.kind,
135
- id: record.githubId,
136
- evidenceId: record.evidenceId,
137
- headRelation: headRelationFor(record, input.targetHead),
183
+ kind: evidence.kind,
184
+ id: evidence.githubId,
185
+ evidenceId: evidence.evidenceId,
186
+ headRelation: headRelationFor(evidence, input.targetHead),
138
187
  },
139
- ...(category === undefined ? {} : { category: category.trim() }),
188
+ ...(typeof category === "string" ? { category } : {}),
189
+ ...(typeof summary === "string" ? { summary } : {}),
140
190
  pointer: {
141
191
  repository: input.repository,
142
192
  prNumber: input.prNumber,
143
- commentId: record.githubId,
144
- ...(record.htmlUrl === undefined ? {} : { htmlUrl: record.htmlUrl }),
145
- ...(record.authorLogin === undefined ? {} : { authorLogin: record.authorLogin }),
146
- kind: record.kind,
147
- authoritativeTime: record.authoritativeTime ?? null,
193
+ commentId: evidence.githubId,
194
+ ...(evidence.htmlUrl === undefined ? {} : { htmlUrl: evidence.htmlUrl }),
195
+ ...(evidence.authorLogin === undefined ? {} : { authorLogin: evidence.authorLogin }),
196
+ kind: evidence.kind,
197
+ authoritativeTime: evidence.authoritativeTime ?? null,
198
+ ...(evidence.commitOid === undefined ? {} : { commitOid: evidence.commitOid }),
148
199
  },
149
200
  });
201
+ projected += 1;
202
+ }
203
+ return {
204
+ findingsSource: "array",
205
+ findingsProjectedCount: projected,
206
+ findingsUnprojected: unprojected,
207
+ };
208
+ }
209
+ /**
210
+ * #676 D6/C: optional unfinished reasons from the model submission.
211
+ * Project readable strings; record when original content could not fully project
212
+ * (do not wash unreadable unfinishedReasons into "none").
213
+ */
214
+ export function extractCollectorUnfinishedReasons(candidate) {
215
+ if (candidate !== undefined && candidate !== null && candidateRecord(candidate) === undefined) {
216
+ return { reasons: undefined, source: "unreadable", unprojected: true };
217
+ }
218
+ const record = candidateRecord(candidate);
219
+ if (record === undefined) {
220
+ return { reasons: undefined, source: "absent", unprojected: false };
221
+ }
222
+ if (!Object.hasOwn(record, "unfinishedReasons")) {
223
+ // unfinishedReasons absent is fine when only findings (or nothing) present.
224
+ // Non-canonical keys alone are recorded on the findings projection path.
225
+ return { reasons: undefined, source: "absent", unprojected: false };
226
+ }
227
+ const raw = record["unfinishedReasons"];
228
+ if (!Array.isArray(raw)) {
229
+ return { reasons: undefined, source: "unreadable", unprojected: true };
150
230
  }
231
+ const reasons = raw.filter((item) => typeof item === "string");
232
+ const unprojected = reasons.length !== raw.length;
233
+ return {
234
+ reasons: reasons.length > 0 ? reasons : undefined,
235
+ source: "array",
236
+ unprojected,
237
+ };
151
238
  }
@@ -1,14 +1,18 @@
1
1
  import Value from "typebox/value";
2
2
  import { applyEvidenceVersionHistory, assignWindowRelations, COLLECTOR_ELIGIBILITY_MS, measureNormalizedBytes, normalizeAuthenticatedUserEvidence, normalizeIssueCommentEvidence, normalizePullRequestEvidence, normalizePullRequestReactionEvidence, normalizeReviewCommentEvidence, normalizeReviewEvidence, sha256Text, } from "./collector-evidence.js";
3
3
  import { buildCollectorRequestBody, } from "./collector-github.js";
4
+ import { CollectorNonOpenRequestError } from "./collector-identity.js";
4
5
  import { collectorObserveArgsSchema, collectorOutputArgsSchema, collectorReadArgsSchema, collectorRequestArgsSchema, collectorWaitArgsSchema, } from "./collector-tool-schemas.js";
5
6
  import { COLLECTOR_OUTPUT_TOOL } from "./package-contracts/collector-output.js";
6
7
  export const COLLECTOR_OBSERVE_TOOL = "ak_collector_observe";
7
8
  export const COLLECTOR_READ_TOOL = "ak_collector_read";
8
9
  export const COLLECTOR_REQUEST_TOOL = "ak_collector_request";
9
10
  export const COLLECTOR_WAIT_TOOL = "ak_collector_wait";
11
+ /** #676 A: role-decided target bind — business tool, ledger-booked. */
12
+ export const COLLECTOR_BIND_TARGET_TOOL = "ak_collector_bind_target";
10
13
  export { COLLECTOR_OUTPUT_TOOL };
11
14
  export const COLLECTOR_OPERATIONAL_TOOLS = [
15
+ COLLECTOR_BIND_TARGET_TOOL,
12
16
  COLLECTOR_OBSERVE_TOOL,
13
17
  COLLECTOR_READ_TOOL,
14
18
  COLLECTOR_REQUEST_TOOL,
@@ -129,10 +133,16 @@ export function createCollectorLedger(config, options) {
129
133
  return Math.max(0, deadlineMono - monoNowOrThrow(clock));
130
134
  };
131
135
  const prIdentity = (pr) => `${pr.state}|${pr.headOid}|${pr.updatedAt ?? ""}`;
136
+ const requireBoundPr = () => {
137
+ if (config.prNumber === undefined) {
138
+ throw new Error("Collector PR target is unbound; call ak_collector_bind_target with the role-decided issue/PR or pass --pr");
139
+ }
140
+ return config.prNumber;
141
+ };
132
142
  const fetchObserveSurfaces = async (transport, observedAt, signal) => {
133
143
  const owner = config.repository.owner;
134
144
  const repo = config.repository.repo;
135
- const prNumber = config.prNumber;
145
+ const prNumber = requireBoundPr();
136
146
  const signalOpt = signal === undefined ? {} : { signal };
137
147
  const user = await transport.getAuthenticatedUser(signalOpt);
138
148
  const prInitial = await transport.getPullRequest({
@@ -433,6 +443,23 @@ export function createCollectorLedger(config, options) {
433
443
  assertNotFatal();
434
444
  outputCandidate = true;
435
445
  },
446
+ bindTarget(prNumber) {
447
+ assertNotFatal();
448
+ if (outputCandidate || pendingOutputCallId !== undefined) {
449
+ throw new Error("通进司已产出输出候选,本局不再受理目标绑定");
450
+ }
451
+ if (!Number.isSafeInteger(prNumber) || prNumber < 1) {
452
+ throw new Error("Collector bind target requires a positive safe-integer PR number");
453
+ }
454
+ if (config.prNumber !== undefined && config.prNumber !== prNumber) {
455
+ throw new Error(`Collector target already bound to PR ${config.prNumber}; cannot rebind to ${prNumber}`);
456
+ }
457
+ config.prNumber = prNumber;
458
+ appendJournal("ak-collector-target-bound", {
459
+ prNumber,
460
+ repository: config.repository.canonical,
461
+ });
462
+ },
436
463
  beginOperational(toolName, toolCallId) {
437
464
  assertNotFatal();
438
465
  if (toolName !== COLLECTOR_OUTPUT_TOOL &&
@@ -581,7 +608,7 @@ export function createCollectorLedger(config, options) {
581
608
  completedMono,
582
609
  host: "github.com",
583
610
  repository: config.repository.canonical,
584
- prNumber: config.prNumber,
611
+ prNumber: requireBoundPr(),
585
612
  prState: pr.state,
586
613
  headOid: pr.headOid,
587
614
  complete: true,
@@ -620,10 +647,6 @@ export function createCollectorLedger(config, options) {
620
647
  if (activationTime === undefined || deadlineTime === undefined) {
621
648
  throw latchFatal("通进司请求需要激活");
622
649
  }
623
- if (pastCutoff(clock)) {
624
- finalObservationRequired = true;
625
- throw latchFatal("通进司请求不在资格截止前");
626
- }
627
650
  if (ledger.unresolvedTransportFailure) {
628
651
  throw latchFatal("通进司请求时存在未恢复的传输失败");
629
652
  }
@@ -639,7 +662,12 @@ export function createCollectorLedger(config, options) {
639
662
  throw new Error("通进司请求要求最新完整快照");
640
663
  }
641
664
  if (snapshot.prState !== "OPEN") {
642
- throw latchFatal("通进司请求要求 OPEN 状态的 PR 快照");
665
+ // #676 D6: non-OPEN keeps materials; bounce before cutoff fatal so post-deadline materials still seal.
666
+ throw new CollectorNonOpenRequestError(snapshot.prState);
667
+ }
668
+ if (pastCutoff(clock)) {
669
+ finalObservationRequired = true;
670
+ throw latchFatal("通进司请求不在资格截止前");
643
671
  }
644
672
  const { body, marker } = buildCollectorRequestBody({
645
673
  configuredBody: request.requestBody,
@@ -657,9 +685,10 @@ export function createCollectorLedger(config, options) {
657
685
  if (existingMarker) {
658
686
  throw new Error(`通进司在此 HEAD 已有同 marker 的已认证请求 "${input.requestId}"`);
659
687
  }
688
+ const boundPr = requireBoundPr();
660
689
  const attemptKey = [
661
690
  config.repository.canonical,
662
- String(config.prNumber),
691
+ String(boundPr),
663
692
  snapshot.headOid,
664
693
  request.id,
665
694
  ].join("|");
@@ -686,7 +715,7 @@ export function createCollectorLedger(config, options) {
686
715
  const result = await transport.createIssueComment({
687
716
  owner: config.repository.owner,
688
717
  repo: config.repository.repo,
689
- prNumber: config.prNumber,
718
+ prNumber: boundPr,
690
719
  body,
691
720
  ...(signal === undefined ? {} : { signal }),
692
721
  });
@@ -1,6 +1,6 @@
1
1
  import { COLLECTOR_HOST } from "./collector-config.js";
2
- import { enrichCollectorFindings, extractCollectorEvidenceIdentityGroups, } from "./collector-identity.js";
3
- import { validateAcceptedCollectorReceipt } from "./package-contracts/collector-output.js";
2
+ import { enrichCollectorFindings, extractCollectorEvidenceIdentityGroups, extractCollectorUnfinishedReasons, } from "./collector-identity.js";
3
+ import { validateAcceptedCollectorReceipt, } from "./package-contracts/collector-output.js";
4
4
  export { validateAcceptedCollectorReceipt };
5
5
  function fail(message) { throw new Error(message); }
6
6
  /** Runtime output assembles findings pointers from the model submission into the typed receipt. */
@@ -12,6 +12,9 @@ export function buildCollectorReceipt(ledger, candidateRaw, clock) {
12
12
  fail("Collector output requires a complete final snapshot");
13
13
  if (ledger.activationTime === undefined || ledger.deadlineTime === undefined)
14
14
  fail("Collector output requires activation timeline");
15
+ if (ledger.config.prNumber === undefined) {
16
+ fail("Collector output requires a bound PR target; call ak_collector_bind_target first or pass --pr");
17
+ }
15
18
  if (clock !== undefined)
16
19
  ledger.assertOutputObservationLaw(clock);
17
20
  else if (ledger.observedGeneration !== ledger.mutationGeneration ||
@@ -21,8 +24,7 @@ export function buildCollectorReceipt(ledger, candidateRaw, clock) {
21
24
  const finalSnapshot = ledger.getSnapshot(ledger.latestCompleteSnapshotId);
22
25
  if (finalSnapshot === undefined || !finalSnapshot.complete)
23
26
  fail("Collector final snapshot is incomplete");
24
- if (finalSnapshot.prState !== "OPEN")
25
- fail("Collector final snapshot PR state is not OPEN");
27
+ // #676 D6: closed/merged still deliver collected materials; status is a fact on the receipt.
26
28
  const evidenceRecords = [...ledger.allEvidence()];
27
29
  const snapshots = [...ledger.allSnapshots()];
28
30
  const evidenceIndex = new Map(evidenceRecords.map((record) => [record.evidenceId, record]));
@@ -44,7 +46,7 @@ export function buildCollectorReceipt(ledger, candidateRaw, clock) {
44
46
  const groups = extractCollectorEvidenceIdentityGroups(evidenceRecords, finalSnapshot.headOid);
45
47
  // #641 chain①: the collector LLM submits findings as pointer refs; the runtime
46
48
  // enriches each with the machine locator from the same stored record.
47
- enrichCollectorFindings({
49
+ const findingsProjection = enrichCollectorFindings({
48
50
  candidate: candidateRaw,
49
51
  records: evidenceRecords,
50
52
  groups,
@@ -53,8 +55,6 @@ export function buildCollectorReceipt(ledger, candidateRaw, clock) {
53
55
  prNumber: ledger.config.prNumber,
54
56
  });
55
57
  for (const group of groups) {
56
- if (group.attendance !== true)
57
- fail("Collector group lacks attendance");
58
58
  for (const material of group.materials) {
59
59
  if (material.evidenceId === undefined || !evidenceIndex.has(material.evidenceId))
60
60
  fail("Collector material lacks a receipt-local evidence ref");
@@ -64,10 +64,20 @@ export function buildCollectorReceipt(ledger, candidateRaw, clock) {
64
64
  fail("Collector finding lacks a receipt-local evidence ref");
65
65
  }
66
66
  }
67
+ const unfinished = extractCollectorUnfinishedReasons(candidateRaw);
68
+ const submissionProjection = {
69
+ findingsSource: findingsProjection.findingsSource,
70
+ findingsProjectedCount: findingsProjection.findingsProjectedCount,
71
+ findingsUnprojected: findingsProjection.findingsUnprojected,
72
+ unfinishedReasonsSource: unfinished.source,
73
+ unfinishedReasonsProjectedCount: unfinished.reasons?.length ?? 0,
74
+ unfinishedReasonsUnprojected: unfinished.unprojected,
75
+ };
67
76
  return {
68
77
  host: COLLECTOR_HOST,
69
78
  repository: ledger.config.repository.canonical,
70
79
  prNumber: ledger.config.prNumber,
80
+ prState: finalSnapshot.prState,
71
81
  manifestDigest: ledger.config.manifest.digest,
72
82
  activationTime: ledger.activationTime.toISOString(),
73
83
  deadlineTime: ledger.deadlineTime.toISOString(),
@@ -75,6 +85,8 @@ export function buildCollectorReceipt(ledger, candidateRaw, clock) {
75
85
  finalSnapshotId: finalSnapshot.snapshotId,
76
86
  targetHead: finalSnapshot.headOid,
77
87
  groups,
88
+ ...(unfinished.reasons === undefined ? {} : { unfinishedReasons: unfinished.reasons }),
89
+ submissionProjection,
78
90
  requestAttempts: [...ledger.requestAttempts()],
79
91
  snapshots,
80
92
  evidenceRecords: evidenceRecords.map(toReceiptEvidenceRecord),