@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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * #676 D1: Collector admission target recognition on structured git context.
3
+ * Explicit PR wins (no association queries). Otherwise gather online candidates
4
+ * from structured upstream head and HEAD commit association only. Deduped unique
5
+ * → bind; many → require explicit --pr; zero → unbound so the role can decide
6
+ * from task materials via the bind-target business tool. No task-text scrape,
7
+ * no second state machine. Git/config/transport failures keep true cause.
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ import type { CollectorRepository } from "./collector-config.ts";
12
+ import {
13
+ createGhApiRunner,
14
+ listPullRequestNumbersByCommit,
15
+ listPullRequestNumbersByHead,
16
+ } from "./collector-github.ts";
17
+ import { CliUsageError } from "./public-cli/cli-errors.ts";
18
+ import {
19
+ ownerFromGitHubRemoteUrl,
20
+ ownerRepoFromGitHubRemoteUrl,
21
+ } from "./public-cli/github-remote.ts";
22
+
23
+ export type ResolveCollectorTargetInput = {
24
+ readonly projectRoot: string;
25
+ readonly repository: CollectorRepository;
26
+ /** Caller-provided PR number when already explicit. */
27
+ readonly explicitPrNumber?: number;
28
+ };
29
+
30
+ export type CollectorTargetResolution =
31
+ | { readonly kind: "bound"; readonly prNumber: number }
32
+ | { readonly kind: "unbound" };
33
+
34
+ function ambiguousTarget(detail: string, cause?: unknown): never {
35
+ throw new CliUsageError(
36
+ `collector target is ambiguous: ${detail}; pass an explicit --pr`,
37
+ cause === undefined ? undefined : { cause },
38
+ );
39
+ }
40
+
41
+ /** Real git failure — not target ambiguity. Propagates with true cause on exit 1. */
42
+ function gitFailure(detail: string, cause: unknown): never {
43
+ throw new Error(`collector git failed: ${detail}`, {
44
+ cause: cause instanceof Error ? cause : new Error(String(cause)),
45
+ });
46
+ }
47
+
48
+ function gitText(projectRoot: string, args: readonly string[]): string {
49
+ return execFileSync("git", [...args], {
50
+ cwd: projectRoot,
51
+ encoding: "utf8",
52
+ stdio: ["ignore", "pipe", "pipe"],
53
+ }).trim();
54
+ }
55
+
56
+ /** git config --get exit 1 = key absent; other failures keep true cause. */
57
+ function isGitConfigMissing(error: unknown): boolean {
58
+ if (typeof error !== "object" || error === null) return false;
59
+ const status = (error as { status?: unknown }).status;
60
+ return status === 1;
61
+ }
62
+
63
+ function readCurrentBranch(projectRoot: string): string {
64
+ try {
65
+ return gitText(projectRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
66
+ } catch (error) {
67
+ gitFailure("cannot read current git branch", error);
68
+ }
69
+ }
70
+
71
+ function readHeadSha(projectRoot: string): string {
72
+ try {
73
+ return gitText(projectRoot, ["rev-parse", "HEAD"]);
74
+ } catch (error) {
75
+ gitFailure("cannot read current HEAD", error);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Head ref short name from branch.<name>.merge (refs/heads/<ref> or bare ref).
81
+ * Non-heads merge forms are not a structured head binding.
82
+ */
83
+ function headRefFromMerge(merge: string): string | undefined {
84
+ const trimmed = merge.trim();
85
+ if (trimmed.length === 0) return undefined;
86
+ if (trimmed.startsWith("refs/heads/")) {
87
+ const ref = trimmed.slice("refs/heads/".length);
88
+ return ref.length > 0 ? ref : undefined;
89
+ }
90
+ // Reject other refs/* (e.g. refs/remotes/…) — not a PR head ref binding.
91
+ if (trimmed.startsWith("refs/")) return undefined;
92
+ return trimmed;
93
+ }
94
+
95
+ /**
96
+ * Real upstream head owner + head ref from branch.<name>.remote/merge when configured.
97
+ * Missing config keys = not configured (undefined). Config/remote execution failures
98
+ * throw with true cause — never swallowed as "no upstream, keep fallback".
99
+ */
100
+ function readUpstreamHeadBinding(
101
+ projectRoot: string,
102
+ branch: string,
103
+ ): { readonly headOwner: string; readonly headRef: string } | undefined {
104
+ let remote: string | undefined;
105
+ try {
106
+ remote = gitText(projectRoot, ["config", "--get", `branch.${branch}.remote`]);
107
+ } catch (error) {
108
+ if (isGitConfigMissing(error)) remote = undefined;
109
+ else gitFailure(`cannot read branch.${branch}.remote`, error);
110
+ }
111
+ if (remote === undefined || remote.length === 0) return undefined;
112
+
113
+ let merge: string | undefined;
114
+ try {
115
+ merge = gitText(projectRoot, ["config", "--get", `branch.${branch}.merge`]);
116
+ } catch (error) {
117
+ if (isGitConfigMissing(error)) merge = undefined;
118
+ else gitFailure(`cannot read branch.${branch}.merge`, error);
119
+ }
120
+ if (merge === undefined || merge.length === 0) return undefined;
121
+
122
+ const headRef = headRefFromMerge(merge);
123
+ if (headRef === undefined) return undefined;
124
+
125
+ let remoteUrl: string;
126
+ try {
127
+ remoteUrl = gitText(projectRoot, ["remote", "get-url", remote]);
128
+ } catch (error) {
129
+ gitFailure(`cannot read remote URL for ${remote}`, error);
130
+ }
131
+ const headOwner = ownerFromGitHubRemoteUrl(remoteUrl);
132
+ if (headOwner === undefined) return undefined;
133
+ return { headOwner, headRef };
134
+ }
135
+
136
+ /**
137
+ * Resolve the Collector PR target without guessing from task text.
138
+ * - Explicit `--pr` → that number (no association queries).
139
+ * - Otherwise: full online candidates (upstream head + HEAD commit), deduped.
140
+ * - Exactly 1 → bound. >1 → require explicit `--pr`. 0 → unbound (role bind tool).
141
+ * - Git/config/transport/HTTP/JSON failures propagate with true cause.
142
+ */
143
+ export async function resolveCollectorTarget(
144
+ input: ResolveCollectorTargetInput,
145
+ ): Promise<CollectorTargetResolution> {
146
+ if (input.explicitPrNumber !== undefined) {
147
+ return { kind: "bound", prNumber: input.explicitPrNumber };
148
+ }
149
+
150
+ const branch = readCurrentBranch(input.projectRoot);
151
+ const detached = branch.length === 0 || branch === "HEAD";
152
+
153
+ // Detached HEAD cannot associate via branch head; commit association still runs.
154
+ const runner = createGhApiRunner();
155
+ const { owner, repo } = input.repository;
156
+ const numbers: number[] = [];
157
+
158
+ if (!detached) {
159
+ const headSha = readHeadSha(input.projectRoot);
160
+ const upstream = readUpstreamHeadBinding(input.projectRoot, branch);
161
+
162
+ // Prefer structured head owner:ref from real upstream merge binding (fork-safe).
163
+ if (upstream !== undefined) {
164
+ numbers.push(
165
+ ...(await listPullRequestNumbersByHead(runner, {
166
+ owner,
167
+ repo,
168
+ headOwner: upstream.headOwner,
169
+ headRef: upstream.headRef,
170
+ })),
171
+ );
172
+ }
173
+ // Always also take commit association — never let a sole upstream hit hide a conflict.
174
+ numbers.push(
175
+ ...(await listPullRequestNumbersByCommit(runner, {
176
+ owner,
177
+ repo,
178
+ commitSha: headSha,
179
+ })),
180
+ );
181
+ } else {
182
+ // Detached: commit association only (no branch head).
183
+ const headSha = readHeadSha(input.projectRoot);
184
+ numbers.push(
185
+ ...(await listPullRequestNumbersByCommit(runner, {
186
+ owner,
187
+ repo,
188
+ commitSha: headSha,
189
+ })),
190
+ );
191
+ }
192
+
193
+ const unique = [...new Set(numbers)];
194
+
195
+ if (unique.length === 1) {
196
+ return { kind: "bound", prNumber: unique[0]! };
197
+ }
198
+ if (unique.length > 1) {
199
+ ambiguousTarget(
200
+ `multiple PRs associated with context: ${unique.join(", ")}`,
201
+ );
202
+ }
203
+ // Zero structured git hits — leave unbound so the role can decide from materials.
204
+ return { kind: "unbound" };
205
+ }
206
+
207
+ export { ownerRepoFromGitHubRemoteUrl, ownerFromGitHubRemoteUrl };
@@ -1,5 +1,6 @@
1
1
  import { Type, type Static } from "typebox";
2
2
  import { COLLECTOR_ELIGIBILITY_MS } from "./collector-evidence.ts";
3
+ import { openToolObject } from "./open-tool-schema.ts";
3
4
  import { withInfrastructureFailureDeclaration } from "./package-contracts/terminating-infrastructure.ts";
4
5
 
5
6
  export const collectorObserveArgsSchema = Type.Object({}, { additionalProperties: false });
@@ -15,31 +16,77 @@ export const collectorWaitArgsSchema = Type.Object({
15
16
  }, { additionalProperties: false });
16
17
 
17
18
  /**
18
- * #641 chain①: the collector LLM does the splitting/classification (#245:
19
- * 他自己就是llm为什么不能读不能拆). Findings are submitted as pointer refs into
20
- * the stored evidence; the runtime enriches each with the machine pointer
21
- * (repo/PR/comment id/url/author/kind/时间) and validates resolvability.
22
- * Category is a short LLM classification label, never a body transcription.
19
+ * #676 A: role-decided target bind. The model judges task materials and submits
20
+ * the chosen PR and/or issue identity; runtime only performs online association
21
+ * for the role-chosen ticket — never scrapes task text to lock a target.
23
22
  */
24
- export const collectorFindingArgsSchema = Type.Object({
25
- evidenceId: Type.String({ minLength: 1, description: "observe 返回的材料证据 id(evidenceId)" }),
26
- category: Type.Optional(Type.String({ minLength: 1, maxLength: 200, description: "该 finding 的简短归类标签;需要头部之外的正文时先用 ak_collector_read 开卷再判读,不得誊写评论正文" })),
27
- }, { additionalProperties: false });
28
-
29
- export const collectorOutputBaseSchema = Type.Object({
30
- findings: Type.Optional(Type.Array(collectorFindingArgsSchema, {
31
- description: "本次收集到的逐条 findings;零 finding 的模板通知不得进入。正常完工无 finding 时省略。",
23
+ export const collectorBindTargetArgsSchema = Type.Object({
24
+ prNumber: Type.Optional(Type.Unknown({
25
+ description: "角色判定的本仓 PR 号(正整数)。与 issueNumber 二选一或同指唯一目标。形状指引,非 schema 闸。",
32
26
  })),
33
- }, { additionalProperties: true, description: "正常完工提交空对象 {};仅在基础设施真实失败时才填写 infrastructureFailure,无失败时必须省略该字段。" });
27
+ issueNumber: Type.Optional(Type.Unknown({
28
+ description: "角色判定的本仓 issue 号(正整数);runtime 经线上关联解析唯一 PR。形状指引,非 schema 闸。",
29
+ })),
30
+ }, { additionalProperties: true });
31
+
32
+ /**
33
+ * #641 chain① / #676 C / ADR 0057: nested finding item declarations for model
34
+ * guidance. Host must not pure-shape-reject the envelope (第 0 条). Runtime binds
35
+ * resolvable evidence pointers only; unprojected content stays distinguishable.
36
+ */
37
+ const collectorFindingItemDeclaration = (() => {
38
+ // Nested declarations for model guidance only — open required so host cannot
39
+ // pure-shape-reject missing optional fields (第 0 条 / ADR 0057 / #676 C).
40
+ const item = Type.Object(
41
+ {
42
+ evidenceId: Type.Unknown({
43
+ description: "observe 返回的材料指针(必填语义)",
44
+ }),
45
+ category: Type.Unknown({
46
+ description: "简短归类标签,不是摘要",
47
+ }),
48
+ summary: Type.Unknown({
49
+ description: "哪个 bot、什么问题的摘要;不誊抄正文",
50
+ }),
51
+ },
52
+ {
53
+ additionalProperties: true,
54
+ description:
55
+ "单条 finding 指针:evidenceId + 可选 category/summary。形状指引,非 schema 闸。",
56
+ },
57
+ );
58
+ (item as unknown as { required: string[] }).required = [];
59
+ return item;
60
+ })();
61
+
62
+ /**
63
+ * Field declarations + descriptions are guidance for the model — host must not
64
+ * pure-shape-reject the envelope (第 0 条 / ADR 0057).
65
+ */
66
+ export const collectorOutputBaseSchema = openToolObject(
67
+ Type.Object({
68
+ // No root type:array — host must not shape-reject non-array findings (#676 C).
69
+ // Nested item declarations ride `items` for registration preservation (ADR 0057).
70
+ findings: Type.Unsafe({
71
+ description:
72
+ "本次收集到的逐条 findings(指针数组为规范形)。零 finding 的模板通知不得进入;正常完工无 finding 时省略。形状指引,非 schema 闸。",
73
+ items: collectorFindingItemDeclaration,
74
+ }),
75
+ unfinishedReasons: Type.Unknown({
76
+ description:
77
+ "未完成原因字符串数组(额度/故障/等待届满等现场依据);不得把未完成表述为无问题。无可报告时省略。形状指引,非 schema 闸。",
78
+ }),
79
+ }),
80
+ );
34
81
 
35
82
  /** Runtime owns the observed evidence; the model submits findings and signals sole-final submission. */
36
83
  export const collectorOutputArgsSchema = withInfrastructureFailureDeclaration(
37
84
  collectorOutputBaseSchema,
38
85
  );
39
- (collectorOutputArgsSchema as unknown as { required: string[] }).required = [];
40
86
 
41
87
  export type CollectorObserveArgs = Static<typeof collectorObserveArgsSchema>;
42
88
  export type CollectorRequestArgs = Static<typeof collectorRequestArgsSchema>;
43
89
  export type CollectorReadArgs = Static<typeof collectorReadArgsSchema>;
44
90
  export type CollectorWaitArgs = Static<typeof collectorWaitArgsSchema>;
91
+ export type CollectorBindTargetArgs = Static<typeof collectorBindTargetArgsSchema>;
45
92
  export type CollectorOutputArgs = Static<typeof collectorOutputArgsSchema>;
@@ -103,7 +103,8 @@ export type RoleTurnActivation =
103
103
  | {
104
104
  readonly role: "collector";
105
105
  readonly repo: string;
106
- readonly pr: string;
106
+ /** Bound PR when known at admission; omit when role will bind from materials. */
107
+ readonly pr?: string;
107
108
  readonly requestManifestPath?: string;
108
109
  }
109
110
  | { readonly role: "doctor"; readonly casePath: string }
@@ -59,10 +59,29 @@ export type CollectorEvidenceRecord = {
59
59
  authoritativeTime?: string | null;
60
60
  };
61
61
 
62
+ /**
63
+ * #676 C: sole structured projection-fact type (builder + package leaf).
64
+ * Builder always emits the full object; package leaf keeps it optional so old
65
+ * volumes without the field stay readable — missing is unknown, never defaulted.
66
+ */
67
+ export type CollectorSubmissionProjection = {
68
+ findingsSource: "absent" | "array" | "unreadable";
69
+ findingsProjectedCount: number;
70
+ findingsUnprojected: boolean;
71
+ unfinishedReasonsSource: "absent" | "array" | "unreadable";
72
+ unfinishedReasonsProjectedCount: number;
73
+ unfinishedReasonsUnprojected: boolean;
74
+ };
75
+
62
76
  export type CollectorReceipt = {
63
77
  host: "github.com";
64
78
  repository: string;
65
79
  prNumber: number;
80
+ /**
81
+ * Final observed PR state; non-OPEN still delivers materials (#676 D6).
82
+ * Absent/unreadable on old volumes is unknown — never defaulted to OPEN.
83
+ */
84
+ prState?: string;
66
85
  manifestDigest: string;
67
86
  activationTime: string;
68
87
  deadlineTime: string;
@@ -70,6 +89,13 @@ export type CollectorReceipt = {
70
89
  finalSnapshotId: string;
71
90
  targetHead: string;
72
91
  groups: CollectorIdentityGroup[];
92
+ unfinishedReasons?: string[];
93
+ /**
94
+ * Distinguishes readable-empty from unprojected original submission content (#676 C).
95
+ * Package leaf may carry a partial projection when some sibling keys are unreadable —
96
+ * missing keys stay absent (never defaulted false/0/OPEN).
97
+ */
98
+ submissionProjection?: Partial<CollectorSubmissionProjection>;
73
99
  requestAttempts: CollectorRequestAttempt[];
74
100
  snapshots: CollectorSnapshot[];
75
101
  evidenceRecords: CollectorEvidenceRecord[];
@@ -97,10 +123,18 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
97
123
  materials: records(safeGet(group, "materials")),
98
124
  findings: records(safeGet(group, "findings")),
99
125
  }));
126
+ const unfinishedRaw = safeGet(value, "unfinishedReasons");
127
+ const unfinishedReasons = strings(unfinishedRaw);
128
+ // #676 C: project readable submissionProjection fields independently — do not
129
+ // drop the whole block when one sibling key is the wrong type (left-拾遗1).
130
+ const submissionProjection = projectSubmissionProjection(safeGet(value, "submissionProjection"));
131
+ const prStateRaw = safeGet(value, "prState");
132
+ const prState = typeof prStateRaw === "string" ? prStateRaw : undefined;
100
133
  return {
101
134
  host: safeGet(value, "host") as "github.com",
102
135
  repository: safeGet(value, "repository") as string,
103
136
  prNumber: safeGet(value, "prNumber") as number,
137
+ ...(prState === undefined ? {} : { prState }),
104
138
  manifestDigest: safeGet(value, "manifestDigest") as string,
105
139
  activationTime: safeGet(value, "activationTime") as string,
106
140
  deadlineTime: safeGet(value, "deadlineTime") as string,
@@ -108,6 +142,8 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
108
142
  finalSnapshotId: safeGet(value, "finalSnapshotId") as string,
109
143
  targetHead: safeGet(value, "targetHead") as string,
110
144
  groups,
145
+ ...(unfinishedReasons.length > 0 ? { unfinishedReasons } : {}),
146
+ ...(submissionProjection === undefined ? {} : { submissionProjection }),
111
147
  requestAttempts: records(safeGet(value, "requestAttempts")) as unknown as CollectorRequestAttempt[],
112
148
  snapshots: records(safeGet(value, "snapshots")).map((snapshot) => ({
113
149
  snapshotId: safeGet(snapshot, "snapshotId"), observedAt: safeGet(snapshot, "observedAt"), completedAt: safeGet(snapshot, "completedAt"), completedMono: safeGet(snapshot, "completedMono"), host: safeGet(snapshot, "host"), repository: safeGet(snapshot, "repository"), prNumber: safeGet(snapshot, "prNumber"), prState: safeGet(snapshot, "prState"), headOid: safeGet(snapshot, "headOid"), complete: safeGet(snapshot, "complete"), evidenceIds: strings(safeGet(snapshot, "evidenceIds")), pageDiagnostics: records(safeGet(snapshot, "pageDiagnostics")), normalizedByteLength: safeGet(snapshot, "normalizedByteLength"),
@@ -115,3 +151,39 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
115
151
  evidenceRecords: records(safeGet(value, "evidenceRecords")).map((record) => ({ evidenceId: safeGet(record, "evidenceId"), kind: safeGet(record, "kind"), versionId: safeGet(record, "versionId"), contentDigest: safeGet(record, "contentDigest"), firstObservedAt: safeGet(record, "firstObservedAt"), githubId: safeGet(record, "githubId"), authorLogin: safeGet(record, "authorLogin"), htmlUrl: safeGet(record, "htmlUrl"), authoritativeTime: safeGet(record, "authoritativeTime") } as CollectorEvidenceRecord)),
116
152
  };
117
153
  }
154
+
155
+ /** Readable projection facts only; omit keys that are the wrong type — never fabricate defaults. */
156
+ function projectSubmissionProjection(
157
+ raw: unknown,
158
+ ): Partial<CollectorSubmissionProjection> | undefined {
159
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
160
+ const p = raw as Record<string, unknown>;
161
+ const out: Partial<CollectorSubmissionProjection> = {};
162
+ if (
163
+ p["findingsSource"] === "absent" ||
164
+ p["findingsSource"] === "array" ||
165
+ p["findingsSource"] === "unreadable"
166
+ ) {
167
+ out.findingsSource = p["findingsSource"];
168
+ }
169
+ if (typeof p["findingsProjectedCount"] === "number") {
170
+ out.findingsProjectedCount = p["findingsProjectedCount"];
171
+ }
172
+ if (typeof p["findingsUnprojected"] === "boolean") {
173
+ out.findingsUnprojected = p["findingsUnprojected"];
174
+ }
175
+ if (
176
+ p["unfinishedReasonsSource"] === "absent" ||
177
+ p["unfinishedReasonsSource"] === "array" ||
178
+ p["unfinishedReasonsSource"] === "unreadable"
179
+ ) {
180
+ out.unfinishedReasonsSource = p["unfinishedReasonsSource"];
181
+ }
182
+ if (typeof p["unfinishedReasonsProjectedCount"] === "number") {
183
+ out.unfinishedReasonsProjectedCount = p["unfinishedReasonsProjectedCount"];
184
+ }
185
+ if (typeof p["unfinishedReasonsUnprojected"] === "boolean") {
186
+ out.unfinishedReasonsUnprojected = p["unfinishedReasonsUnprojected"];
187
+ }
188
+ return Object.keys(out).length > 0 ? out : undefined;
189
+ }
@@ -18,21 +18,32 @@ export const INFRASTRUCTURE_FAILURE_DECLARATION_KEY =
18
18
  "infrastructureFailure" as const;
19
19
  export const INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY = "diagnostic" as const;
20
20
 
21
- /** Shared typed declaration fragment: `infrastructureFailure.diagnostic` = non-empty string. */
21
+ /**
22
+ * Shared declaration fragment for model guidance (#541 / #676 C / ADR 0057).
23
+ * Nested field declarations + descriptions only — host must not pure-shape-reject
24
+ * the envelope (第 0 条). Runtime `failOnInfrastructureFailureDeclaration` still
25
+ * recognizes a real non-empty diagnostic string as the failure declaration.
26
+ * No required/minLength/type host gates on the declaration fragment.
27
+ */
28
+ const infrastructureFailureNested = Type.Object(
29
+ {
30
+ [INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY]: Type.Unknown({
31
+ description:
32
+ "非空基础设施失败诊断字符串。无失败时必须省略整个 infrastructureFailure。形状指引,非 schema 闸。",
33
+ }),
34
+ },
35
+ {
36
+ additionalProperties: true,
37
+ description:
38
+ "基础设施真实失败声明(如需)。规范形:{ diagnostic: 非空诊断字符串 };无失败时必须省略。形状指引,非 schema 闸。",
39
+ },
40
+ );
41
+ // Open nested required so host cannot pure-shape-reject the declaration fragment.
42
+ (infrastructureFailureNested as unknown as { required: string[] }).required = [];
43
+
22
44
  const infrastructureFailureDeclarationSchema = Type.Object(
23
45
  {
24
- [INFRASTRUCTURE_FAILURE_DECLARATION_KEY]: Type.Object(
25
- {
26
- [INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY]: Type.String({
27
- minLength: 1,
28
- description: "非空基础设施失败诊断",
29
- }),
30
- },
31
- {
32
- additionalProperties: true,
33
- description: "基础设施失败声明",
34
- },
35
- ),
46
+ [INFRASTRUCTURE_FAILURE_DECLARATION_KEY]: infrastructureFailureNested,
36
47
  },
37
48
  { additionalProperties: true },
38
49
  );
@@ -112,8 +112,7 @@ function buildActivationFlagArgs(activation: RoleTurnActivation): string[] {
112
112
  "collector",
113
113
  "--ak-collector-repo",
114
114
  activation.repo,
115
- "--ak-collector-pr",
116
- activation.pr,
115
+ ...(activation.pr === undefined ? [] : ["--ak-collector-pr", activation.pr]),
117
116
  ...(activation.requestManifestPath === undefined
118
117
  ? []
119
118
  : ["--ak-collector-request-manifest", activation.requestManifestPath]),
@@ -149,6 +149,7 @@ import {
149
149
  } from "./registry.ts";
150
150
  import {
151
151
  formatCliDiagnostic,
152
+ formatErrorCauseDetail,
152
153
  presentStructuralRejection,
153
154
  } from "./settlement.ts";
154
155
  import type { TerminalResult } from "./terminal.ts";
@@ -1620,11 +1621,19 @@ export async function runAkRole(
1620
1621
  return { exitCode: 2 };
1621
1622
  }
1622
1623
  // Unrecognized outer failure: retain actual name/message identity (no wash).
1624
+ // #676 B: cause (HTTP body/headers, git stderr, nested Error) must reach the
1625
+ // caller via the same formatErrorCauseDetail face as structural rejection.
1623
1626
  if (error instanceof Error) {
1624
- const label =
1627
+ let label =
1625
1628
  error.name !== "" && error.name !== "Error"
1626
1629
  ? `${error.name}: ${error.message}`
1627
1630
  : error.message;
1631
+ if (error.cause !== undefined) {
1632
+ const detail = formatErrorCauseDetail(error.cause);
1633
+ if (detail.trim().length > 0) {
1634
+ label = `${label || error.name || "unrecognized exception"}; cause: ${detail}`;
1635
+ }
1636
+ }
1628
1637
  io.stderr(formatCliDiagnostic(label || error.name || "unrecognized exception"));
1629
1638
  return { exitCode: 1 };
1630
1639
  }
@@ -51,7 +51,8 @@ export function buildCollectorTurnRequest(
51
51
  activation: {
52
52
  role: "collector" as const,
53
53
  repo: admitted.repository.display,
54
- pr: String(admitted.prNumber),
54
+ // #676 A: omit pr when unbound — role binds via ak_collector_bind_target.
55
+ ...(admitted.prNumber === undefined ? {} : { pr: String(admitted.prNumber) }),
55
56
  ...(admitted.requestManifestPath === undefined ? {} : { requestManifestPath: admitted.requestManifestPath }),
56
57
  },
57
58
  },
@@ -76,7 +77,7 @@ export async function runPublicCollector(
76
77
  home: env.home,
77
78
  principalAuthority: env.principalAuthority,
78
79
  cwd: env.cwd,
79
- prNumber: parsed.prNumber,
80
+ ...(parsed.prNumber === undefined ? {} : { prNumber: parsed.prNumber }),
80
81
  instruction: parsed.instruction,
81
82
  attachmentPaths: parsed.attachmentPaths,
82
83
  ...(parsed.project === undefined ? {} : { project: parsed.project }),
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared github.com remote URL → owner/repo parse (#676 B/D7).
3
+ * One authority for collector admission origin and upstream head owner.
4
+ */
5
+
6
+ /** Parse owner from a github.com remote URL; undefined when not a GitHub owner/repo remote. */
7
+ export function ownerFromGitHubRemoteUrl(remoteUrl: string): string | undefined {
8
+ const ownerRepo = ownerRepoFromGitHubRemoteUrl(remoteUrl);
9
+ if (ownerRepo === undefined) return undefined;
10
+ return ownerRepo.split("/")[0]!.toLowerCase();
11
+ }
12
+
13
+ /** Parse owner/repo identity from a github.com remote URL; undefined when not exact. */
14
+ export function ownerRepoFromGitHubRemoteUrl(remoteUrl: string): string | undefined {
15
+ const trimmed = remoteUrl.trim();
16
+ // git@github.com:owner/repo.git — exact owner/repo identity only.
17
+ const scp = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(trimmed);
18
+ if (scp) {
19
+ return `${scp[1]}/${stripGitSuffix(scp[2]!)}`;
20
+ }
21
+ // ssh://git@github.com/owner/repo(.git) — exact owner/repo identity only.
22
+ const ssh = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(
23
+ trimmed,
24
+ );
25
+ if (ssh) {
26
+ return `${ssh[1]}/${stripGitSuffix(ssh[2]!)}`;
27
+ }
28
+ // https://github.com/owner/repo(.git) and git://github.com/... — exact two-segment path.
29
+ let parsed: URL;
30
+ try {
31
+ parsed = new URL(trimmed);
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ if (!/^github\.com$/i.test(parsed.hostname)) return undefined;
36
+ // Non-identity URL material (query/hash/extra path) is not a repository remote.
37
+ if (parsed.search !== "" || parsed.hash !== "") return undefined;
38
+ const parts = parsed.pathname.split("/").filter((p) => p.length > 0);
39
+ if (parts.length !== 2) return undefined;
40
+ return `${parts[0]}/${stripGitSuffix(parts[1]!)}`;
41
+ }
42
+
43
+ function stripGitSuffix(name: string): string {
44
+ return name.toLowerCase().endsWith(".git") ? name.slice(0, -4) : name;
45
+ }