@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,169 @@
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
+ import { createGhApiRunner, listPullRequestNumbersByCommit, listPullRequestNumbersByHead, } from "./collector-github.js";
11
+ import { CliUsageError } from "./public-cli/cli-errors.js";
12
+ import { ownerFromGitHubRemoteUrl, ownerRepoFromGitHubRemoteUrl, } from "./public-cli/github-remote.js";
13
+ function ambiguousTarget(detail, cause) {
14
+ throw new CliUsageError(`collector target is ambiguous: ${detail}; pass an explicit --pr`, cause === undefined ? undefined : { cause });
15
+ }
16
+ /** Real git failure — not target ambiguity. Propagates with true cause on exit 1. */
17
+ function gitFailure(detail, cause) {
18
+ throw new Error(`collector git failed: ${detail}`, {
19
+ cause: cause instanceof Error ? cause : new Error(String(cause)),
20
+ });
21
+ }
22
+ function gitText(projectRoot, args) {
23
+ return execFileSync("git", [...args], {
24
+ cwd: projectRoot,
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ }).trim();
28
+ }
29
+ /** git config --get exit 1 = key absent; other failures keep true cause. */
30
+ function isGitConfigMissing(error) {
31
+ if (typeof error !== "object" || error === null)
32
+ return false;
33
+ const status = error.status;
34
+ return status === 1;
35
+ }
36
+ function readCurrentBranch(projectRoot) {
37
+ try {
38
+ return gitText(projectRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
39
+ }
40
+ catch (error) {
41
+ gitFailure("cannot read current git branch", error);
42
+ }
43
+ }
44
+ function readHeadSha(projectRoot) {
45
+ try {
46
+ return gitText(projectRoot, ["rev-parse", "HEAD"]);
47
+ }
48
+ catch (error) {
49
+ gitFailure("cannot read current HEAD", error);
50
+ }
51
+ }
52
+ /**
53
+ * Head ref short name from branch.<name>.merge (refs/heads/<ref> or bare ref).
54
+ * Non-heads merge forms are not a structured head binding.
55
+ */
56
+ function headRefFromMerge(merge) {
57
+ const trimmed = merge.trim();
58
+ if (trimmed.length === 0)
59
+ return undefined;
60
+ if (trimmed.startsWith("refs/heads/")) {
61
+ const ref = trimmed.slice("refs/heads/".length);
62
+ return ref.length > 0 ? ref : undefined;
63
+ }
64
+ // Reject other refs/* (e.g. refs/remotes/…) — not a PR head ref binding.
65
+ if (trimmed.startsWith("refs/"))
66
+ return undefined;
67
+ return trimmed;
68
+ }
69
+ /**
70
+ * Real upstream head owner + head ref from branch.<name>.remote/merge when configured.
71
+ * Missing config keys = not configured (undefined). Config/remote execution failures
72
+ * throw with true cause — never swallowed as "no upstream, keep fallback".
73
+ */
74
+ function readUpstreamHeadBinding(projectRoot, branch) {
75
+ let remote;
76
+ try {
77
+ remote = gitText(projectRoot, ["config", "--get", `branch.${branch}.remote`]);
78
+ }
79
+ catch (error) {
80
+ if (isGitConfigMissing(error))
81
+ remote = undefined;
82
+ else
83
+ gitFailure(`cannot read branch.${branch}.remote`, error);
84
+ }
85
+ if (remote === undefined || remote.length === 0)
86
+ return undefined;
87
+ let merge;
88
+ try {
89
+ merge = gitText(projectRoot, ["config", "--get", `branch.${branch}.merge`]);
90
+ }
91
+ catch (error) {
92
+ if (isGitConfigMissing(error))
93
+ merge = undefined;
94
+ else
95
+ gitFailure(`cannot read branch.${branch}.merge`, error);
96
+ }
97
+ if (merge === undefined || merge.length === 0)
98
+ return undefined;
99
+ const headRef = headRefFromMerge(merge);
100
+ if (headRef === undefined)
101
+ return undefined;
102
+ let remoteUrl;
103
+ try {
104
+ remoteUrl = gitText(projectRoot, ["remote", "get-url", remote]);
105
+ }
106
+ catch (error) {
107
+ gitFailure(`cannot read remote URL for ${remote}`, error);
108
+ }
109
+ const headOwner = ownerFromGitHubRemoteUrl(remoteUrl);
110
+ if (headOwner === undefined)
111
+ return undefined;
112
+ return { headOwner, headRef };
113
+ }
114
+ /**
115
+ * Resolve the Collector PR target without guessing from task text.
116
+ * - Explicit `--pr` → that number (no association queries).
117
+ * - Otherwise: full online candidates (upstream head + HEAD commit), deduped.
118
+ * - Exactly 1 → bound. >1 → require explicit `--pr`. 0 → unbound (role bind tool).
119
+ * - Git/config/transport/HTTP/JSON failures propagate with true cause.
120
+ */
121
+ export async function resolveCollectorTarget(input) {
122
+ if (input.explicitPrNumber !== undefined) {
123
+ return { kind: "bound", prNumber: input.explicitPrNumber };
124
+ }
125
+ const branch = readCurrentBranch(input.projectRoot);
126
+ const detached = branch.length === 0 || branch === "HEAD";
127
+ // Detached HEAD cannot associate via branch head; commit association still runs.
128
+ const runner = createGhApiRunner();
129
+ const { owner, repo } = input.repository;
130
+ const numbers = [];
131
+ if (!detached) {
132
+ const headSha = readHeadSha(input.projectRoot);
133
+ const upstream = readUpstreamHeadBinding(input.projectRoot, branch);
134
+ // Prefer structured head owner:ref from real upstream merge binding (fork-safe).
135
+ if (upstream !== undefined) {
136
+ numbers.push(...(await listPullRequestNumbersByHead(runner, {
137
+ owner,
138
+ repo,
139
+ headOwner: upstream.headOwner,
140
+ headRef: upstream.headRef,
141
+ })));
142
+ }
143
+ // Always also take commit association — never let a sole upstream hit hide a conflict.
144
+ numbers.push(...(await listPullRequestNumbersByCommit(runner, {
145
+ owner,
146
+ repo,
147
+ commitSha: headSha,
148
+ })));
149
+ }
150
+ else {
151
+ // Detached: commit association only (no branch head).
152
+ const headSha = readHeadSha(input.projectRoot);
153
+ numbers.push(...(await listPullRequestNumbersByCommit(runner, {
154
+ owner,
155
+ repo,
156
+ commitSha: headSha,
157
+ })));
158
+ }
159
+ const unique = [...new Set(numbers)];
160
+ if (unique.length === 1) {
161
+ return { kind: "bound", prNumber: unique[0] };
162
+ }
163
+ if (unique.length > 1) {
164
+ ambiguousTarget(`multiple PRs associated with context: ${unique.join(", ")}`);
165
+ }
166
+ // Zero structured git hits — leave unbound so the role can decide from materials.
167
+ return { kind: "unbound" };
168
+ }
169
+ export { ownerRepoFromGitHubRemoteUrl, ownerFromGitHubRemoteUrl };
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { COLLECTOR_ELIGIBILITY_MS } from "./collector-evidence.js";
3
+ import { openToolObject } from "./open-tool-schema.js";
3
4
  import { withInfrastructureFailureDeclaration } from "./package-contracts/terminating-infrastructure.js";
4
5
  export const collectorObserveArgsSchema = Type.Object({}, { additionalProperties: false });
5
6
  export const collectorRequestArgsSchema = Type.Object({
@@ -13,21 +14,57 @@ export const collectorWaitArgsSchema = Type.Object({
13
14
  durationMs: Type.Integer({ minimum: 1, maximum: COLLECTOR_ELIGIBILITY_MS, description: "等待毫秒;单次上限五分钟且不超剩余资格" }),
14
15
  }, { additionalProperties: false });
15
16
  /**
16
- * #641 chain①: the collector LLM does the splitting/classification (#245:
17
- * 他自己就是llm为什么不能读不能拆). Findings are submitted as pointer refs into
18
- * the stored evidence; the runtime enriches each with the machine pointer
19
- * (repo/PR/comment id/url/author/kind/时间) and validates resolvability.
20
- * Category is a short LLM classification label, never a body transcription.
17
+ * #676 A: role-decided target bind. The model judges task materials and submits
18
+ * the chosen PR and/or issue identity; runtime only performs online association
19
+ * for the role-chosen ticket — never scrapes task text to lock a target.
21
20
  */
22
- export const collectorFindingArgsSchema = Type.Object({
23
- evidenceId: Type.String({ minLength: 1, description: "observe 返回的材料证据 id(evidenceId)" }),
24
- category: Type.Optional(Type.String({ minLength: 1, maxLength: 200, description: "该 finding 的简短归类标签;需要头部之外的正文时先用 ak_collector_read 开卷再判读,不得誊写评论正文" })),
25
- }, { additionalProperties: false });
26
- export const collectorOutputBaseSchema = Type.Object({
27
- findings: Type.Optional(Type.Array(collectorFindingArgsSchema, {
28
- description: "本次收集到的逐条 findings;零 finding 的模板通知不得进入。正常完工无 finding 时省略。",
21
+ export const collectorBindTargetArgsSchema = Type.Object({
22
+ prNumber: Type.Optional(Type.Unknown({
23
+ description: "角色判定的本仓 PR 号(正整数)。与 issueNumber 二选一或同指唯一目标。形状指引,非 schema 闸。",
29
24
  })),
30
- }, { additionalProperties: true, description: "正常完工提交空对象 {};仅在基础设施真实失败时才填写 infrastructureFailure,无失败时必须省略该字段。" });
25
+ issueNumber: Type.Optional(Type.Unknown({
26
+ description: "角色判定的本仓 issue 号(正整数);runtime 经线上关联解析唯一 PR。形状指引,非 schema 闸。",
27
+ })),
28
+ }, { additionalProperties: true });
29
+ /**
30
+ * #641 chain① / #676 C / ADR 0057: nested finding item declarations for model
31
+ * guidance. Host must not pure-shape-reject the envelope (第 0 条). Runtime binds
32
+ * resolvable evidence pointers only; unprojected content stays distinguishable.
33
+ */
34
+ const collectorFindingItemDeclaration = (() => {
35
+ // Nested declarations for model guidance only — open required so host cannot
36
+ // pure-shape-reject missing optional fields (第 0 条 / ADR 0057 / #676 C).
37
+ const item = Type.Object({
38
+ evidenceId: Type.Unknown({
39
+ description: "observe 返回的材料指针(必填语义)",
40
+ }),
41
+ category: Type.Unknown({
42
+ description: "简短归类标签,不是摘要",
43
+ }),
44
+ summary: Type.Unknown({
45
+ description: "哪个 bot、什么问题的摘要;不誊抄正文",
46
+ }),
47
+ }, {
48
+ additionalProperties: true,
49
+ description: "单条 finding 指针:evidenceId + 可选 category/summary。形状指引,非 schema 闸。",
50
+ });
51
+ item.required = [];
52
+ return item;
53
+ })();
54
+ /**
55
+ * Field declarations + descriptions are guidance for the model — host must not
56
+ * pure-shape-reject the envelope (第 0 条 / ADR 0057).
57
+ */
58
+ export const collectorOutputBaseSchema = openToolObject(Type.Object({
59
+ // No root type:array — host must not shape-reject non-array findings (#676 C).
60
+ // Nested item declarations ride `items` for registration preservation (ADR 0057).
61
+ findings: Type.Unsafe({
62
+ description: "本次收集到的逐条 findings(指针数组为规范形)。零 finding 的模板通知不得进入;正常完工无 finding 时省略。形状指引,非 schema 闸。",
63
+ items: collectorFindingItemDeclaration,
64
+ }),
65
+ unfinishedReasons: Type.Unknown({
66
+ description: "未完成原因字符串数组(额度/故障/等待届满等现场依据);不得把未完成表述为无问题。无可报告时省略。形状指引,非 schema 闸。",
67
+ }),
68
+ }));
31
69
  /** Runtime owns the observed evidence; the model submits findings and signals sole-final submission. */
32
70
  export const collectorOutputArgsSchema = withInfrastructureFailureDeclaration(collectorOutputBaseSchema);
33
- collectorOutputArgsSchema.required = [];
@@ -25,10 +25,16 @@ function validateAcceptedCollectorReceipt(value) {
25
25
  materials: records(safeGet(group, "materials")),
26
26
  findings: records(safeGet(group, "findings"))
27
27
  }));
28
+ const unfinishedRaw = safeGet(value, "unfinishedReasons");
29
+ const unfinishedReasons = strings(unfinishedRaw);
30
+ const submissionProjection = projectSubmissionProjection(safeGet(value, "submissionProjection"));
31
+ const prStateRaw = safeGet(value, "prState");
32
+ const prState = typeof prStateRaw === "string" ? prStateRaw : void 0;
28
33
  return {
29
34
  host: safeGet(value, "host"),
30
35
  repository: safeGet(value, "repository"),
31
36
  prNumber: safeGet(value, "prNumber"),
37
+ ...prState === void 0 ? {} : { prState },
32
38
  manifestDigest: safeGet(value, "manifestDigest"),
33
39
  activationTime: safeGet(value, "activationTime"),
34
40
  deadlineTime: safeGet(value, "deadlineTime"),
@@ -36,6 +42,8 @@ function validateAcceptedCollectorReceipt(value) {
36
42
  finalSnapshotId: safeGet(value, "finalSnapshotId"),
37
43
  targetHead: safeGet(value, "targetHead"),
38
44
  groups,
45
+ ...unfinishedReasons.length > 0 ? { unfinishedReasons } : {},
46
+ ...submissionProjection === void 0 ? {} : { submissionProjection },
39
47
  requestAttempts: records(safeGet(value, "requestAttempts")),
40
48
  snapshots: records(safeGet(value, "snapshots")).map((snapshot) => ({
41
49
  snapshotId: safeGet(snapshot, "snapshotId"),
@@ -55,6 +63,30 @@ function validateAcceptedCollectorReceipt(value) {
55
63
  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") }))
56
64
  };
57
65
  }
66
+ function projectSubmissionProjection(raw) {
67
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return void 0;
68
+ const p = raw;
69
+ const out = {};
70
+ if (p["findingsSource"] === "absent" || p["findingsSource"] === "array" || p["findingsSource"] === "unreadable") {
71
+ out.findingsSource = p["findingsSource"];
72
+ }
73
+ if (typeof p["findingsProjectedCount"] === "number") {
74
+ out.findingsProjectedCount = p["findingsProjectedCount"];
75
+ }
76
+ if (typeof p["findingsUnprojected"] === "boolean") {
77
+ out.findingsUnprojected = p["findingsUnprojected"];
78
+ }
79
+ if (p["unfinishedReasonsSource"] === "absent" || p["unfinishedReasonsSource"] === "array" || p["unfinishedReasonsSource"] === "unreadable") {
80
+ out.unfinishedReasonsSource = p["unfinishedReasonsSource"];
81
+ }
82
+ if (typeof p["unfinishedReasonsProjectedCount"] === "number") {
83
+ out.unfinishedReasonsProjectedCount = p["unfinishedReasonsProjectedCount"];
84
+ }
85
+ if (typeof p["unfinishedReasonsUnprojected"] === "boolean") {
86
+ out.unfinishedReasonsUnprojected = p["unfinishedReasonsUnprojected"];
87
+ }
88
+ return Object.keys(out).length > 0 ? out : void 0;
89
+ }
58
90
  export {
59
91
  COLLECTOR_ACCEPTED_TEXT,
60
92
  COLLECTOR_HOST,
@@ -1,20 +1,21 @@
1
1
  import { Type } from "typebox";
2
2
  const INFRASTRUCTURE_FAILURE_DECLARATION_KEY = "infrastructureFailure";
3
3
  const INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY = "diagnostic";
4
+ const infrastructureFailureNested = Type.Object(
5
+ {
6
+ [INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY]: Type.Unknown({
7
+ description: "\u975E\u7A7A\u57FA\u7840\u8BBE\u65BD\u5931\u8D25\u8BCA\u65AD\u5B57\u7B26\u4E32\u3002\u65E0\u5931\u8D25\u65F6\u5FC5\u987B\u7701\u7565\u6574\u4E2A infrastructureFailure\u3002\u5F62\u72B6\u6307\u5F15\uFF0C\u975E schema \u95F8\u3002"
8
+ })
9
+ },
10
+ {
11
+ additionalProperties: true,
12
+ description: "\u57FA\u7840\u8BBE\u65BD\u771F\u5B9E\u5931\u8D25\u58F0\u660E\uFF08\u5982\u9700\uFF09\u3002\u89C4\u8303\u5F62\uFF1A{ diagnostic: \u975E\u7A7A\u8BCA\u65AD\u5B57\u7B26\u4E32 }\uFF1B\u65E0\u5931\u8D25\u65F6\u5FC5\u987B\u7701\u7565\u3002\u5F62\u72B6\u6307\u5F15\uFF0C\u975E schema \u95F8\u3002"
13
+ }
14
+ );
15
+ infrastructureFailureNested.required = [];
4
16
  const infrastructureFailureDeclarationSchema = Type.Object(
5
17
  {
6
- [INFRASTRUCTURE_FAILURE_DECLARATION_KEY]: Type.Object(
7
- {
8
- [INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY]: Type.String({
9
- minLength: 1,
10
- description: "\u975E\u7A7A\u57FA\u7840\u8BBE\u65BD\u5931\u8D25\u8BCA\u65AD"
11
- })
12
- },
13
- {
14
- additionalProperties: true,
15
- description: "\u57FA\u7840\u8BBE\u65BD\u5931\u8D25\u58F0\u660E"
16
- }
17
- )
18
+ [INFRASTRUCTURE_FAILURE_DECLARATION_KEY]: infrastructureFailureNested
18
19
  },
19
20
  { additionalProperties: true }
20
21
  );
@@ -91,8 +91,7 @@ function buildActivationFlagArgs(activation) {
91
91
  "collector",
92
92
  "--ak-collector-repo",
93
93
  activation.repo,
94
- "--ak-collector-pr",
95
- activation.pr,
94
+ ...(activation.pr === undefined ? [] : ["--ak-collector-pr", activation.pr]),
96
95
  ...(activation.requestManifestPath === undefined
97
96
  ? []
98
97
  : ["--ak-collector-request-manifest", activation.requestManifestPath]),
@@ -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
+ /** Parse owner from a github.com remote URL; undefined when not a GitHub owner/repo remote. */
6
+ export function ownerFromGitHubRemoteUrl(remoteUrl) {
7
+ const ownerRepo = ownerRepoFromGitHubRemoteUrl(remoteUrl);
8
+ if (ownerRepo === undefined)
9
+ return undefined;
10
+ return ownerRepo.split("/")[0].toLowerCase();
11
+ }
12
+ /** Parse owner/repo identity from a github.com remote URL; undefined when not exact. */
13
+ export function ownerRepoFromGitHubRemoteUrl(remoteUrl) {
14
+ const trimmed = remoteUrl.trim();
15
+ // git@github.com:owner/repo.git — exact owner/repo identity only.
16
+ const scp = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(trimmed);
17
+ if (scp) {
18
+ return `${scp[1]}/${stripGitSuffix(scp[2])}`;
19
+ }
20
+ // ssh://git@github.com/owner/repo(.git) — exact owner/repo identity only.
21
+ const ssh = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(trimmed);
22
+ if (ssh) {
23
+ return `${ssh[1]}/${stripGitSuffix(ssh[2])}`;
24
+ }
25
+ // https://github.com/owner/repo(.git) and git://github.com/... — exact two-segment path.
26
+ let parsed;
27
+ try {
28
+ parsed = new URL(trimmed);
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ if (!/^github\.com$/i.test(parsed.hostname))
34
+ return undefined;
35
+ // Non-identity URL material (query/hash/extra path) is not a repository remote.
36
+ if (parsed.search !== "" || parsed.hash !== "")
37
+ return undefined;
38
+ const parts = parsed.pathname.split("/").filter((p) => p.length > 0);
39
+ if (parts.length !== 2)
40
+ return undefined;
41
+ return `${parts[0]}/${stripGitSuffix(parts[1])}`;
42
+ }
43
+ function stripGitSuffix(name) {
44
+ return name.toLowerCase().endsWith(".git") ? name.slice(0, -4) : name;
45
+ }
@@ -10,7 +10,9 @@ import { activationBookDirectory, ensureRealDirectoryTree, homeFromRunDirectory,
10
10
  import { resolveBookKeyFromGit } from "../activation-ledger-git.js";
11
11
  import { readRunTicketNumber } from "../run-ticket-number.js";
12
12
  import { loadDoctorCase, } from "../doctor-evidence.js";
13
- import { COLLECTOR_FIXED_KICKOFF, emptyCollectorManifest, loadCollectorManifest, parseCollectorPrNumber, parseCollectorRepository, } from "../collector-config.js";
13
+ import { emptyCollectorManifest, loadCollectorManifest, parseCollectorPrNumber, parseCollectorRepository, } from "../collector-config.js";
14
+ import { resolveCollectorTarget } from "../collector-target.js";
15
+ import { ownerRepoFromGitHubRemoteUrl } from "./github-remote.js";
14
16
  import { FixerPacketValidationError, parseFixerPrerequisites, } from "../package-contracts/fixer-packet.js";
15
17
  import { createProductionMergerGitState } from "../merger-git-state.js";
16
18
  import { validateMergerInput, } from "../merger-contracts.js";
@@ -1020,10 +1022,11 @@ export function parseCollectorArgv(args) {
1020
1022
  }
1021
1023
  positional.push(token);
1022
1024
  }
1023
- // Unconditional required (e.g. --pr) from typed table via shared consumer (#342).
1025
+ // Unconditional required from typed table via shared consumer (#342).
1026
+ // #676 D1: --pr is optional; ambiguous targets reject at admission, not by guessing.
1024
1027
  options.assertRequired();
1025
1028
  return {
1026
- prNumber: prNumber,
1029
+ ...(prNumber === undefined ? {} : { prNumber }),
1027
1030
  instruction: positional.join(" "),
1028
1031
  attachmentPaths,
1029
1032
  ...(project === undefined ? {} : { project }),
@@ -1034,6 +1037,7 @@ export function parseCollectorArgv(args) {
1034
1037
  /**
1035
1038
  * Resolve owner/repo from the project's `origin` remote (github.com only).
1036
1039
  * Supports https and SSH GitHub URL shapes; never scrapes instruction prose.
1040
+ * Missing origin / non-github remote → usage. Git execution failure → true cause (exit 1).
1037
1041
  */
1038
1042
  export function resolveGitHubRemoteRepository(projectRoot) {
1039
1043
  let remoteUrl;
@@ -1045,7 +1049,13 @@ export function resolveGitHubRemoteRepository(projectRoot) {
1045
1049
  }).trim();
1046
1050
  }
1047
1051
  catch (error) {
1048
- throw new CliUsageError("collector requires a github.com origin remote or an explicit --repo owner/repo", { cause: error });
1052
+ // git remote get-url exit 2 = no such remote; other failures keep true cause.
1053
+ if (isGitRemoteMissing(error)) {
1054
+ throw new CliUsageError("collector requires a github.com origin remote or an explicit --repo owner/repo", { cause: error });
1055
+ }
1056
+ throw new Error("collector git failed: cannot read origin remote URL", {
1057
+ cause: error instanceof Error ? error : new Error(String(error)),
1058
+ });
1049
1059
  }
1050
1060
  if (remoteUrl.length === 0) {
1051
1061
  throw new CliUsageError("collector requires a github.com origin remote or an explicit --repo owner/repo");
@@ -1062,55 +1072,34 @@ export function resolveGitHubRemoteRepository(projectRoot) {
1062
1072
  throw new CliUsageError(detail, { cause: error });
1063
1073
  }
1064
1074
  }
1065
- function ownerRepoFromGitHubRemoteUrl(remoteUrl) {
1066
- const trimmed = remoteUrl.trim();
1067
- // git@github.com:owner/repo.git — exact owner/repo identity only.
1068
- const scp = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(trimmed);
1069
- if (scp) {
1070
- return `${scp[1]}/${stripGitSuffix(scp[2])}`;
1071
- }
1072
- // ssh://git@github.com/owner/repo(.git) — exact owner/repo identity only.
1073
- const ssh = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(trimmed);
1074
- if (ssh) {
1075
- return `${ssh[1]}/${stripGitSuffix(ssh[2])}`;
1076
- }
1077
- // https://github.com/owner/repo(.git) and git://github.com/... — exact two-segment path.
1078
- let parsed;
1079
- try {
1080
- parsed = new URL(trimmed);
1081
- }
1082
- catch {
1083
- return undefined;
1084
- }
1085
- if (!/^github\.com$/i.test(parsed.hostname))
1086
- return undefined;
1087
- // Non-identity URL material (query/hash/extra path) is not a repository remote.
1088
- if (parsed.search !== "" || parsed.hash !== "")
1089
- return undefined;
1090
- const parts = parsed.pathname.split("/").filter((p) => p.length > 0);
1091
- if (parts.length !== 2)
1092
- return undefined;
1093
- return `${parts[0]}/${stripGitSuffix(parts[1])}`;
1094
- }
1095
- function stripGitSuffix(name) {
1096
- return name.toLowerCase().endsWith(".git") ? name.slice(0, -4) : name;
1075
+ /**
1076
+ * git remote get-url: exit 2 = no such remote on common git (missing config).
1077
+ * Other statuses keep true cause — do not broaden into usage (#676 B).
1078
+ */
1079
+ function isGitRemoteMissing(error) {
1080
+ if (typeof error !== "object" || error === null)
1081
+ return false;
1082
+ const status = error.status;
1083
+ return status === 2;
1097
1084
  }
1098
1085
  /**
1099
1086
  * Admit a Collector Role run: assemble the retained leg manifest from typed
1100
- * declarations, resolve repository identity, and place the session under #78.
1101
- * Does not preflight PR/author existence against GitHub.
1087
+ * declarations, resolve repository + PR target (#676 D1), and place the session under #78.
1088
+ * Explicit PR is not preflighted for existence; context resolution uses online association.
1102
1089
  */
1103
1090
  export async function admitCollectorInvocation(options) {
1104
1091
  if (options.project !== undefined) {
1105
1092
  requireOptionPath("--project", options.project);
1106
1093
  }
1107
- let prNumber;
1108
- try {
1109
- prNumber = parseCollectorPrNumber(options.prNumber);
1110
- }
1111
- catch (error) {
1112
- const detail = error instanceof Error ? error.message : String(error);
1113
- throw new CliUsageError(detail, { cause: error });
1094
+ let explicitPrNumber;
1095
+ if (options.prNumber !== undefined) {
1096
+ try {
1097
+ explicitPrNumber = parseCollectorPrNumber(options.prNumber);
1098
+ }
1099
+ catch (error) {
1100
+ const detail = error instanceof Error ? error.message : String(error);
1101
+ throw new CliUsageError(detail, { cause: error });
1102
+ }
1114
1103
  }
1115
1104
  const projectRoot = resolve(options.project ?? options.cwd);
1116
1105
  let repository;
@@ -1149,14 +1138,23 @@ export async function admitCollectorInvocation(options) {
1149
1138
  const attachmentsDirectory = join(runDirectory, "attachments");
1150
1139
  ensureRealDirectoryTree(ledgerHome, sessionDirectory);
1151
1140
  ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
1141
+ // #676 A: freeze task materials BEFORE target resolution so the role receives
1142
+ // real instruction + attachments. Admission binds only explicit --pr or unique
1143
+ // head/commit association — task-text scrape is not a target lock.
1152
1144
  const attachments = await freezeAttachments(options.attachmentPaths ?? [], attachmentsDirectory);
1145
+ const instruction = options.instruction ?? "";
1146
+ const instructionEmpty = instruction.trim() === "";
1147
+ const target = await resolveCollectorTarget({
1148
+ projectRoot,
1149
+ repository,
1150
+ ...(explicitPrNumber === undefined ? {} : { explicitPrNumber }),
1151
+ });
1152
+ const prNumber = target.kind === "bound" ? target.prNumber : undefined;
1153
1153
  let requestManifestPath;
1154
1154
  if (manifestCanonicalJson !== undefined) {
1155
1155
  requestManifestPath = join(runDirectory, "request-manifest.json");
1156
1156
  await writeFile(requestManifestPath, manifestCanonicalJson, "utf8");
1157
1157
  }
1158
- const instruction = options.instruction ?? "";
1159
- const instructionEmpty = instruction.trim() === "";
1160
1158
  const admitted = {
1161
1159
  role: "collector",
1162
1160
  runId,
@@ -1166,7 +1164,7 @@ export async function admitCollectorInvocation(options) {
1166
1164
  principal,
1167
1165
  instruction,
1168
1166
  instructionEmpty,
1169
- prNumber,
1167
+ ...(prNumber === undefined ? {} : { prNumber }),
1170
1168
  repository: repository.canonical,
1171
1169
  repositoryDisplay: repository.display,
1172
1170
  ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
@@ -1196,19 +1194,20 @@ export async function admitCollectorInvocation(options) {
1196
1194
  runDirectory,
1197
1195
  principal,
1198
1196
  admittedRequestPath,
1199
- prNumber,
1197
+ ...(prNumber === undefined ? {} : { prNumber }),
1200
1198
  repository,
1201
1199
  ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
1202
1200
  manifestDigest,
1203
1201
  };
1204
1202
  }
1205
1203
  /**
1206
- * Collector always consumes the fixed packaged kickoff (one-shot observation).
1207
- * Optional public instruction is retained only in the admitted request.
1204
+ * #676 A: Collector consumes the real call task + frozen attachments so the role
1205
+ * can identify issue/PR from materials via ak_collector_bind_target. Explicit --pr
1206
+ * still wins at admission; unique head/commit association also binds. No fixed
1207
+ * kickoff rewrite of the caller task; no mechanical task-text target lock.
1208
1208
  */
1209
- export function buildCollectorTransportPrompt(_admitted, engineMaterial) {
1210
- // Exact historical fixed kickoff bytes (one-shot observation).
1211
- return appendEngineSessionMaterial([COLLECTOR_FIXED_KICKOFF], engineMaterial).join("\n");
1209
+ export function buildCollectorTransportPrompt(admitted, engineMaterial) {
1210
+ return buildInstructionTransportPrompt(admitted, engineMaterial);
1212
1211
  }
1213
1212
  /** Positive Issue number grammar shared with Doctor case path identity. */
1214
1213
  const DOCTOR_ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/;