@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
@@ -31,13 +31,14 @@ import {
31
31
  } from "../doctor-evidence.ts";
32
32
  import type { DoctorCaseIdentity } from "../doctor-contracts.ts";
33
33
  import {
34
- COLLECTOR_FIXED_KICKOFF,
35
34
  emptyCollectorManifest,
36
35
  loadCollectorManifest,
37
36
  parseCollectorPrNumber,
38
37
  parseCollectorRepository,
39
38
  type CollectorRepository,
40
39
  } from "../collector-config.ts";
40
+ import { resolveCollectorTarget } from "../collector-target.ts";
41
+ import { ownerRepoFromGitHubRemoteUrl } from "./github-remote.ts";
41
42
  import {
42
43
  FixerPacketValidationError,
43
44
  parseFixerPrerequisites,
@@ -172,7 +173,8 @@ export type AdmittedFixerInvocation = AdmittedRoleInvocationBase & {
172
173
 
173
174
  export type AdmittedCollectorInvocation = AdmittedRoleInvocationBase & {
174
175
  readonly role: "collector";
175
- readonly prNumber: number;
176
+ /** Bound at admission when explicit/--pr or unique head/commit; otherwise role binds. */
177
+ readonly prNumber?: number;
176
178
  readonly repository: CollectorRepository;
177
179
  readonly requestManifestPath?: string;
178
180
  readonly manifestDigest: string;
@@ -718,7 +720,8 @@ export type ParseFixerArgvResult = {
718
720
  };
719
721
 
720
722
  export type ParseCollectorArgvResult = {
721
- prNumber: number;
723
+ /** Explicit --pr when provided; admission resolves from context when absent (#676 D1). */
724
+ prNumber?: number;
722
725
  instruction: string;
723
726
  attachmentPaths: string[];
724
727
  project?: string;
@@ -1759,10 +1762,11 @@ export function parseCollectorArgv(args: readonly string[]): ParseCollectorArgvR
1759
1762
  }
1760
1763
  positional.push(token);
1761
1764
  }
1762
- // Unconditional required (e.g. --pr) from typed table via shared consumer (#342).
1765
+ // Unconditional required from typed table via shared consumer (#342).
1766
+ // #676 D1: --pr is optional; ambiguous targets reject at admission, not by guessing.
1763
1767
  options.assertRequired();
1764
1768
  return {
1765
- prNumber: prNumber!,
1769
+ ...(prNumber === undefined ? {} : { prNumber }),
1766
1770
  instruction: positional.join(" "),
1767
1771
  attachmentPaths,
1768
1772
  ...(project === undefined ? {} : { project }),
@@ -1774,6 +1778,7 @@ export function parseCollectorArgv(args: readonly string[]): ParseCollectorArgvR
1774
1778
  /**
1775
1779
  * Resolve owner/repo from the project's `origin` remote (github.com only).
1776
1780
  * Supports https and SSH GitHub URL shapes; never scrapes instruction prose.
1781
+ * Missing origin / non-github remote → usage. Git execution failure → true cause (exit 1).
1777
1782
  */
1778
1783
  export function resolveGitHubRemoteRepository(
1779
1784
  projectRoot: string,
@@ -1786,10 +1791,16 @@ export function resolveGitHubRemoteRepository(
1786
1791
  stdio: ["ignore", "pipe", "pipe"],
1787
1792
  }).trim();
1788
1793
  } catch (error) {
1789
- throw new CliUsageError(
1790
- "collector requires a github.com origin remote or an explicit --repo owner/repo",
1791
- { cause: error },
1792
- );
1794
+ // git remote get-url exit 2 = no such remote; other failures keep true cause.
1795
+ if (isGitRemoteMissing(error)) {
1796
+ throw new CliUsageError(
1797
+ "collector requires a github.com origin remote or an explicit --repo owner/repo",
1798
+ { cause: error },
1799
+ );
1800
+ }
1801
+ throw new Error("collector git failed: cannot read origin remote URL", {
1802
+ cause: error instanceof Error ? error : new Error(String(error)),
1803
+ });
1793
1804
  }
1794
1805
  if (remoteUrl.length === 0) {
1795
1806
  throw new CliUsageError(
@@ -1811,44 +1822,22 @@ export function resolveGitHubRemoteRepository(
1811
1822
  }
1812
1823
  }
1813
1824
 
1814
- function ownerRepoFromGitHubRemoteUrl(remoteUrl: string): string | undefined {
1815
- const trimmed = remoteUrl.trim();
1816
- // git@github.com:owner/repo.git — exact owner/repo identity only.
1817
- const scp = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i.exec(trimmed);
1818
- if (scp) {
1819
- return `${scp[1]}/${stripGitSuffix(scp[2]!)}`;
1820
- }
1821
- // ssh://git@github.com/owner/repo(.git) — exact owner/repo identity only.
1822
- const ssh = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(
1823
- trimmed,
1824
- );
1825
- if (ssh) {
1826
- return `${ssh[1]}/${stripGitSuffix(ssh[2]!)}`;
1827
- }
1828
- // https://github.com/owner/repo(.git) and git://github.com/... — exact two-segment path.
1829
- let parsed: URL;
1830
- try {
1831
- parsed = new URL(trimmed);
1832
- } catch {
1833
- return undefined;
1834
- }
1835
- if (!/^github\.com$/i.test(parsed.hostname)) return undefined;
1836
- // Non-identity URL material (query/hash/extra path) is not a repository remote.
1837
- if (parsed.search !== "" || parsed.hash !== "") return undefined;
1838
- const parts = parsed.pathname.split("/").filter((p) => p.length > 0);
1839
- if (parts.length !== 2) return undefined;
1840
- return `${parts[0]}/${stripGitSuffix(parts[1]!)}`;
1841
- }
1842
-
1843
- function stripGitSuffix(name: string): string {
1844
- return name.toLowerCase().endsWith(".git") ? name.slice(0, -4) : name;
1825
+ /**
1826
+ * git remote get-url: exit 2 = no such remote on common git (missing config).
1827
+ * Other statuses keep true cause — do not broaden into usage (#676 B).
1828
+ */
1829
+ function isGitRemoteMissing(error: unknown): boolean {
1830
+ if (typeof error !== "object" || error === null) return false;
1831
+ const status = (error as { status?: unknown }).status;
1832
+ return status === 2;
1845
1833
  }
1846
1834
 
1847
1835
  export type AdmitCollectorInvocationOptions = {
1848
1836
  home: string;
1849
1837
  principalAuthority: DurablePrincipalAuthority;
1850
1838
  cwd: string;
1851
- prNumber: number;
1839
+ /** Explicit PR when provided; resolved from context when absent (#676 D1). */
1840
+ prNumber?: number;
1852
1841
  instruction?: string;
1853
1842
  attachmentPaths?: readonly string[];
1854
1843
  project?: string;
@@ -1863,8 +1852,8 @@ export type AdmitCollectorInvocationOptions = {
1863
1852
 
1864
1853
  /**
1865
1854
  * Admit a Collector Role run: assemble the retained leg manifest from typed
1866
- * declarations, resolve repository identity, and place the session under #78.
1867
- * Does not preflight PR/author existence against GitHub.
1855
+ * declarations, resolve repository + PR target (#676 D1), and place the session under #78.
1856
+ * Explicit PR is not preflighted for existence; context resolution uses online association.
1868
1857
  */
1869
1858
  export async function admitCollectorInvocation(
1870
1859
  options: AdmitCollectorInvocationOptions,
@@ -1872,12 +1861,14 @@ export async function admitCollectorInvocation(
1872
1861
  if (options.project !== undefined) {
1873
1862
  requireOptionPath("--project", options.project);
1874
1863
  }
1875
- let prNumber: number;
1876
- try {
1877
- prNumber = parseCollectorPrNumber(options.prNumber);
1878
- } catch (error) {
1879
- const detail = error instanceof Error ? error.message : String(error);
1880
- throw new CliUsageError(detail, { cause: error });
1864
+ let explicitPrNumber: number | undefined;
1865
+ if (options.prNumber !== undefined) {
1866
+ try {
1867
+ explicitPrNumber = parseCollectorPrNumber(options.prNumber);
1868
+ } catch (error) {
1869
+ const detail = error instanceof Error ? error.message : String(error);
1870
+ throw new CliUsageError(detail, { cause: error });
1871
+ }
1881
1872
  }
1882
1873
 
1883
1874
  const projectRoot = resolve(options.project ?? options.cwd);
@@ -1924,16 +1915,25 @@ export async function admitCollectorInvocation(
1924
1915
  ensureRealDirectoryTree(ledgerHome, sessionDirectory);
1925
1916
  ensureRealDirectoryTree(ledgerHome, attachmentsDirectory);
1926
1917
 
1918
+ // #676 A: freeze task materials BEFORE target resolution so the role receives
1919
+ // real instruction + attachments. Admission binds only explicit --pr or unique
1920
+ // head/commit association — task-text scrape is not a target lock.
1927
1921
  const attachments = await freezeAttachments(options.attachmentPaths ?? [], attachmentsDirectory);
1922
+ const instruction = options.instruction ?? "";
1923
+ const instructionEmpty = instruction.trim() === "";
1924
+
1925
+ const target = await resolveCollectorTarget({
1926
+ projectRoot,
1927
+ repository,
1928
+ ...(explicitPrNumber === undefined ? {} : { explicitPrNumber }),
1929
+ });
1930
+ const prNumber = target.kind === "bound" ? target.prNumber : undefined;
1928
1931
 
1929
1932
  let requestManifestPath: string | undefined;
1930
1933
  if (manifestCanonicalJson !== undefined) {
1931
1934
  requestManifestPath = join(runDirectory, "request-manifest.json");
1932
1935
  await writeFile(requestManifestPath, manifestCanonicalJson, "utf8");
1933
1936
  }
1934
-
1935
- const instruction = options.instruction ?? "";
1936
- const instructionEmpty = instruction.trim() === "";
1937
1937
  const admitted = {
1938
1938
  role: "collector" as const,
1939
1939
  runId,
@@ -1943,7 +1943,7 @@ export async function admitCollectorInvocation(
1943
1943
  principal,
1944
1944
  instruction,
1945
1945
  instructionEmpty,
1946
- prNumber,
1946
+ ...(prNumber === undefined ? {} : { prNumber }),
1947
1947
  repository: repository.canonical,
1948
1948
  repositoryDisplay: repository.display,
1949
1949
  ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
@@ -1974,7 +1974,7 @@ export async function admitCollectorInvocation(
1974
1974
  runDirectory,
1975
1975
  principal,
1976
1976
  admittedRequestPath,
1977
- prNumber,
1977
+ ...(prNumber === undefined ? {} : { prNumber }),
1978
1978
  repository,
1979
1979
  ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
1980
1980
  manifestDigest,
@@ -1982,15 +1982,16 @@ export async function admitCollectorInvocation(
1982
1982
  }
1983
1983
 
1984
1984
  /**
1985
- * Collector always consumes the fixed packaged kickoff (one-shot observation).
1986
- * Optional public instruction is retained only in the admitted request.
1985
+ * #676 A: Collector consumes the real call task + frozen attachments so the role
1986
+ * can identify issue/PR from materials via ak_collector_bind_target. Explicit --pr
1987
+ * still wins at admission; unique head/commit association also binds. No fixed
1988
+ * kickoff rewrite of the caller task; no mechanical task-text target lock.
1987
1989
  */
1988
1990
  export function buildCollectorTransportPrompt(
1989
- _admitted: AdmittedCollectorInvocation,
1991
+ admitted: AdmittedCollectorInvocation,
1990
1992
  engineMaterial?: EngineSessionMaterial,
1991
1993
  ): string {
1992
- // Exact historical fixed kickoff bytes (one-shot observation).
1993
- return appendEngineSessionMaterial([COLLECTOR_FIXED_KICKOFF], engineMaterial).join("\n");
1994
+ return buildInstructionTransportPrompt(admitted, engineMaterial);
1994
1995
  }
1995
1996
 
1996
1997
  /** Positive Issue number grammar shared with Doctor case path identity. */
@@ -565,12 +565,13 @@ const COLLECTOR_OPTIONS = [
565
565
  canonical: "--pr",
566
566
  aliases: [],
567
567
  valueMetavar: "number",
568
- required: true,
568
+ // #676 D1: optional when context uniquely determines the PR; ambiguous → require explicit.
569
+ required: false,
569
570
  repeatable: false,
570
571
  form: "option",
571
572
  description: {
572
- en: "Required positive GitHub pull request number.",
573
- zh: "必填;正整数 GitHub PR 号。",
573
+ en: "Positive GitHub pull request number. Optional when unique branch/HEAD association binds the PR, or when the Collector role can decide the target from task materials via bind-target; multi-candidate git context or a role that cannot decide requires an explicit value.",
574
+ zh: "正整数 GitHub PR 号。分支/HEAD 唯一关联可绑定时可省略;亦可由通进司从任务材料经 bind-target 判定。git 多候选或角色无法判定时必须显式提供。",
574
575
  },
575
576
  },
576
577
  {
@@ -1199,9 +1200,10 @@ const ROLE_COMMAND_HELP = {
1199
1200
  collector: {
1200
1201
  command: "collector",
1201
1202
  summary: "Collect GitHub PR review evidence.",
1202
- usage: ["ak-role collector --pr <number> [options] [instruction]"],
1203
+ usage: ["ak-role collector [--pr <number>] [options] [instruction]"],
1203
1204
  examples: [
1204
1205
  "ak-role collector --pr 42 --repo owner/repository",
1206
+ "ak-role collector --repo owner/repository \"Collect findings for #42\"",
1205
1207
  "ak-role collector --pr 42 --request-manifest ./requests.json",
1206
1208
  ],
1207
1209
  },
@@ -1883,8 +1883,8 @@ export async function loadResumableCollectorRun(
1883
1883
  );
1884
1884
  }
1885
1885
  const { prNumber, repository, repositoryDisplay, manifestDigest } = loaded.admittedFields;
1886
+ // #676 A: prNumber may be unbound at admission (role bind-target). Repository is required.
1886
1887
  if (
1887
- prNumber === undefined ||
1888
1888
  repository === undefined ||
1889
1889
  repositoryDisplay === undefined ||
1890
1890
  manifestDigest === undefined
@@ -1908,7 +1908,7 @@ export async function loadResumableCollectorRun(
1908
1908
  const admitted: AdmittedCollectorInvocation = {
1909
1909
  role: "collector",
1910
1910
  ...resumedBaseAdmitted(loaded),
1911
- prNumber,
1911
+ ...(prNumber === undefined ? {} : { prNumber }),
1912
1912
  repository: parsedRepository,
1913
1913
  ...(loaded.admittedFields.requestManifestPath === undefined
1914
1914
  ? {}
@@ -44,6 +44,7 @@ import {
44
44
  // COMPLIANCE_RESPONSE_ENTRY_TYPE remains for boundRetainedAuditResponse (call/result
45
45
  // interval binding on historical session bytes). Provider-stop retain authority is Sitian.
46
46
  import {
47
+ COLLECTOR_BIND_TARGET_TOOL,
47
48
  COLLECTOR_OBSERVE_TOOL,
48
49
  COLLECTOR_READ_TOOL,
49
50
  COLLECTOR_REQUEST_TOOL,
@@ -375,12 +376,36 @@ export function formatFailureStderrDiagnostic(failure: ControlledFailure): strin
375
376
  return formatCliDiagnostic(boundConciseDiagnostic(oneLine));
376
377
  }
377
378
 
378
- /** Pre-admission structural rejection: stderr only, no run, no Terminal. */
379
+ /**
380
+ * #676 B: one cause face for public stderr — Error.message, else object JSON with
381
+ * status/body/headers when present, else String. Never wash objects to [object Object].
382
+ */
383
+ export function formatErrorCauseDetail(cause: unknown): string {
384
+ if (cause instanceof Error) return cause.message;
385
+ if (typeof cause === "object" && cause !== null) {
386
+ try {
387
+ return JSON.stringify(cause);
388
+ } catch {
389
+ return String(cause);
390
+ }
391
+ }
392
+ return String(cause);
393
+ }
394
+
395
+ /** Pre-admission structural rejection: stderr only, no run, no Terminal. Cause retained when present. */
379
396
  export function presentStructuralRejection(
380
- error: { message: string },
397
+ error: { message: string; cause?: unknown },
381
398
  io: { stderr: (text: string) => void },
382
399
  ): void {
383
- io.stderr(formatCliDiagnostic(error.message));
400
+ let message = error.message;
401
+ const cause = error.cause;
402
+ if (cause !== undefined) {
403
+ const detail = formatErrorCauseDetail(cause);
404
+ if (detail.trim().length > 0) {
405
+ message = `${message}; cause: ${detail}`;
406
+ }
407
+ }
408
+ io.stderr(formatCliDiagnostic(message));
384
409
  }
385
410
 
386
411
  /** ControlledFailure face without admitted-run Terminal (stdout body + stderr line). */
@@ -1520,6 +1545,34 @@ function toolResultText(message: SessionMessage): string {
1520
1545
  .trim();
1521
1546
  }
1522
1547
 
1548
+ /**
1549
+ * #676 D1/J3: durable bind-target rejection on the current attempt → public no_receipt facts.
1550
+ * Callers must learn they need explicit --pr without opening the session 正本.
1551
+ * Authority is the latest bind-target toolResult only — a later success clears an earlier failure.
1552
+ */
1553
+ function extractCollectorTargetBindRejection(
1554
+ entries: readonly SessionEntry[],
1555
+ ): { readonly diagnostic: string; readonly code?: string } | undefined {
1556
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
1557
+ const entry = entries[i];
1558
+ if (entry?.type !== "message") continue;
1559
+ const message = entry.message;
1560
+ if (message?.role !== "toolResult") continue;
1561
+ if (message.toolName !== COLLECTOR_BIND_TARGET_TOOL) continue;
1562
+ // Latest bind result wins: success means the attempt is no longer rejected.
1563
+ if (message.isError !== true) return undefined;
1564
+ const diagnostic = toolResultText(message);
1565
+ if (diagnostic.length === 0) return undefined;
1566
+ const details = message.details;
1567
+ const code =
1568
+ isRecord(details) && typeof details.code === "string" && details.code.trim() !== ""
1569
+ ? details.code
1570
+ : undefined;
1571
+ return code === undefined ? { diagnostic } : { diagnostic, code };
1572
+ }
1573
+ return undefined;
1574
+ }
1575
+
1523
1576
  type BoundErroredToolCandidate = {
1524
1577
  candidate: unknown;
1525
1578
  diagnostic: string;
@@ -1697,7 +1750,9 @@ export function assertCollectorReceiptMatchesAdmitted(
1697
1750
  `Collector receipt repository "${receipt.repository}" does not match admitted repository "${admitted.repository.canonical}"`,
1698
1751
  );
1699
1752
  }
1700
- if (receipt.prNumber !== admitted.prNumber) {
1753
+ // #676 A: when admission left the target unbound, receipt.prNumber is the role bind.
1754
+ // When admission bound explicitly/head-commit, receipt must match.
1755
+ if (admitted.prNumber !== undefined && receipt.prNumber !== admitted.prNumber) {
1701
1756
  throw collectorReceiptBindingFailure(
1702
1757
  `Collector receipt prNumber ${receipt.prNumber} does not match admitted prNumber ${admitted.prNumber}`,
1703
1758
  );
@@ -2787,7 +2842,7 @@ export async function publishCollectorArtifacts(
2787
2842
  {
2788
2843
  runId: admitted.runId,
2789
2844
  role: "collector",
2790
- prNumber: admitted.prNumber,
2845
+ ...(admitted.prNumber === undefined ? {} : { prNumber: admitted.prNumber }),
2791
2846
  repository: admitted.repository.canonical,
2792
2847
  manifestDigest: admitted.manifestDigest,
2793
2848
  sessionDirectory: coordinates.sessionDirectory,
@@ -4178,7 +4233,19 @@ export async function settleFailureTerminalResult(
4178
4233
  try {
4179
4234
  const facts = parseNoReceiptLifecycleFacts(raw);
4180
4235
  if (facts.runPointer === admitted.runDirectory && facts.attemptPointer === `current:${admitted.runDirectory}`) {
4181
- const decisiveFacts: NoReceiptLifecycleFacts = facts;
4236
+ let decisiveFacts: NoReceiptLifecycleFacts & Record<string, unknown> = facts;
4237
+ // #676 D1/J3: project durable bind-target rejection onto public no_receipt facts.
4238
+ if (admitted.role === "collector") {
4239
+ const bindRejection = extractCollectorTargetBindRejection(entries.slice(attemptStart));
4240
+ if (bindRejection !== undefined) {
4241
+ decisiveFacts = {
4242
+ ...facts,
4243
+ targetBindRejected: true,
4244
+ targetBindDiagnostic: bindRejection.diagnostic,
4245
+ ...(bindRejection.code === undefined ? {} : { targetBindCode: bindRejection.code }),
4246
+ };
4247
+ }
4248
+ }
4182
4249
  // #478: no_receipt is still a public Terminal — project accepted gate facts.
4183
4250
  return withOptionalGateProjection(
4184
4251
  {
@@ -4285,6 +4352,15 @@ export function presentFailureTerminal(
4285
4352
  cause: terminal.roleOutcome.cause,
4286
4353
  diagnostic: terminal.roleOutcome.diagnostic,
4287
4354
  }));
4355
+ return;
4356
+ }
4357
+ // #676 D1/J3: no_receipt stays lawful exit 0; surface durable target-bind clarification on stderr.
4358
+ const bindDiagnostic = terminal.roleOutcome.decisiveFacts.targetBindDiagnostic;
4359
+ if (typeof bindDiagnostic === "string" && bindDiagnostic.trim() !== "") {
4360
+ io.stderr(formatFailureStderrDiagnostic({
4361
+ cause: "output",
4362
+ diagnostic: bindDiagnostic,
4363
+ }));
4288
4364
  }
4289
4365
  }
4290
4366
 
@@ -43,12 +43,10 @@ import type { AnyCanonicalSkillBinding } from "./canonical-skill-binding.ts";
43
43
  import type { CollectorClock } from "./collector-evidence.ts";
44
44
  import type { CollectorGitHubTransport } from "./collector-github.ts";
45
45
  import {
46
- COLLECTOR_OBSERVE_TOOL,
47
- COLLECTOR_OUTPUT_TOOL,
48
- COLLECTOR_READ_TOOL,
49
- COLLECTOR_REQUEST_TOOL,
50
- COLLECTOR_WAIT_TOOL,
46
+ COLLECTOR_REQUIRED_TOOLS,
47
+ COLLECTOR_TRANSPORT_FLAGS,
51
48
  createCollectorRoleRuntime,
49
+ type CollectorActivation,
52
50
  } from "./collector-role.ts";
53
51
  import type { ComplianceDecision } from "./compliance-transport.ts";
54
52
  import { createDoctorRoleRuntime } from "./doctor-role.ts";
@@ -449,7 +447,9 @@ type ActivationRuntime = {
449
447
  decodeReviewerAdmitted(): ReviewerAdmittedInputs;
450
448
  /** Envelope stores live parent activation for agent_start prompt assembly. */
451
449
  bindReviewerParent(activation: ReviewerActivation): void;
452
- collector: { activate(context: HostContext, event: { reason: string }): Promise<void> };
450
+ collector: {
451
+ activate(context: HostContext, event: { reason: string }): Promise<void>;
452
+ };
453
453
  doctor: { activate(): Promise<void> };
454
454
  notary: {
455
455
  activate(admitted?: import("./notary-role.ts").NotaryAdmittedTicket): Promise<void>;
@@ -629,7 +629,6 @@ export type RoleRuntimeDependencies = {
629
629
  createMergerGitState?(repositoryRoot: string): MergerRoleDependencies["gitState"];
630
630
  auditDoctorCompliance?(options: { context: HostContext; signal?: AbortSignal }): Promise<ComplianceDecision>;
631
631
  createCollectorClock?(): CollectorClock;
632
- collectorPackageExtensionPath?: string;
633
632
  createNavigatorAttendance?(options: { context: HostContext; role: string; phase: NavigatorPhase; subjectKey: string; subject: string; authority: string; contextError?: unknown; invocationId: string; onEvent: (event: import("./navigator-attendance.ts").NavigatorEvent, report: import("./navigator-attendance.ts").NavigatorReport) => void | Promise<void> }): NavigatorAttendanceDependency | Promise<NavigatorAttendanceDependency>;
634
633
  loadNavigatorWorkContext?(options: { context: HostContext; role: string; phase: NavigatorPhase; getFlag?: (name: string) => unknown }): Promise<NavigatorWorkContext>;
635
634
  loadCanonicalSkillBinding?(
@@ -1144,6 +1143,10 @@ export function createRoleRuntimeExtension(
1144
1143
  for (const flag of GLEANER_LEFT_TRANSPORT_FLAGS) {
1145
1144
  roleHost.registerFlag(flag.name, flag.definition);
1146
1145
  }
1146
+ // Collector transport flags: shared envelope owns registration (ADR 0018 / #676 E).
1147
+ for (const flag of COLLECTOR_TRANSPORT_FLAGS) {
1148
+ roleHost.registerFlag(flag.name, flag.definition);
1149
+ }
1147
1150
 
1148
1151
  let admitted = false;
1149
1152
  let selectedRole: string | undefined;
@@ -1305,10 +1308,46 @@ export function createRoleRuntimeExtension(
1305
1308
  }),
1306
1309
  };
1307
1310
  }
1311
+ // #676 E / J1: collector materials + drift gates share this envelope hook (no parallel register).
1312
+ if (role === "collector" && activeCollector !== undefined) {
1313
+ const options = event.systemPromptOptions;
1314
+ if (options.skills && options.skills.length > 0) {
1315
+ failInfrastructure(
1316
+ activeCollector.ledger.latchFatal("通进司检测到系统提示中的环境 skills"),
1317
+ ctx,
1318
+ );
1319
+ }
1320
+ if (options.contextFiles && options.contextFiles.length > 0) {
1321
+ failInfrastructure(
1322
+ activeCollector.ledger.latchFatal("通进司检测到系统提示中的环境 context files"),
1323
+ ctx,
1324
+ );
1325
+ }
1326
+ if (
1327
+ typeof options.appendSystemPrompt === "string"
1328
+ && options.appendSystemPrompt.trim().length > 0
1329
+ ) {
1330
+ failInfrastructure(
1331
+ activeCollector.ledger.latchFatal("通进司检测到 appendSystemPrompt 漂移"),
1332
+ ctx,
1333
+ );
1334
+ }
1335
+ if (!collectorFirstDispatchDone) {
1336
+ collectorFirstDispatchDone = true;
1337
+ activeCollector.ledger.recordActivation(activeCollector.clock);
1338
+ }
1339
+ return {
1340
+ systemPrompt: collectorBusiness.assembleMaterials(activeCollector, event.systemPrompt),
1341
+ };
1342
+ }
1308
1343
  });
1309
1344
  roleHost.on("tool_result", async (event) => {
1310
1345
  const role = selectedRole;
1311
1346
  if (role === undefined) return;
1347
+ // #676 E / J1: collector operational bookkeeping on the shared tool_result seam.
1348
+ if (role === "collector" && activeCollector !== undefined) {
1349
+ collectorBusiness.onToolResult(activeCollector, event);
1350
+ }
1312
1351
  const pendingInfra = pendingInfrastructureFailures.get(event.toolCallId);
1313
1352
  const isRoleInfrastructureFailure = pendingInfra !== undefined;
1314
1353
  if (pendingInfra !== undefined) pendingInfrastructureFailures.delete(event.toolCallId);
@@ -1439,6 +1478,16 @@ export function createRoleRuntimeExtension(
1439
1478
  priorFetch = undefined;
1440
1479
  fetchWrapped = false;
1441
1480
  }
1481
+ // #676 J4: collector fatal latch must surface nonzero exit on shutdown (envelope-owned).
1482
+ if (
1483
+ selectedRole === "collector"
1484
+ && activeCollector !== undefined
1485
+ && activeCollector.ledger.fatal
1486
+ ) {
1487
+ if (process.exitCode === undefined || process.exitCode === 0) {
1488
+ process.exitCode = 1;
1489
+ }
1490
+ }
1442
1491
  // Flush any still-pending affirmative attendance before teardown. Accepted
1443
1492
  // grace-timeout paths normally emit on agent_settled; abort can skip that hook.
1444
1493
  const presentation = pendingNavigatorPresentation;
@@ -1630,7 +1679,11 @@ export function createRoleRuntimeExtension(
1630
1679
  completedMerge(mergeCommitId, automaticMergeTreeId) { if (!sessionMergerGitState) throw new Error("Merger runtime dependencies are not configured"); return sessionMergerGitState.completedMerge(mergeCommitId, automaticMergeTreeId); },
1631
1680
  },
1632
1681
  }, hostActions);
1633
- const collector = createCollectorRoleRuntime(
1682
+ // #676 E: shared envelope owns collector lifecycle (mode/fork, tool surface,
1683
+ // event gates). Role module supplies business activate/tools/materials only.
1684
+ let activeCollector: CollectorActivation | undefined;
1685
+ let collectorFirstDispatchDone = false;
1686
+ const collectorBusiness = createCollectorRoleRuntime(
1634
1687
  roleHost,
1635
1688
  {
1636
1689
  async loadSoul() {
@@ -1664,14 +1717,114 @@ export function createRoleRuntimeExtension(
1664
1717
  ...(dependencies.createCollectorClock === undefined
1665
1718
  ? {}
1666
1719
  : { createClock: dependencies.createCollectorClock }),
1667
- ...(dependencies.collectorPackageExtensionPath === undefined
1668
- ? {}
1669
- : {
1670
- packageExtensionPath: dependencies.collectorPackageExtensionPath,
1671
- }),
1672
1720
  },
1673
1721
  hostActions,
1674
1722
  );
1723
+ // #676 J1: business tools + tool_call gate register only behind admission/activation.
1724
+ // before_agent_start / tool_result collector branches live on the shared envelope hooks above.
1725
+ //
1726
+ // #676 J4 dispositions for guards removed from the role-private collector module:
1727
+ // - skills/contextFiles/appendSystemPrompt fail-closed → shared before_agent_start (above).
1728
+ // - ambient skill/prompt/template commands → activate (below).
1729
+ // - session_shutdown fatal exitCode → shared session_shutdown (above).
1730
+ // - subsequent-input latchFatal + fixed-kickoff rewrite → intentionally not migrated:
1731
+ // #676 materials-first multi-turn abolished the single-shot fixed kickoff; multi-turn
1732
+ // observe/request/wait requires later inputs. Judge r1: fixed-kickoff equality delete is authorized.
1733
+ // - tool sourceInfo path override check → intentionally not migrated: depended on
1734
+ // packageExtensionPath deleted under ADR 0018 / #676 E envelope ownership; uniqueness
1735
+ // + setActiveTools inventory checks remain on activate.
1736
+ let collectorToolCallRegistered = false;
1737
+ const collector = {
1738
+ async activate(context: HostContext, event: { reason: string }) {
1739
+ activeCollector = undefined;
1740
+ collectorFirstDispatchDone = false;
1741
+ // Envelope-owned mode / fork-reload gates (not role-private lifecycle).
1742
+ if (context.mode !== "print" && context.mode !== "json") {
1743
+ throw new Error(
1744
+ `Collector supports only print or json mode (got ${context.mode})`,
1745
+ );
1746
+ }
1747
+ if (event.reason === "fork" || event.reason === "reload") {
1748
+ throw new Error(
1749
+ `Collector does not support session_start reason ${event.reason}`,
1750
+ );
1751
+ }
1752
+ // #676 J4: ambient skill/prompt/template commands fail closed at activation.
1753
+ const commands = roleHost.getCommands?.() ?? [];
1754
+ const ambientCommands = commands.filter((command) => {
1755
+ const name = command.name.toLowerCase();
1756
+ return (
1757
+ name.includes("skill")
1758
+ || name.includes("prompt")
1759
+ || name.startsWith("template")
1760
+ );
1761
+ });
1762
+ if (ambientCommands.length > 0) {
1763
+ throw new Error(
1764
+ `Collector detected ambient instruction commands: ${
1765
+ ambientCommands.map((c) => c.name).join(", ")
1766
+ }`,
1767
+ );
1768
+ }
1769
+ // Business tools behind admission barrier (inert-without-role invariant).
1770
+ // First activation: fail closed if a required name is already occupied.
1771
+ // Later activations reuse the once-registered tools (registerBusinessTools is idempotent).
1772
+ const preExisting = roleHost.getAllTools();
1773
+ const alreadyRegistered = COLLECTOR_REQUIRED_TOOLS.every((required) =>
1774
+ preExisting.some((tool) => tool.name === required),
1775
+ );
1776
+ if (!alreadyRegistered) {
1777
+ for (const required of COLLECTOR_REQUIRED_TOOLS) {
1778
+ const prior = preExisting.filter((tool) => tool.name === required);
1779
+ if (prior.length > 0) {
1780
+ throw new Error(`Collector required tool name collision: ${required}`);
1781
+ }
1782
+ }
1783
+ }
1784
+ collectorBusiness.registerBusinessTools(() => activeCollector);
1785
+ const allTools = roleHost.getAllTools();
1786
+ for (const required of COLLECTOR_REQUIRED_TOOLS) {
1787
+ const matches = allTools.filter((tool) => tool.name === required);
1788
+ if (matches.length === 0) {
1789
+ throw new Error(`Collector required tool missing: ${required}`);
1790
+ }
1791
+ if (matches.length > 1) {
1792
+ throw new Error(`Collector required tool name collision: ${required}`);
1793
+ }
1794
+ }
1795
+ roleHost.setActiveTools([...COLLECTOR_REQUIRED_TOOLS]);
1796
+ const active = new Set(roleHost.getActiveTools());
1797
+ for (const required of COLLECTOR_REQUIRED_TOOLS) {
1798
+ if (!active.has(required)) {
1799
+ throw new Error(`Collector failed to activate required tool ${required}`);
1800
+ }
1801
+ }
1802
+ for (const name of active) {
1803
+ if (!(COLLECTOR_REQUIRED_TOOLS as readonly string[]).includes(name)) {
1804
+ throw new Error(`Collector active tool surface includes unexpected ${name}`);
1805
+ }
1806
+ }
1807
+ // Seat-scoped tool_call gate — registered only after collector admission (no install-time tool_call).
1808
+ if (!collectorToolCallRegistered) {
1809
+ collectorToolCallRegistered = true;
1810
+ roleHost.on("tool_call", (toolEvent) => {
1811
+ if (activeCollector === undefined || selectedRole !== "collector") return;
1812
+ if (!(COLLECTOR_REQUIRED_TOOLS as readonly string[]).includes(toolEvent.toolName)) {
1813
+ return {
1814
+ block: true,
1815
+ reason: `通进司禁用工具 ${toolEvent.toolName}`,
1816
+ };
1817
+ }
1818
+ return collectorBusiness.onToolCall(activeCollector, toolEvent);
1819
+ });
1820
+ }
1821
+ const activation = await collectorBusiness.activate(context);
1822
+ if (activation.ledger.activationRecorded) {
1823
+ collectorFirstDispatchDone = true;
1824
+ }
1825
+ activeCollector = activation;
1826
+ },
1827
+ };
1675
1828
 
1676
1829
  const clock = dependencies.activationClock ?? (() => new Date().toISOString());
1677
1830
  const writeTrace = dependencies.activationTraceWriter ?? writeActivationTraceRecord;