@akagilnc/pi-workflow-roles 0.1.4800 → 0.1.4870

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 (143) hide show
  1. package/dist/acp-host/production-host.js +19434 -18218
  2. package/dist/analyst-book-key.js +63 -0
  3. package/dist/analyst-cohort.js +167 -0
  4. package/dist/analyst-entry.js +320 -0
  5. package/dist/analyst-index.js +247 -0
  6. package/dist/analyst-ledger.js +561 -0
  7. package/dist/analyst-median.js +16 -0
  8. package/dist/analyst-metric-families/acceptance-success-rework.js +249 -0
  9. package/dist/analyst-metric-families/b2-frame-buckets-actions.js +189 -0
  10. package/dist/analyst-metric-families/gate-cycles.js +77 -0
  11. package/dist/analyst-metric-families/leg-wall-clock.js +56 -0
  12. package/dist/analyst-metric-families/round-timeline.js +126 -0
  13. package/dist/analyst-metric-families.js +23 -0
  14. package/dist/analyst-metric-family.js +11 -0
  15. package/dist/analyst-model-groups.js +135 -0
  16. package/dist/analyst-page.js +182 -0
  17. package/dist/atomic-write.js +23 -0
  18. package/dist/auditor-soul.js +1 -1
  19. package/dist/countersign-contracts.js +0 -2
  20. package/dist/diarist-contracts.js +0 -1
  21. package/dist/doctor-contracts.js +2 -2
  22. package/dist/gatekeeper-role.js +15 -16
  23. package/dist/gleaner-left-contracts.js +0 -2
  24. package/dist/headless-host/production-host.js +16967 -15749
  25. package/dist/host-contracts.js +0 -4
  26. package/dist/inspector-contracts.js +0 -2
  27. package/dist/institutional-resolution.js +4 -2
  28. package/dist/merger-contracts.js +0 -2
  29. package/dist/migrate-book-topology.js +3908 -3089
  30. package/dist/navigator-public-session.js +4 -3
  31. package/dist/notary-contracts.js +0 -2
  32. package/dist/package-contracts/auditor-output.js +0 -1
  33. package/dist/package-contracts/collector-output.js +0 -2
  34. package/dist/package-contracts/fixer-output.js +0 -2
  35. package/dist/package-contracts/gatekeeper-output.js +0 -2
  36. package/dist/package-contracts/judge-output.js +0 -2
  37. package/dist/package-contracts/navigator-output.js +0 -2
  38. package/dist/package-contracts/reviewer-output.js +0 -2
  39. package/dist/package-contracts/terminating-tools.js +32 -92
  40. package/dist/package-contracts/worker-output.js +0 -4
  41. package/dist/packaged-role-registry.js +518 -36
  42. package/dist/pi/role-turn-host.js +2 -1
  43. package/dist/public-cli/analyst-run.js +185 -0
  44. package/dist/public-cli/cli.js +885 -0
  45. package/dist/public-cli/config.js +12 -9
  46. package/dist/public-cli/countersign-run.js +4 -311
  47. package/dist/public-cli/instruction-seat-run.js +628 -158
  48. package/dist/public-cli/invocation.js +806 -1550
  49. package/dist/public-cli/main.js +6962 -9882
  50. package/dist/public-cli/option-definitions.js +51 -99
  51. package/dist/public-cli/post-admission.js +30 -2
  52. package/dist/public-cli/run-lifecycle.js +234 -353
  53. package/dist/public-cli/seat-ticket-binding.js +6 -0
  54. package/dist/public-cli/settlement.js +261 -675
  55. package/dist/public-cli/turn-request.js +76 -0
  56. package/dist/public-role-summons.js +38 -112
  57. package/dist/role-activation-flags.js +23 -45
  58. package/dist/secretariat-contracts.js +0 -2
  59. package/dist/session-identity.js +17 -8
  60. package/dist/session-opening-materials.js +3 -2
  61. package/extensions/role-runtime.ts +6 -80
  62. package/package.json +1 -1
  63. package/souls/coder.md +1 -2
  64. package/src/acp-host/role-turn-host.ts +4 -1
  65. package/src/analyst-metric-families/acceptance-success-rework.ts +3 -2
  66. package/src/auditor-role.ts +1 -2
  67. package/src/auditor-soul.ts +1 -1
  68. package/src/collector-role.ts +2 -2
  69. package/src/countersign-contracts.ts +0 -1
  70. package/src/countersign-role.ts +1 -1
  71. package/src/diarist-contracts.ts +0 -1
  72. package/src/diarist-role.ts +0 -1
  73. package/src/doctor-auditor.ts +1 -2
  74. package/src/doctor-contracts.ts +1 -1
  75. package/src/doctor-role.ts +3 -2
  76. package/src/external-host-turn-loop.ts +1 -1
  77. package/src/factory-board.ts +10 -7
  78. package/src/gatekeeper-role.ts +16 -10
  79. package/src/gleaner-left-contracts.ts +0 -1
  80. package/src/gleaner-left-role.ts +0 -1
  81. package/src/headless-host/role-turn-host.ts +7 -1
  82. package/src/host-contracts.ts +10 -6
  83. package/src/inspector-contracts.ts +0 -1
  84. package/src/inspector-role.ts +0 -2
  85. package/src/institutional-resolution.ts +6 -4
  86. package/src/judge-role.ts +3 -3
  87. package/src/merger-contracts.ts +0 -1
  88. package/src/merger-role.ts +2 -2
  89. package/src/navigator-public-session.ts +7 -6
  90. package/src/navigator-work-context.ts +6 -8
  91. package/src/notary-contracts.ts +0 -1
  92. package/src/notary-role.ts +2 -3
  93. package/src/package-contracts/auditor-output.ts +0 -1
  94. package/src/package-contracts/collector-output.ts +0 -1
  95. package/src/package-contracts/fixer-output.ts +0 -1
  96. package/src/package-contracts/gatekeeper-output.ts +0 -1
  97. package/src/package-contracts/judge-output.ts +0 -1
  98. package/src/package-contracts/navigator-output.ts +0 -1
  99. package/src/package-contracts/reviewer-output.ts +0 -1
  100. package/src/package-contracts/terminating-tools.ts +27 -99
  101. package/src/package-contracts/worker-output.ts +0 -2
  102. package/src/packaged-role-registry.ts +604 -35
  103. package/src/pi/role-turn-host.ts +2 -1
  104. package/src/public-cli/cli.ts +64 -259
  105. package/src/public-cli/config.ts +18 -10
  106. package/src/public-cli/countersign-run.ts +14 -449
  107. package/src/public-cli/instruction-seat-run.ts +758 -277
  108. package/src/public-cli/invocation.ts +1097 -2051
  109. package/src/public-cli/option-definitions.ts +62 -127
  110. package/src/public-cli/post-admission.ts +47 -2
  111. package/src/public-cli/run-lifecycle.ts +303 -613
  112. package/src/public-cli/seat-ticket-binding.ts +6 -0
  113. package/src/public-cli/settlement.ts +399 -1185
  114. package/src/public-cli/turn-request.ts +92 -1
  115. package/src/public-role-summons.ts +44 -128
  116. package/src/reviewer-role.ts +3 -2
  117. package/src/role-activation-flags.ts +20 -41
  118. package/src/role-runtime-dependencies.ts +25 -26
  119. package/src/role-runtime.ts +121 -232
  120. package/src/secretariat-contracts.ts +0 -1
  121. package/src/secretariat-role.ts +0 -2
  122. package/src/session-identity.ts +23 -8
  123. package/src/session-opening-materials.ts +2 -1
  124. package/src/worker-role.ts +3 -4
  125. package/dist/doctor-auditor.js +0 -25
  126. package/dist/public-cli/diarist-run.js +0 -130
  127. package/dist/public-cli/doctor-run.js +0 -87
  128. package/dist/public-cli/inspector-run.js +0 -151
  129. package/dist/public-cli/judge-run.js +0 -114
  130. package/dist/public-cli/notary-run.js +0 -174
  131. package/dist/public-cli/reviewer-run.js +0 -339
  132. package/src/public-cli/coder-run.ts +0 -255
  133. package/src/public-cli/collector-run.ts +0 -189
  134. package/src/public-cli/diarist-run.ts +0 -233
  135. package/src/public-cli/doctor-run.ts +0 -167
  136. package/src/public-cli/fixer-run.ts +0 -265
  137. package/src/public-cli/gleaner-left-run.ts +0 -167
  138. package/src/public-cli/inspector-run.ts +0 -261
  139. package/src/public-cli/judge-run.ts +0 -196
  140. package/src/public-cli/merger-run.ts +0 -294
  141. package/src/public-cli/notary-run.ts +0 -279
  142. package/src/public-cli/reviewer-run.ts +0 -480
  143. package/src/public-cli/secretariat-run.ts +0 -265
@@ -35,6 +35,18 @@ import type {
35
35
  DurablePrincipal,
36
36
  DurablePrincipalAuthority,
37
37
  } from "../host-contracts.ts";
38
+ import type { PackagedRole } from "../packaged-role-registry.ts";
39
+ import {
40
+ packagedAdmittedSubject,
41
+ packagedArgvResult,
42
+ packagedBareToken,
43
+ packagedBaseIsRevision,
44
+ packagedEmitsAuthorityRefs,
45
+ packagedPublicInstructionSubject,
46
+ packagedRoleMetadata,
47
+ packagedStoresSourceRunRaw,
48
+ packagedSubjectChoices,
49
+ } from "../packaged-role-registry.ts";
38
50
  import {
39
51
  isSafePositiveTicketNumber,
40
52
  readBoardTicketNumber,
@@ -93,6 +105,8 @@ import {
93
105
  resolveAnalystMode,
94
106
  type OptionOwner,
95
107
  type PublicOptionDefinition,
108
+ type TakenTypedOption,
109
+ type TypedOptionConsumer,
96
110
  } from "./option-definitions.ts";
97
111
  import type { PublicThinkingLevel } from "./registry.ts";
98
112
 
@@ -794,24 +808,6 @@ export async function recordLaunchedRolePackageIdentity(
794
808
  });
795
809
  }
796
810
 
797
- export type ParseInstructionArgvResult = {
798
- instruction: string;
799
- attachmentPaths: string[];
800
- project?: string;
801
- /** Auditor only — audited subject selecting soul materials (#675 owner). */
802
- subject?: "judge" | "doctor";
803
- /** Auditor source-run locator — same input surface for direct and nested (#675). */
804
- sourceRun?: string;
805
- };
806
-
807
- /** Judge/Countersign 命令面同形:--project/--attach/opaque instruction。 */
808
- export type ParseJudgeArgvResult = ParseInstructionArgvResult;
809
- export type ParseCountersignArgvResult = ParseInstructionArgvResult;
810
- export type ParseInspectorArgvResult = ParseInstructionArgvResult;
811
- export type ParseGatekeeperArgvResult = ParseInstructionArgvResult;
812
- export type ParseNavigatorArgvResult = ParseInstructionArgvResult;
813
- export type ParseDiaristArgvResult = ParseInstructionArgvResult;
814
-
815
811
  /** Positive ticket number for analyst query-scope face (and shared integer parse). */
816
812
  export function parsePositiveTicketNumber(
817
813
  raw: string,
@@ -828,139 +824,269 @@ export function parsePositiveTicketNumber(
828
824
  return value;
829
825
  }
830
826
 
831
- /** 共享解析体:同形 owner 的 argv → instruction/attachments/project。 */
832
- function parseInstructionArgv(
827
+ /**
828
+ * One token scan for public argv.
829
+ * Callers assign values; `--` still ends flag parsing.
830
+ * Analyst keeps its own assignment. Public seats assign in `parsePublicSeatArgv`.
831
+ */
832
+ function scanPublicArgv(
833
833
  args: readonly string[],
834
- owner: "judge" | "countersign" | "inspector" | "gatekeeper" | "navigator" | "auditor" | "diarist" | "secretariat",
835
- ): ParseInstructionArgvResult {
836
- const attachmentPaths: string[] = [];
837
- let project: string | undefined;
838
- let subject: "judge" | "doctor" | undefined;
839
- let sourceRun: string | undefined;
840
- const positional: string[] = [];
834
+ options: TypedOptionConsumer,
835
+ handlers: {
836
+ readonly onDashed: (taken: TakenTypedOption) => void;
837
+ readonly onBare: (token: string) => void;
838
+ readonly onDoubleDash: (rest: readonly string[]) => void;
839
+ },
840
+ ): void {
841
841
  const tokens = [...args];
842
- const definitions = roleOptions(owner);
843
- const options = createTypedOptionConsumer(definitions);
844
-
845
842
  while (tokens.length > 0) {
846
843
  if (tokens[0] === "--") {
847
844
  tokens.shift();
848
- positional.push(...tokens);
849
- break;
845
+ handlers.onDoubleDash(tokens);
846
+ return;
850
847
  }
851
848
  const taken = options.takeDashed(tokens);
852
849
  if (taken !== undefined) {
853
- if (taken.def.id === "attach") {
854
- attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value));
855
- continue;
856
- }
857
- if (taken.def.id === "project") {
858
- project = requireOptionPath(taken.def.canonical, taken.value);
859
- continue;
860
- }
861
- if (taken.def.id === "subject") {
862
- const raw = typeof taken.value === "string" ? taken.value.trim() : "";
863
- if (raw !== "judge" && raw !== "doctor") {
864
- throw new CliUsageError(
865
- `auditor --subject must be judge|doctor, got ${taken.value ?? "(missing)"}`,
866
- );
867
- }
868
- subject = raw;
869
- continue;
870
- }
871
- if (taken.def.id === "source-run") {
872
- const raw = typeof taken.value === "string" ? taken.value.trim() : "";
873
- if (raw === "") {
874
- throw new CliUsageError("auditor --source-run requires a run locator");
875
- }
876
- sourceRun = raw;
877
- continue;
878
- }
879
- throw new CliUsageError(`unknown ${owner} option: ${taken.def.canonical}`);
880
- }
881
- const token = tokens.shift()!;
882
- if (token.startsWith("-") && token !== "-") {
883
- throw new CliUsageError(`unknown ${owner} option: ${token}`);
850
+ handlers.onDashed(taken);
851
+ continue;
884
852
  }
885
- positional.push(token);
853
+ handlers.onBare(tokens.shift()!);
886
854
  }
855
+ }
887
856
 
888
- options.assertRequired();
889
- return {
890
- instruction: positional.join(" "),
891
- attachmentPaths,
892
- ...(project === undefined ? {} : { project }),
893
- ...(subject === undefined ? {} : { subject }),
894
- ...(sourceRun === undefined ? {} : { sourceRun }),
895
- };
857
+ function appendBareInstruction(
858
+ owner: string,
859
+ token: string,
860
+ positional: string[],
861
+ ): void {
862
+ if (token.startsWith("-") && token !== "-") {
863
+ throw new CliUsageError(`unknown ${owner} option: ${token}`);
864
+ }
865
+ positional.push(token);
896
866
  }
897
867
 
898
- export type ParseCoderArgvResult = {
899
- phase: CoderPhase;
900
- instruction: string;
901
- attachmentPaths: string[];
902
- project?: string;
903
- };
868
+ /** LLM public seats. Analyst stays on its own deterministic argv. */
869
+ export type PublicSeatArgvOwner = Exclude<OptionOwner, "global" | "analyst">;
904
870
 
905
- export type ParseFixerArgvResult = {
906
- phase: FixerPhase;
907
- instruction: string;
871
+ type SeatArgvFields = {
908
872
  attachmentPaths: string[];
909
- /** Optional path to structurally valid prerequisite JSON array. */
910
- prerequisitesPath?: string;
911
873
  project?: string;
912
- };
913
-
914
- export type ParseCollectorArgvResult = {
915
- /** Explicit --pr when provided; admission resolves from context when absent (#676 D1). */
874
+ positional: string[];
875
+ prerequisitesPath?: string;
916
876
  prNumber?: number;
917
- instruction: string;
918
- attachmentPaths: string[];
919
- project?: string;
920
877
  repo?: string;
921
878
  requestManifestPath?: string;
922
- /** Optional wait-window ms (#678 D4). */
923
879
  waitWindowMs?: number;
924
- };
925
-
926
- export type ParseDoctorArgvResult = {
927
- issueNumber: number;
928
- /** Optional project-relative retained runs root override. */
880
+ issueNumber?: number;
929
881
  runs?: string;
930
- instruction: string;
931
- attachmentPaths: string[];
932
- project?: string;
933
- };
934
-
935
- export type ParseReviewerArgvResult = {
936
- /** Optional caller prose retained only as admitted provenance. */
937
- instruction: string;
938
- attachmentPaths: string[];
939
- /** Required fixed base revision for the pinned review target. */
940
- baseRevision: string;
941
- /**
942
- * Explicit single-axis override. Omitted public `--lens` is the internal
943
- * parallel two-axis branch mark only — never persisted on an admitted run.
944
- */
882
+ sourceRun?: string;
883
+ baseRevision?: string;
945
884
  lens?: ReviewerLens;
946
- /** Repeatable durable authority references/URLs (exact order preserved; at least one). */
947
885
  authorityRefs: string[];
948
- project?: string;
886
+ subject?: "judge" | "doctor";
949
887
  };
950
888
 
951
- export type ParseGleanerLeftArgvResult = {
952
- /** Optional caller prose; empty is the lawful path (无锚定). Must not carry direction. */
953
- instruction: string;
954
- /** Required comparison-base revision for the unanchored merge-candidate diff. */
955
- baseRevision: string;
956
- project?: string;
957
- };
889
+ function isMergerInternalPacketFace(token: string): boolean {
890
+ return (
891
+ token === "--targetObjectId" || token.startsWith("--targetObjectId=")
892
+ || token === "--sourceObjectId" || token.startsWith("--sourceObjectId=")
893
+ || token === "--expectedConflictPaths" || token.startsWith("--expectedConflictPaths=")
894
+ || token === "--resolutionScope" || token.startsWith("--resolutionScope=")
895
+ );
896
+ }
958
897
 
959
- export type ParseMergerArgvResult = {
960
- instruction: string;
961
- attachmentPaths: string[];
962
- project?: string;
963
- };
898
+ /** One assignment for every public-seat option id. Owner only selects the table. */
899
+ function assignPublicSeatOption(
900
+ owner: PublicSeatArgvOwner,
901
+ taken: TakenTypedOption,
902
+ fields: SeatArgvFields,
903
+ ): void {
904
+ const value = taken.value;
905
+ switch (taken.def.id) {
906
+ case "attach":
907
+ fields.attachmentPaths.push(requireOptionPath(taken.def.canonical, value));
908
+ return;
909
+ case "project":
910
+ fields.project = requireOptionPath(taken.def.canonical, value);
911
+ return;
912
+ case "subject": {
913
+ const raw = typeof value === "string" ? value.trim() : "";
914
+ const subject = packagedAdmittedSubject(owner, raw);
915
+ if (subject === undefined) {
916
+ const allowed = packagedSubjectChoices(owner)?.join("|") ?? "";
917
+ throw new CliUsageError(
918
+ `${owner} --subject must be ${allowed}, got ${value ?? "(missing)"}`,
919
+ );
920
+ }
921
+ fields.subject = subject;
922
+ return;
923
+ }
924
+ case "source-run": {
925
+ const text = typeof value === "string" ? value : "";
926
+ if (text.trim() === "") {
927
+ throw new CliUsageError(`${owner} --source-run requires a run locator`);
928
+ }
929
+ fields.sourceRun = packagedStoresSourceRunRaw(owner) ? text : text.trim();
930
+ return;
931
+ }
932
+ case "base":
933
+ fields.baseRevision = packagedBaseIsRevision(owner)
934
+ ? requireReviewerBaseRevision(value)
935
+ : requireOptionPath(taken.def.canonical, value);
936
+ return;
937
+ case "lens":
938
+ fields.lens = requireReviewerLens(value);
939
+ return;
940
+ case "authority-ref":
941
+ fields.authorityRefs.push(requireAuthorityRef(value));
942
+ return;
943
+ case "prerequisites":
944
+ fields.prerequisitesPath = requireOptionPath(taken.def.canonical, value);
945
+ return;
946
+ case "pr":
947
+ fields.prNumber = parsePositivePrOption(value);
948
+ return;
949
+ case "repo":
950
+ fields.repo = parseRepoOption(value);
951
+ return;
952
+ case "request-manifest":
953
+ fields.requestManifestPath = requireOptionPath(taken.def.canonical, value);
954
+ return;
955
+ case "wait-ms": {
956
+ if (value === undefined || !/^[1-9]\d*$/.test(value.trim())) {
957
+ throw new CliUsageError("--wait-ms requires a positive safe-integer millisecond value");
958
+ }
959
+ const parsed = Number(value.trim());
960
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
961
+ throw new CliUsageError("--wait-ms requires a positive safe-integer millisecond value");
962
+ }
963
+ fields.waitWindowMs = parsed;
964
+ return;
965
+ }
966
+ case "issue": {
967
+ if (value === undefined || value.trim() === "") {
968
+ throw new CliUsageError("doctor --issue requires a positive integer");
969
+ }
970
+ fields.issueNumber = parseDoctorIssueNumber(value);
971
+ return;
972
+ }
973
+ case "runs": {
974
+ if (value === undefined || value.trim() === "") {
975
+ throw new CliUsageError("doctor --runs requires a path");
976
+ }
977
+ fields.runs = value;
978
+ return;
979
+ }
980
+ default:
981
+ throw new CliUsageError(`unknown ${owner} option: ${taken.def.canonical}`);
982
+ }
983
+ }
984
+
985
+ function rejectSeatBareToken(owner: PublicSeatArgvOwner, token: string): void {
986
+ const bare = packagedBareToken(owner);
987
+ if (bare === "burden" && isRejectedPublicSpelling(owner, token)) {
988
+ throw new CliUsageError(
989
+ "judge does not accept a public burden selector; Judge infers its own burden",
990
+ );
991
+ }
992
+ if (
993
+ bare === "packet"
994
+ && (isRejectedPublicSpelling(owner, token) || isMergerInternalPacketFace(token))
995
+ ) {
996
+ throw new CliUsageError(
997
+ "merger does not accept public packet fields; the adapter reads Git merge materials",
998
+ );
999
+ }
1000
+ if (bare === "instruction") {
1001
+ if (token.startsWith("-") && token !== "-") {
1002
+ throw new CliUsageError(`unknown notary option: ${token}`);
1003
+ }
1004
+ throw new CliUsageError(
1005
+ "notary rejects caller prompt/instruction; only --source-run locator is admitted",
1006
+ );
1007
+ }
1008
+ }
1009
+
1010
+ /**
1011
+ * Sole public-seat argv parse. Seat rows differ by the option table and by
1012
+ * which result keys that table produces. Analyst does not enter here.
1013
+ */
1014
+ export function parsePublicSeatArgv(
1015
+ owner: PublicSeatArgvOwner,
1016
+ args: readonly string[],
1017
+ ): PublicSeatParse {
1018
+ if (packagedBareToken(owner) === "burden") {
1019
+ const dd = args.indexOf("--");
1020
+ const preDd = dd === -1 ? args : args.slice(0, dd);
1021
+ for (const token of preDd) {
1022
+ if (isRejectedPublicSpelling(owner, token)) {
1023
+ throw new CliUsageError(
1024
+ "judge does not accept a public burden selector; Judge infers its own burden",
1025
+ );
1026
+ }
1027
+ }
1028
+ }
1029
+ const fields: SeatArgvFields = {
1030
+ attachmentPaths: [],
1031
+ positional: [],
1032
+ authorityRefs: [],
1033
+ };
1034
+ const options = createTypedOptionConsumer(roleOptions(owner));
1035
+ scanPublicArgv(args, options, {
1036
+ onDashed(taken) {
1037
+ assignPublicSeatOption(owner, taken, fields);
1038
+ },
1039
+ onBare(token) {
1040
+ rejectSeatBareToken(owner, token);
1041
+ appendBareInstruction(owner, token, fields.positional);
1042
+ },
1043
+ onDoubleDash(rest) {
1044
+ if (packagedBareToken(owner) === "instruction" && rest.length > 0) {
1045
+ throw new CliUsageError(
1046
+ "notary rejects caller prompt/instruction; only --source-run locator is admitted",
1047
+ );
1048
+ }
1049
+ fields.positional.push(...rest);
1050
+ },
1051
+ });
1052
+
1053
+ const phaseDef = optionsForOwner(owner).find(
1054
+ (def) => def.id === "phase" && def.form === "positional",
1055
+ );
1056
+ const phase = phaseDef === undefined
1057
+ ? undefined
1058
+ : options.consumeLeadingPhase(fields.positional);
1059
+ options.assertRequired();
1060
+
1061
+ const project = fields.project === undefined ? {} : { project: fields.project };
1062
+ if (packagedArgvResult(owner) === "source-run") {
1063
+ const sourceRun = fields.sourceRun;
1064
+ if (sourceRun === undefined || sourceRun.trim() === "") {
1065
+ throw new CliUsageError(`${owner} --source-run requires a run locator`);
1066
+ }
1067
+ return { sourceRun, ...project };
1068
+ }
1069
+ const instruction = fields.positional.join(" ");
1070
+ const material = packagedArgvResult(owner) === "instruction"
1071
+ ? { instruction, ...project }
1072
+ : { instruction, attachmentPaths: fields.attachmentPaths, ...project };
1073
+ return {
1074
+ ...material,
1075
+ ...(phase === undefined ? {} : { phase }),
1076
+ ...(fields.prerequisitesPath === undefined ? {} : { prerequisitesPath: fields.prerequisitesPath }),
1077
+ ...(fields.prNumber === undefined ? {} : { prNumber: fields.prNumber }),
1078
+ ...(fields.repo === undefined ? {} : { repo: fields.repo }),
1079
+ ...(fields.requestManifestPath === undefined ? {} : { requestManifestPath: fields.requestManifestPath }),
1080
+ ...(fields.waitWindowMs === undefined ? {} : { waitWindowMs: fields.waitWindowMs }),
1081
+ ...(fields.issueNumber === undefined ? {} : { issueNumber: fields.issueNumber }),
1082
+ ...(fields.runs === undefined ? {} : { runs: fields.runs }),
1083
+ ...(fields.sourceRun === undefined ? {} : { sourceRun: fields.sourceRun }),
1084
+ ...(fields.baseRevision === undefined ? {} : { baseRevision: fields.baseRevision }),
1085
+ ...(fields.lens === undefined ? {} : { lens: fields.lens }),
1086
+ ...(packagedEmitsAuthorityRefs(owner) ? { authorityRefs: fields.authorityRefs } : {}),
1087
+ ...(fields.subject === undefined ? {} : { subject: fields.subject }),
1088
+ };
1089
+ }
964
1090
 
965
1091
  /**
966
1092
  * #336/#337/#338/#399 analyst public argv — three live faces on one registration seam.
@@ -1100,161 +1226,6 @@ export function requireAuthorityRef(value: string | undefined): string {
1100
1226
  });
1101
1227
  }
1102
1228
 
1103
- /**
1104
- * Parse Judge-specific argv after the `judge` token.
1105
- * Spellings from PUBLIC_OPTION_TABLE.judge; rejects burden family (#342).
1106
- */
1107
- export function parseJudgeArgv(args: readonly string[]): ParseJudgeArgvResult {
1108
- // Judge owns burden inference — rejected spellings checked per-token.
1109
- // `--` ends flag parsing: everything after is opaque instruction.
1110
- const dd = args.indexOf("--");
1111
- const preDd = dd === -1 ? args : args.slice(0, dd);
1112
- for (const token of preDd) {
1113
- if (isRejectedPublicSpelling("judge", token)) {
1114
- throw new CliUsageError(
1115
- "judge does not accept a public burden selector; Judge infers its own burden",
1116
- );
1117
- }
1118
- }
1119
- return parseInstructionArgv(args, "judge");
1120
- }
1121
-
1122
- export function parseCountersignArgv(args: readonly string[]): ParseCountersignArgvResult {
1123
- return parseInstructionArgv(args, "countersign");
1124
- }
1125
-
1126
- export function parseInspectorArgv(args: readonly string[]): ParseInspectorArgvResult {
1127
- return parseInstructionArgv(args, "inspector");
1128
- }
1129
-
1130
- export function parseGatekeeperArgv(args: readonly string[]): ParseGatekeeperArgvResult {
1131
- return parseInstructionArgv(args, "gatekeeper");
1132
- }
1133
-
1134
- export function parseNavigatorArgv(args: readonly string[]): ParseNavigatorArgvResult {
1135
- return parseInstructionArgv(args, "navigator");
1136
- }
1137
-
1138
- export type ParseAuditorArgvResult = ParseInstructionArgvResult;
1139
-
1140
- export function parseAuditorArgv(args: readonly string[]): ParseAuditorArgvResult {
1141
- return parseInstructionArgv(args, "auditor");
1142
- }
1143
-
1144
- export function parseDiaristArgv(args: readonly string[]): ParseDiaristArgvResult {
1145
- return parseInstructionArgv(args, "diarist");
1146
- }
1147
-
1148
- export type ParseSecretariatArgvResult = ParseInstructionArgvResult;
1149
-
1150
- export function parseSecretariatArgv(args: readonly string[]): ParseSecretariatArgvResult {
1151
- return parseInstructionArgv(args, "secretariat");
1152
- }
1153
-
1154
- /**
1155
- * Parse Coder-specific argv after the `coder` token.
1156
- * Phase defaults to apply; spellings from PUBLIC_OPTION_TABLE.coder (#342).
1157
- */
1158
- export function parseCoderArgv(args: readonly string[]): ParseCoderArgvResult {
1159
- const attachmentPaths: string[] = [];
1160
- let project: string | undefined;
1161
- const positional: string[] = [];
1162
- const tokens = [...args];
1163
- const definitions = roleOptions("coder");
1164
- const options = createTypedOptionConsumer(definitions);
1165
-
1166
- while (tokens.length > 0) {
1167
- if (tokens[0] === "--") {
1168
- tokens.shift();
1169
- positional.push(...tokens);
1170
- break;
1171
- }
1172
- const taken = options.takeDashed(tokens);
1173
- if (taken !== undefined) {
1174
- if (taken.def.id === "attach") {
1175
- attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value));
1176
- continue;
1177
- }
1178
- if (taken.def.id === "project") {
1179
- project = requireOptionPath(taken.def.canonical, taken.value);
1180
- continue;
1181
- }
1182
- throw new CliUsageError(`unknown coder option: ${taken.def.canonical}`);
1183
- }
1184
- const token = tokens.shift()!;
1185
- if (token.startsWith("-") && token !== "-") {
1186
- throw new CliUsageError(`unknown coder option: ${token}`);
1187
- }
1188
- positional.push(token);
1189
- }
1190
-
1191
- // Phase aliases + default come solely from the typed coder phase row (#342).
1192
- const phase = options.consumeLeadingPhase(positional);
1193
- options.assertRequired();
1194
-
1195
- return {
1196
- phase,
1197
- instruction: positional.join(" "),
1198
- attachmentPaths,
1199
- ...(project === undefined ? {} : { project }),
1200
- };
1201
- }
1202
-
1203
- /**
1204
- * Parse Fixer-specific argv after the `fixer` token.
1205
- * Phase defaults to apply; spellings from PUBLIC_OPTION_TABLE.fixer (#342).
1206
- */
1207
- export function parseFixerArgv(args: readonly string[]): ParseFixerArgvResult {
1208
- const attachmentPaths: string[] = [];
1209
- let project: string | undefined;
1210
- let prerequisitesPath: string | undefined;
1211
- const positional: string[] = [];
1212
- const tokens = [...args];
1213
- const definitions = roleOptions("fixer");
1214
- const options = createTypedOptionConsumer(definitions);
1215
-
1216
- while (tokens.length > 0) {
1217
- if (tokens[0] === "--") {
1218
- tokens.shift();
1219
- positional.push(...tokens);
1220
- break;
1221
- }
1222
- const taken = options.takeDashed(tokens);
1223
- if (taken !== undefined) {
1224
- if (taken.def.id === "attach") {
1225
- attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value));
1226
- continue;
1227
- }
1228
- if (taken.def.id === "project") {
1229
- project = requireOptionPath(taken.def.canonical, taken.value);
1230
- continue;
1231
- }
1232
- if (taken.def.id === "prerequisites") {
1233
- prerequisitesPath = requireOptionPath(taken.def.canonical, taken.value);
1234
- continue;
1235
- }
1236
- throw new CliUsageError(`unknown fixer option: ${taken.def.canonical}`);
1237
- }
1238
- const token = tokens.shift()!;
1239
- if (token.startsWith("-") && token !== "-") {
1240
- throw new CliUsageError(`unknown fixer option: ${token}`);
1241
- }
1242
- positional.push(token);
1243
- }
1244
-
1245
- // Phase aliases + default come solely from the typed fixer phase row (#342).
1246
- const phase = options.consumeLeadingPhase(positional);
1247
- options.assertRequired();
1248
-
1249
- return {
1250
- phase,
1251
- instruction: positional.join(" "),
1252
- attachmentPaths,
1253
- ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
1254
- ...(project === undefined ? {} : { project }),
1255
- };
1256
- }
1257
-
1258
1229
  async function readRegularFileAttachment(
1259
1230
  sourcePath: string,
1260
1231
  ): Promise<{ absolute: string; bytes: Buffer }> {
@@ -1439,49 +1410,549 @@ function ticketAdmissionFields(
1439
1410
  };
1440
1411
  }
1441
1412
 
1442
- export type AdmitJudgeInvocationOptions = {
1443
- home: string;
1444
- cwd: string;
1445
- instruction: string;
1446
- attachmentPaths: readonly string[];
1447
- project?: string;
1448
- /** Injectable clock/id for tests. */
1449
- createRunId?: () => string;
1450
- principalAuthority: DurablePrincipalAuthority;
1451
- /** Effective model for this invocation — written onto invocation.json. */
1452
- model?: InvocationEffectiveModel;
1453
- };
1413
+ /** Project an already-typed summons ticket into admit options. Absent stays absent. */
1414
+ export function summonedTicketFields(
1415
+ ticketNumber: number | undefined,
1416
+ ): { assertedTicketNumber?: number } {
1417
+ if (ticketNumber === undefined) return {};
1418
+ return { assertedTicketNumber: ticketNumber };
1419
+ }
1454
1420
 
1455
- export type AdmitInspectorInvocationOptions = AdmitJudgeInvocationOptions & {
1456
- correlationId?: string;
1457
- /** Typed identity handed off by 起居郎 or inherited from an already-bound run. */
1458
- assertedTicketNumber?: number;
1459
- };
1421
+ /**
1422
+ * Fields every public admit call shares, including the summons ticket.
1423
+ * Seat runners spread this once; they do not restate ticket placement.
1424
+ */
1425
+ export function admissionCallerOptions(env: {
1426
+ readonly home: string;
1427
+ readonly principalAuthority: DurablePrincipalAuthority;
1428
+ readonly cwd: string;
1429
+ readonly createRunId?: () => string;
1430
+ readonly model?: InvocationEffectiveModel;
1431
+ readonly correlationId?: string;
1432
+ readonly boundTicketNumber?: number;
1433
+ }): {
1434
+ readonly home: string;
1435
+ readonly principalAuthority: DurablePrincipalAuthority;
1436
+ readonly cwd: string;
1437
+ readonly createRunId?: () => string;
1438
+ readonly model?: InvocationEffectiveModel;
1439
+ readonly correlationId?: string;
1440
+ readonly assertedTicketNumber?: number;
1441
+ } {
1442
+ return {
1443
+ home: env.home,
1444
+ principalAuthority: env.principalAuthority,
1445
+ cwd: env.cwd,
1446
+ ...(env.createRunId === undefined ? {} : { createRunId: env.createRunId }),
1447
+ ...(env.model === undefined ? {} : { model: env.model }),
1448
+ ...(env.correlationId === undefined || env.correlationId.trim() === ""
1449
+ ? {}
1450
+ : { correlationId: env.correlationId }),
1451
+ ...summonedTicketFields(env.boundTicketNumber),
1452
+ };
1453
+ }
1460
1454
 
1461
- export type AdmitGatekeeperInvocationOptions = AdmitInspectorInvocationOptions;
1462
- export type AdmitNavigatorInvocationOptions = AdmitInspectorInvocationOptions;
1463
- export type AdmitDiaristInvocationOptions = AdmitInspectorInvocationOptions;
1464
- export type AdmitSecretariatInvocationOptions = AdmitInspectorInvocationOptions;
1455
+ /** Parsed public-seat argv fields the single admit path reads. */
1456
+ export type PublicSeatParse = {
1457
+ readonly instruction?: string;
1458
+ readonly attachmentPaths?: readonly string[];
1459
+ readonly project?: string;
1460
+ readonly phase?: "plan" | "apply";
1461
+ readonly prerequisitesPath?: string;
1462
+ readonly prNumber?: number;
1463
+ readonly repo?: string;
1464
+ readonly requestManifestPath?: string;
1465
+ readonly waitWindowMs?: number;
1466
+ readonly issueNumber?: number;
1467
+ readonly runs?: string;
1468
+ readonly sourceRun?: string;
1469
+ readonly baseRevision?: string;
1470
+ readonly lens?: "completeness" | "correctness";
1471
+ readonly authorityRefs?: readonly string[];
1472
+ readonly subject?: "judge" | "doctor";
1473
+ };
1465
1474
 
1466
1475
  /**
1467
- * Shared instruction-seat admission for Judge and Inspector: project check,
1468
- * principal/placement issue, attachment freeze, admitted-request and invocation
1469
- * ledger write. CorrelationId is projected only when supplied (Inspector).
1470
- * Ticket binding is post-admission via shared seat LLM path (#635).
1476
+ * Sole admit call. Kind comes from the composition-root record.
1477
+ * Placement stays ticketAdmissionFields. Seat field checks live in this function only.
1471
1478
  */
1472
- async function admitStandardMaterialInvocation<
1473
- R extends "judge" | "inspector" | "gatekeeper" | "navigator" | "auditor" | "diarist" | "secretariat",
1474
- >(
1479
+ export function admitPublicRole<R extends PackagedRole>(
1475
1480
  role: R,
1476
- options: AdmitInspectorInvocationOptions,
1477
- ): Promise<AdmittedRoleInvocationBase & { readonly role: R }> {
1478
- // Empty project override must not reach resolve("") → cwd (silent default).
1481
+ parsed: PublicSeatParse,
1482
+ env: Parameters<typeof admissionCallerOptions>[0],
1483
+ override?: {
1484
+ readonly assertedTicketNumber?: number;
1485
+ readonly deferPersistence?: boolean;
1486
+ },
1487
+ ): Promise<Extract<AdmittedRoleInvocation, { readonly role: R }>>;
1488
+ export async function admitPublicRole(
1489
+ role: PackagedRole,
1490
+ parsed: PublicSeatParse,
1491
+ env: Parameters<typeof admissionCallerOptions>[0],
1492
+ override?: {
1493
+ readonly assertedTicketNumber?: number;
1494
+ readonly deferPersistence?: boolean;
1495
+ },
1496
+ ): Promise<AdmittedRoleInvocation> {
1497
+ const record = packagedRoleMetadata(role);
1498
+ if (record === undefined) {
1499
+ throw new CliUsageError(`unknown role: ${role}`);
1500
+ }
1501
+ const shared = {
1502
+ ...admissionCallerOptions(env),
1503
+ ...(override?.assertedTicketNumber === undefined
1504
+ ? {}
1505
+ : { assertedTicketNumber: override.assertedTicketNumber }),
1506
+ };
1507
+ const instruction = parsed.instruction ?? "";
1508
+ const attachmentPaths = parsed.attachmentPaths ?? [];
1509
+ const project = parsed.project === undefined ? {} : { project: parsed.project };
1510
+ switch (record.admission) {
1511
+ case "instruction":
1512
+ return admitStandardMaterialInvocation(
1513
+ role as "judge" | "inspector" | "gatekeeper" | "navigator" | "auditor" | "diarist" | "secretariat",
1514
+ {
1515
+ ...shared,
1516
+ instruction,
1517
+ attachmentPaths,
1518
+ ...project,
1519
+ },
1520
+ );
1521
+ case "court-materials":
1522
+ return admitStandardMaterialInvocation("countersign", {
1523
+ ...shared,
1524
+ instruction,
1525
+ attachmentPaths,
1526
+ ...project,
1527
+ ...(override?.deferPersistence === undefined
1528
+ ? {}
1529
+ : { deferPersistence: override.deferPersistence }),
1530
+ });
1531
+ case "worker-task": {
1532
+ if (instruction.trim() === "") {
1533
+ throw new CliUsageError("coder requires a nonblank task instruction");
1534
+ }
1535
+ const phase = parsed.phase ?? "apply";
1536
+ if (!record.phases.some((item) => item === phase)) {
1537
+ throw new CliUsageError("coder phase must be plan or apply");
1538
+ }
1539
+ return admitStandardMaterialInvocation("coder", {
1540
+ ...shared,
1541
+ instruction,
1542
+ attachmentPaths,
1543
+ ...project,
1544
+ placedFields: async (placed) => {
1545
+ const taskPath = join(placed.runDirectory, "task.md");
1546
+ await writeFile(taskPath, instruction, "utf8");
1547
+ return { phase, taskPath };
1548
+ },
1549
+ });
1550
+ }
1551
+ case "worker-packet": {
1552
+ if (parsed.project !== undefined) {
1553
+ requireOptionPath("--project", parsed.project);
1554
+ }
1555
+ if (instruction.trim() === "") {
1556
+ throw new CliUsageError("fixer requires a nonblank repair instruction");
1557
+ }
1558
+ const phase = parsed.phase ?? "apply";
1559
+ if (!record.phases.some((item) => item === phase)) {
1560
+ throw new CliUsageError("fixer phase must be plan or apply");
1561
+ }
1562
+ let prerequisites: readonly FixerPrerequisite[] = Object.freeze([]);
1563
+ let prerequisitesSource: string | undefined;
1564
+ if (parsed.prerequisitesPath !== undefined) {
1565
+ const absolutePrereq = isAbsolute(parsed.prerequisitesPath)
1566
+ ? parsed.prerequisitesPath
1567
+ : resolve(parsed.prerequisitesPath);
1568
+ try {
1569
+ prerequisitesSource = await readFile(absolutePrereq, "utf8");
1570
+ } catch (error) {
1571
+ throw new CliUsageError(
1572
+ `fixer prerequisites path is unreadable: ${parsed.prerequisitesPath}`,
1573
+ { cause: error },
1574
+ );
1575
+ }
1576
+ try {
1577
+ prerequisites = parseFixerPrerequisites(prerequisitesSource);
1578
+ } catch (error) {
1579
+ if (error instanceof FixerPacketValidationError) {
1580
+ throw new CliUsageError(error.message, { cause: error });
1581
+ }
1582
+ throw error;
1583
+ }
1584
+ }
1585
+ return admitStandardMaterialInvocation("fixer", {
1586
+ ...shared,
1587
+ instruction,
1588
+ attachmentPaths,
1589
+ ...project,
1590
+ placedFields: async (placed) => {
1591
+ let prerequisitesPath: string | undefined;
1592
+ if (prerequisitesSource !== undefined) {
1593
+ prerequisitesPath = join(placed.runDirectory, "prerequisites.json");
1594
+ await writeFile(
1595
+ prerequisitesPath,
1596
+ `${JSON.stringify(prerequisites, null, 2)}\n`,
1597
+ "utf8",
1598
+ );
1599
+ }
1600
+ const packetPath = join(placed.runDirectory, "fix-packet.md");
1601
+ await writeFile(packetPath, instruction, "utf8");
1602
+ return {
1603
+ phase,
1604
+ packetPath,
1605
+ prerequisites,
1606
+ ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
1607
+ };
1608
+ },
1609
+ });
1610
+ }
1611
+ case "collect-target": {
1612
+ if (parsed.project !== undefined) {
1613
+ requireOptionPath("--project", parsed.project);
1614
+ }
1615
+ let explicitPrNumber: number | undefined;
1616
+ if (parsed.prNumber !== undefined) {
1617
+ try {
1618
+ explicitPrNumber = parseCollectorPrNumber(parsed.prNumber);
1619
+ } catch (error) {
1620
+ const detail = error instanceof Error ? error.message : String(error);
1621
+ throw new CliUsageError(detail, { cause: error });
1622
+ }
1623
+ }
1624
+ const projectRoot = resolve(parsed.project ?? shared.cwd);
1625
+ let repository: CollectorRepository;
1626
+ if (parsed.repo !== undefined) {
1627
+ try {
1628
+ repository = parseCollectorRepository(parsed.repo);
1629
+ } catch (error) {
1630
+ const detail = error instanceof Error ? error.message : String(error);
1631
+ throw new CliUsageError(detail, { cause: error });
1632
+ }
1633
+ } else {
1634
+ repository = resolveGitHubRemoteRepository(projectRoot);
1635
+ }
1636
+ let manifest = emptyCollectorManifest();
1637
+ let manifestCanonicalJson: string | undefined;
1638
+ if (parsed.requestManifestPath !== undefined) {
1639
+ try {
1640
+ manifest = await loadCollectorManifest(parsed.requestManifestPath);
1641
+ manifestCanonicalJson = manifest.canonicalJson;
1642
+ } catch (error) {
1643
+ throw new CliUsageError(
1644
+ error instanceof Error ? error.message : String(error),
1645
+ { cause: error },
1646
+ );
1647
+ }
1648
+ }
1649
+ const manifestDigest = manifest.digest;
1650
+ const admittedCollector = await admitStandardMaterialInvocation("collector", {
1651
+ ...shared,
1652
+ instruction,
1653
+ attachmentPaths,
1654
+ ...project,
1655
+ placedFields: async (placed) => {
1656
+ // Task materials are frozen before target resolution (#676 A).
1657
+ const target = await resolveCollectorTarget({
1658
+ projectRoot,
1659
+ repository,
1660
+ ...(explicitPrNumber === undefined ? {} : { explicitPrNumber }),
1661
+ });
1662
+ const prNumber = target.kind === "bound" ? target.prNumber : undefined;
1663
+ let requestManifestPath: string | undefined;
1664
+ if (manifestCanonicalJson !== undefined) {
1665
+ requestManifestPath = join(placed.runDirectory, "request-manifest.json");
1666
+ await writeFile(requestManifestPath, manifestCanonicalJson, "utf8");
1667
+ }
1668
+ return {
1669
+ ...(prNumber === undefined ? {} : { prNumber }),
1670
+ repository: repository.canonical,
1671
+ repositoryDisplay: repository.display,
1672
+ ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
1673
+ ...(parsed.waitWindowMs === undefined ? {} : { waitWindowMs: parsed.waitWindowMs }),
1674
+ manifestDigest,
1675
+ };
1676
+ },
1677
+ });
1678
+ return { ...admittedCollector, repository };
1679
+ }
1680
+ case "case-identity": {
1681
+ if (parsed.project !== undefined) {
1682
+ requireOptionPath("--project", parsed.project);
1683
+ }
1684
+ const issueNumber = parsed.issueNumber ?? Number.NaN;
1685
+ if (
1686
+ !Number.isInteger(issueNumber) ||
1687
+ issueNumber < 1 ||
1688
+ !DOCTOR_ISSUE_NUMBER_PATTERN.test(String(issueNumber))
1689
+ ) {
1690
+ throw new CliUsageError(
1691
+ `doctor --issue must be a positive integer, got ${issueNumber}`,
1692
+ );
1693
+ }
1694
+ let frozenAttachments: readonly FrozenAttachment[] = [];
1695
+ const admittedDoctor = await admitStandardMaterialInvocation("doctor", {
1696
+ ...shared,
1697
+ instruction,
1698
+ attachmentPaths: [],
1699
+ freezeAttachments: false,
1700
+ ...project,
1701
+ placedFields: async (placed) => {
1702
+ let caseRunsPath: string;
1703
+ try {
1704
+ caseRunsPath = await resolveDoctorCaseRunsPath({
1705
+ home: shared.home,
1706
+ projectRoot: placed.projectRoot,
1707
+ bookKey: placed.bookKey,
1708
+ issueNumber,
1709
+ ...(parsed.runs === undefined ? {} : { runs: parsed.runs }),
1710
+ });
1711
+ } catch (error) {
1712
+ if (error instanceof CliUsageError) throw error;
1713
+ const detail = error instanceof Error ? error.message : String(error);
1714
+ throw new CliUsageError(detail, { cause: error });
1715
+ }
1716
+ if (parsed.runs === undefined) {
1717
+ ensureRealDirectoryTree(placed.ledgerHome, caseRunsPath);
1718
+ }
1719
+ let caseIdentity: DoctorCaseIdentity;
1720
+ try {
1721
+ const patient = await loadDoctorCase(caseRunsPath);
1722
+ if (patient.identity.issueNumber !== issueNumber) {
1723
+ throw new CliUsageError(
1724
+ `doctor case issue ${patient.identity.issueNumber} does not match --issue ${issueNumber}`,
1725
+ );
1726
+ }
1727
+ caseIdentity = patient.identity;
1728
+ caseRunsPath = await realpath(caseRunsPath);
1729
+ } catch (error) {
1730
+ if (error instanceof CliUsageError) throw error;
1731
+ const detail = error instanceof Error ? error.message : String(error);
1732
+ throw new CliUsageError(
1733
+ `doctor case could not be constructed from retained evidence: ${detail}`,
1734
+ { cause: error },
1735
+ );
1736
+ }
1737
+ frozenAttachments = await freezeAttachments(attachmentPaths, placed.attachmentsDirectory);
1738
+ return {
1739
+ issueNumber,
1740
+ caseRunsPath,
1741
+ caseIdentity,
1742
+ attachments: persistedAttachmentRefs(frozenAttachments),
1743
+ };
1744
+ },
1745
+ });
1746
+ return { ...admittedDoctor, attachments: frozenAttachments };
1747
+ }
1748
+ case "source-locator": {
1749
+ if (parsed.project !== undefined) {
1750
+ requireOptionPath("--project", parsed.project);
1751
+ }
1752
+ const projectRoot = resolve(parsed.project ?? shared.cwd);
1753
+ let sourceRun: NotarySourceRunLocator;
1754
+ try {
1755
+ sourceRun = await resolveNotarySourceRunLocator({
1756
+ projectRoot,
1757
+ sourceRun: parsed.sourceRun ?? "",
1758
+ home: shared.home,
1759
+ });
1760
+ } catch (error) {
1761
+ if (error instanceof NotarySourceRunError) {
1762
+ throw new CliUsageError(error.message, { cause: error });
1763
+ }
1764
+ throw error;
1765
+ }
1766
+ // Board ticket on the source run wins; otherwise the summons ticket. Code does not infer one.
1767
+ const inheritedTicketNumber = await readBoardTicketNumber(sourceRun.runDirectory);
1768
+ const knownTicketNumber = inheritedTicketNumber ?? shared.assertedTicketNumber;
1769
+ return admitStandardMaterialInvocation("notary", {
1770
+ ...shared,
1771
+ ...(knownTicketNumber === undefined
1772
+ ? {}
1773
+ : { assertedTicketNumber: knownTicketNumber }),
1774
+ instruction: "",
1775
+ attachmentPaths: [],
1776
+ ...project,
1777
+ freezeAttachments: false,
1778
+ admittedFields: {
1779
+ sourceRunPath: sourceRun.runDirectory,
1780
+ sourceRun,
1781
+ },
1782
+ });
1783
+ }
1784
+ case "gleaner": {
1785
+ if (parsed.project !== undefined) {
1786
+ requireOptionPath("--project", parsed.project);
1787
+ }
1788
+ const baseRevision = parsed.baseRevision ?? "";
1789
+ if (baseRevision.trim() === "") {
1790
+ throw new CliUsageError("--base requires a nonempty revision");
1791
+ }
1792
+ return admitStandardMaterialInvocation("gleaner-left", {
1793
+ ...shared,
1794
+ instruction,
1795
+ attachmentPaths: [],
1796
+ ...project,
1797
+ freezeAttachments: false,
1798
+ admittedFields: { baseRevision },
1799
+ });
1800
+ }
1801
+ case "review-basis": {
1802
+ const lens = requireReviewerLens(parsed.lens);
1803
+ if (parsed.project !== undefined) {
1804
+ requireOptionPath("--project", parsed.project);
1805
+ }
1806
+ const baseRevision = requireReviewerBaseRevision(parsed.baseRevision);
1807
+ const rawRefs = parsed.authorityRefs ?? [];
1808
+ if (rawRefs.length === 0) {
1809
+ throw new CliUsageError("reviewer requires --authority-ref <ref>");
1810
+ }
1811
+ const authorityRefs = Object.freeze(rawRefs.map((ref) => requireAuthorityRef(ref)));
1812
+ const admittedReviewer = await admitStandardMaterialInvocation("reviewer", {
1813
+ ...shared,
1814
+ instruction,
1815
+ attachmentPaths,
1816
+ ...project,
1817
+ admittedFields: {
1818
+ baseRevision,
1819
+ lens,
1820
+ authorityRefs: [...authorityRefs],
1821
+ },
1822
+ });
1823
+ return { ...admittedReviewer, authorityRefs };
1824
+ }
1825
+ case "merge-envelope": {
1826
+ if (parsed.project !== undefined) {
1827
+ requireOptionPath("--project", parsed.project);
1828
+ }
1829
+ if (instruction.trim() === "") {
1830
+ throw new CliUsageError("merger requires a nonblank task instruction");
1831
+ }
1832
+ const projectRoot = resolve(parsed.project ?? shared.cwd);
1833
+ const derived = await deriveMergerEnvelopeFromActiveMerge(
1834
+ projectRoot,
1835
+ createProductionMergerGitState(projectRoot),
1836
+ );
1837
+ return admitStandardMaterialInvocation("merger", {
1838
+ ...shared,
1839
+ instruction,
1840
+ attachmentPaths,
1841
+ ...project,
1842
+ placedFields: async (placed) => {
1843
+ const targetLabel = derived.targetObjectId === "" ? "(none observed)" : derived.targetObjectId;
1844
+ const sourceLabel = derived.sourceObjectId === "" ? "(none observed)" : derived.sourceObjectId;
1845
+ const mergerInput = validateMergerInput({
1846
+ attemptId: placed.runId,
1847
+ targetObjectId: derived.targetObjectId,
1848
+ sourceObjectId: derived.sourceObjectId,
1849
+ materials: {
1850
+ task: mergerMaterialFromUtf8(instruction),
1851
+ authority: mergerMaterialFromUtf8(instruction),
1852
+ targetIntent: mergerMaterialFromUtf8(
1853
+ `Investigate primary sources for target parent ${targetLabel}. Do not invent intent.`,
1854
+ ),
1855
+ sourceIntent: mergerMaterialFromUtf8(
1856
+ `Investigate primary sources for source parent ${sourceLabel}. Do not invent intent.`,
1857
+ ),
1858
+ },
1859
+ expectedConflictPaths: [...derived.expectedConflictPaths],
1860
+ resolutionScope: [...derived.resolutionScope],
1861
+ authorizedChecks: [],
1862
+ });
1863
+ const mergerInputPath = join(placed.runDirectory, "merger-input.json");
1864
+ await writeFile(
1865
+ mergerInputPath,
1866
+ `${JSON.stringify(mergerInput, null, 2)}\n`,
1867
+ "utf8",
1868
+ );
1869
+ return {
1870
+ mergerInputPath,
1871
+ derived: {
1872
+ targetObjectId: derived.targetObjectId,
1873
+ sourceObjectId: derived.sourceObjectId,
1874
+ expectedConflictPaths: [...derived.expectedConflictPaths],
1875
+ resolutionScope: [...derived.resolutionScope],
1876
+ },
1877
+ };
1878
+ },
1879
+ });
1880
+ }
1881
+ }
1882
+ }
1883
+
1884
+ /** One placement subject: a typed ticket already on the summons, otherwise unbound. */
1885
+ function admissionSubject(ticketNumber: number | undefined): RoleRunSubject {
1886
+ const fields = ticketAdmissionFields(ticketNumber);
1887
+ if (fields.ticketNumber === undefined) return { unbound: true };
1888
+ return { ticketNumber: fields.ticketNumber };
1889
+ }
1890
+
1891
+ export type AdmitJudgeInvocationOptions = {
1892
+ home: string;
1893
+ cwd: string;
1894
+ instruction: string;
1895
+ attachmentPaths: readonly string[];
1896
+ project?: string;
1897
+ /** Injectable clock/id for tests. */
1898
+ createRunId?: () => string;
1899
+ principalAuthority: DurablePrincipalAuthority;
1900
+ /** Effective model for this invocation — written onto invocation.json. */
1901
+ model?: InvocationEffectiveModel;
1902
+ /** Typed ticket already on this summons (起居录 / parent board). Never parsed from prose. */
1903
+ assertedTicketNumber?: number;
1904
+ };
1905
+
1906
+ export type AdmitInspectorInvocationOptions = AdmitJudgeInvocationOptions & {
1907
+ correlationId?: string;
1908
+ /** Typed identity handed off by 起居郎 or inherited from an already-bound run. */
1909
+ assertedTicketNumber?: number;
1910
+ };
1911
+
1912
+ export type AdmitGatekeeperInvocationOptions = AdmitInspectorInvocationOptions;
1913
+ export type AdmitNavigatorInvocationOptions = AdmitInspectorInvocationOptions;
1914
+ export type AdmitDiaristInvocationOptions = AdmitInspectorInvocationOptions;
1915
+ export type AdmitSecretariatInvocationOptions = AdmitInspectorInvocationOptions;
1916
+
1917
+ type PlacedRoleAdmission = {
1918
+ readonly runId: string;
1919
+ readonly bookKey: string;
1920
+ readonly projectRoot: string;
1921
+ readonly runDirectory: string;
1922
+ readonly principal: DurablePrincipal;
1923
+ readonly sessionDirectory: string;
1924
+ readonly sessionFile: string;
1925
+ readonly attachments: readonly FrozenAttachment[];
1926
+ readonly attachmentsDirectory: string;
1927
+ readonly ledgerHome: string;
1928
+ readonly ticketFields: ReturnType<typeof ticketAdmissionFields>;
1929
+ };
1930
+
1931
+ /**
1932
+ * One admission placement (#505): typed ticket already on the summons, otherwise unbound.
1933
+ * Every public seat persists through this function. Seat-only fields are supplied by the caller.
1934
+ */
1935
+ async function placeRoleAdmission(options: {
1936
+ readonly role: AdmittedRoleInvocation["role"];
1937
+ readonly home: string;
1938
+ readonly principalAuthority: DurablePrincipalAuthority;
1939
+ readonly cwd: string;
1940
+ readonly project?: string;
1941
+ readonly createRunId?: () => string;
1942
+ /** Reuse a reserved run id (deferred countersign materialization). */
1943
+ readonly runId?: string;
1944
+ readonly assertedTicketNumber?: number;
1945
+ readonly attachmentPaths: readonly string[];
1946
+ /** Gleaner-left admits no caller attachments. */
1947
+ readonly freezeAttachments?: boolean;
1948
+ /** Countersign reserves coordinates, then materializes after identity lookup. */
1949
+ readonly materialize?: boolean;
1950
+ }): Promise<PlacedRoleAdmission> {
1479
1951
  if (options.project !== undefined) {
1480
1952
  requireOptionPath("--project", options.project);
1481
1953
  }
1482
1954
  const projectRoot = resolve(options.project ?? options.cwd);
1483
- const runId = (options.createRunId ?? uuidv7)();
1484
- // Validate asserted ticket before placement so 0/NaN never become subjects.
1955
+ const runId = options.runId ?? (options.createRunId ?? uuidv7)();
1485
1956
  const ticketFields = ticketAdmissionFields(options.assertedTicketNumber);
1486
1957
  const {
1487
1958
  principal,
@@ -1494,138 +1965,230 @@ async function admitStandardMaterialInvocation<
1494
1965
  } = issueAdmissionPlacement(options.principalAuthority, {
1495
1966
  cwd: projectRoot,
1496
1967
  runId,
1497
- role,
1498
- subject: ticketFields.ticketNumber === undefined
1499
- ? { unbound: true }
1500
- : { ticketNumber: ticketFields.ticketNumber },
1968
+ role: options.role,
1969
+ subject: admissionSubject(options.assertedTicketNumber),
1501
1970
  home: options.home,
1971
+ ...(options.materialize === undefined ? {} : { materialize: options.materialize }),
1972
+ });
1973
+ const attachments = options.freezeAttachments === false
1974
+ ? []
1975
+ : await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
1976
+ return {
1977
+ runId,
1978
+ bookKey,
1979
+ projectRoot,
1980
+ runDirectory,
1981
+ principal,
1982
+ sessionDirectory,
1983
+ sessionFile,
1984
+ attachments,
1985
+ attachmentsDirectory,
1986
+ ledgerHome,
1987
+ ticketFields,
1988
+ };
1989
+ }
1990
+
1991
+ function persistedAttachmentRefs(
1992
+ attachments: readonly FrozenAttachment[],
1993
+ ): ReadonlyArray<{
1994
+ provenancePath: string;
1995
+ frozenPath: string;
1996
+ byteLength: number;
1997
+ sha256: string;
1998
+ mediaKind: FrozenAttachment["mediaKind"];
1999
+ }> {
2000
+ return attachments.map((attachment) => ({
2001
+ provenancePath: attachment.provenancePath,
2002
+ frozenPath: attachment.frozenPath,
2003
+ byteLength: attachment.byteLength,
2004
+ sha256: attachment.sha256,
2005
+ mediaKind: attachment.mediaKind,
2006
+ }));
2007
+ }
2008
+
2009
+ async function persistPlacedAdmission(
2010
+ admitted: {
2011
+ readonly role: AdmittedRoleInvocation["role"];
2012
+ readonly runId: string;
2013
+ readonly bookKey: string;
2014
+ readonly projectRoot: string;
2015
+ readonly runDirectory: string;
2016
+ readonly principal: DurablePrincipal;
2017
+ readonly instruction: string;
2018
+ readonly instructionEmpty: boolean;
2019
+ readonly correlationId?: string;
2020
+ readonly ticketNumber?: number;
2021
+ readonly attachments: ReturnType<typeof persistedAttachmentRefs>;
2022
+ },
2023
+ placed: PlacedRoleAdmission,
2024
+ model: InvocationEffectiveModel | undefined,
2025
+ ): Promise<string> {
2026
+ const admittedRequestPath = join(placed.runDirectory, "admitted-request.json");
2027
+ await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
2028
+ sessionDirectory: placed.sessionDirectory,
2029
+ sessionFile: placed.sessionFile,
1502
2030
  });
2031
+ await writeRoleInvocationLedger(
2032
+ {
2033
+ ...admitted,
2034
+ sessionDirectory: placed.sessionDirectory,
2035
+ sessionFile: placed.sessionFile,
2036
+ },
2037
+ admitted.role,
2038
+ model,
2039
+ );
2040
+ return admittedRequestPath;
2041
+ }
1503
2042
 
1504
- const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
2043
+ /**
2044
+ * Shared admission: project check, placement, attachment freeze,
2045
+ * admitted-request and invocation ledger write.
2046
+ * Countersign passes deferPersistence so same-ticket lookup can reserve coordinates
2047
+ * before materializeCountersignInvocation writes the page.
2048
+ * Seats whose extra facts are known before placement pass them as admittedFields.
2049
+ * Seats whose extra facts depend on the placed run pass placedFields
2050
+ * (coder writes task.md; fixer writes fix-packet.md; collector writes request-manifest.json;
2051
+ * doctor resolves the case, then freezes attachments; merger writes merger-input.json).
2052
+ */
2053
+ async function admitStandardMaterialInvocation<
2054
+ R extends "judge" | "inspector" | "gatekeeper" | "navigator" | "auditor" | "diarist" | "secretariat" | "countersign" | "gleaner-left" | "reviewer" | "notary" | "coder" | "fixer" | "collector" | "doctor" | "merger",
2055
+ Extra extends object = {},
2056
+ >(
2057
+ role: R,
2058
+ options: AdmitInspectorInvocationOptions & {
2059
+ /** Reserve coordinates; skip freeze, placement disk, and admitted-request write. */
2060
+ readonly deferPersistence?: boolean;
2061
+ /** Skip attachment freeze. Gleaner-left admits no caller attachments. */
2062
+ readonly freezeAttachments?: boolean;
2063
+ /** Seat facts already validated by admitPublicRole. */
2064
+ readonly admittedFields?: Extra;
2065
+ /** Seat facts that exist only after placement. Written before the admitted page. */
2066
+ readonly placedFields?: (placed: PlacedRoleAdmission) => Extra | Promise<Extra>;
2067
+ },
2068
+ ): Promise<AdmittedRoleInvocationBase & { readonly role: R } & Extra> {
2069
+ const defer = options.deferPersistence === true;
2070
+ const skipFreeze = defer || options.freezeAttachments === false;
2071
+ const placed = await placeRoleAdmission({
2072
+ role,
2073
+ home: options.home,
2074
+ principalAuthority: options.principalAuthority,
2075
+ cwd: options.cwd,
2076
+ attachmentPaths: options.attachmentPaths,
2077
+ ...(skipFreeze ? { freezeAttachments: false } : {}),
2078
+ ...(defer ? { materialize: false } : {}),
2079
+ ...(options.project === undefined ? {} : { project: options.project }),
2080
+ ...(options.createRunId === undefined ? {} : { createRunId: options.createRunId }),
2081
+ ...(options.assertedTicketNumber === undefined
2082
+ ? {}
2083
+ : { assertedTicketNumber: options.assertedTicketNumber }),
2084
+ });
1505
2085
  const correlationFields =
1506
2086
  options.correlationId === undefined
1507
2087
  ? {}
1508
2088
  : { correlationId: options.correlationId };
1509
-
1510
2089
  const instruction = options.instruction;
1511
2090
  const instructionEmpty = instruction.trim() === "";
2091
+ const placedExtra = options.placedFields === undefined ? undefined : await options.placedFields(placed);
2092
+ const admittedFields = {
2093
+ ...(options.admittedFields ?? ({} as Extra)),
2094
+ ...(placedExtra ?? ({} as Extra)),
2095
+ };
1512
2096
  const admitted = {
1513
2097
  role,
1514
- runId,
1515
- bookKey,
1516
- projectRoot,
1517
- runDirectory,
1518
- principal,
2098
+ runId: placed.runId,
2099
+ bookKey: placed.bookKey,
2100
+ projectRoot: placed.projectRoot,
2101
+ runDirectory: placed.runDirectory,
2102
+ principal: placed.principal,
1519
2103
  ...correlationFields,
1520
2104
  instruction,
1521
2105
  instructionEmpty,
1522
- attachments: attachments.map((a) => ({
1523
- provenancePath: a.provenancePath,
1524
- frozenPath: a.frozenPath,
1525
- byteLength: a.byteLength,
1526
- sha256: a.sha256,
1527
- mediaKind: a.mediaKind,
1528
- })),
1529
- ...ticketFields,
2106
+ ...admittedFields,
2107
+ attachments: persistedAttachmentRefs(placed.attachments),
2108
+ ...placed.ticketFields,
1530
2109
  };
1531
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
1532
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
1533
- sessionDirectory,
1534
- sessionFile,
1535
- });
1536
- await writeRoleInvocationLedger(
1537
- { ...admitted, sessionDirectory, sessionFile },
1538
- admitted.role,
1539
- options.model,
1540
- );
1541
-
2110
+ const admittedRequestPath = defer
2111
+ ? join(placed.runDirectory, "admitted-request.json")
2112
+ : await persistPlacedAdmission(admitted, placed, options.model);
1542
2113
  return {
1543
2114
  role,
1544
- runId,
1545
- bookKey,
1546
- projectRoot,
2115
+ runId: placed.runId,
2116
+ bookKey: placed.bookKey,
2117
+ projectRoot: placed.projectRoot,
1547
2118
  instruction,
1548
2119
  instructionEmpty,
1549
- attachments,
1550
- runDirectory,
1551
- principal,
2120
+ attachments: placed.attachments,
2121
+ runDirectory: placed.runDirectory,
2122
+ principal: placed.principal,
1552
2123
  admittedRequestPath,
1553
2124
  ...correlationFields,
1554
- ...ticketFields,
2125
+ ...placed.ticketFields,
2126
+ ...admittedFields,
1555
2127
  };
1556
2128
  }
1557
2129
 
1558
- /**
1559
- * Atomically admit a Judge Role run: freeze Attachments, persist the request,
1560
- * and reserve session placement under the #78 ledger book.
1561
- */
1562
- export async function admitJudgeInvocation(
1563
- options: AdmitJudgeInvocationOptions,
1564
- ): Promise<AdmittedJudgeInvocation> {
1565
- return admitStandardMaterialInvocation("judge", options);
1566
- }
1567
-
1568
- /**
1569
- * Admit a direct Inspector (台院) run: freeze attachments, persist the request,
1570
- * and reserve session placement. Same instruction-seat face as Judge (#568).
1571
- */
1572
- export async function admitInspectorInvocation(
1573
- options: AdmitInspectorInvocationOptions,
1574
- ): Promise<AdmittedInspectorInvocation> {
1575
- return admitStandardMaterialInvocation("inspector", options);
1576
- }
1577
-
1578
- /**
1579
- * Admit a direct Gatekeeper (门下省) run (#639): freeze attachments, persist
1580
- * the request, reserve session placement — same instruction-seat face.
1581
- */
1582
- export async function admitGatekeeperInvocation(
1583
- options: AdmitGatekeeperInvocationOptions,
1584
- ): Promise<AdmittedGatekeeperInvocation> {
1585
- return admitStandardMaterialInvocation("gatekeeper", options);
1586
- }
1587
-
1588
- /**
1589
- * Admit a direct Navigator (游奕使) run (#639): freeze attachments, persist
1590
- * the request, reserve session placement — same instruction-seat face.
1591
- */
1592
- export async function admitNavigatorInvocation(
1593
- options: AdmitNavigatorInvocationOptions,
1594
- ): Promise<AdmittedNavigatorInvocation> {
1595
- return admitStandardMaterialInvocation("navigator", options);
1596
- }
1597
-
1598
2130
  export type AdmitAuditorInvocationOptions = AdmitInspectorInvocationOptions;
1599
2131
 
1600
- /** Admit a public 审刑院 run (#675). */
1601
- export async function admitAuditorInvocation(
1602
- options: AdmitAuditorInvocationOptions,
1603
- ): Promise<AdmittedAuditorInvocation> {
1604
- return admitStandardMaterialInvocation("auditor", options);
2132
+ type InstructionTransportSource = {
2133
+ readonly role?: string;
2134
+ readonly instruction: string;
2135
+ readonly instructionEmpty: boolean;
2136
+ readonly attachments: readonly { readonly frozenPath: string }[];
2137
+ readonly baseRevision?: string;
2138
+ readonly lens?: ReviewerLens;
2139
+ readonly authorityRefs?: readonly string[];
2140
+ };
2141
+
2142
+ function admittedTransportPromptKind(
2143
+ admitted: InstructionTransportSource,
2144
+ ): "instruction" | "fixed-kickoff" | "baseline" | "skill-args" {
2145
+ if (admitted.role === undefined) return "instruction";
2146
+ const record = packagedRoleMetadata(admitted.role);
2147
+ if (record !== undefined && "transportPrompt" in record) return record.transportPrompt;
2148
+ return "instruction";
1605
2149
  }
1606
2150
 
1607
2151
  /**
1608
- * Admit a direct Diarist (起居郎) run (#708): freeze attachments, persist the
1609
- * request, reserve session placement — same instruction-seat face.
2152
+ * One initial prompt transport. The registry `transportPrompt` leaf selects
2153
+ * a fixed kickoff, a bound baseline, or frozen skill args. Absent means the
2154
+ * caller instruction plus frozen attachment paths.
1610
2155
  */
1611
- export async function admitDiaristInvocation(
1612
- options: AdmitDiaristInvocationOptions,
1613
- ): Promise<AdmittedDiaristInvocation> {
1614
- return admitStandardMaterialInvocation("diarist", options);
1615
- }
1616
-
1617
- /** Admit a public Secretariat (中书省) run (#924). */
1618
- export async function admitSecretariatInvocation(
1619
- options: AdmitSecretariatInvocationOptions,
1620
- ): Promise<AdmittedSecretariatInvocation> {
1621
- return admitStandardMaterialInvocation("secretariat", options);
1622
- }
1623
-
1624
- /** Shared prompt transport for instruction-seat roles (judge/countersign/inspector). */
1625
2156
  export function buildInstructionTransportPrompt(
1626
- admitted: { instruction: string; instructionEmpty: boolean; attachments: readonly { frozenPath: string }[] },
2157
+ admitted: InstructionTransportSource,
1627
2158
  engineMaterial?: EngineSessionMaterial,
1628
2159
  ): string {
2160
+ const kind = admittedTransportPromptKind(admitted);
2161
+ if (kind === "fixed-kickoff") {
2162
+ return appendEngineSessionMaterial([NOTARY_FIXED_KICKOFF], engineMaterial).join("\n");
2163
+ }
2164
+ if (kind === "baseline") {
2165
+ if (admitted.baseRevision === undefined) {
2166
+ throw new Error("baseline transport prompt is missing the bound revision");
2167
+ }
2168
+ const lines = [`左拾遗案已受理。比较基线:${admitted.baseRevision}`];
2169
+ if (!admitted.instructionEmpty) {
2170
+ lines.push("", admitted.instruction);
2171
+ }
2172
+ return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
2173
+ }
2174
+ if (kind === "skill-args") {
2175
+ if (
2176
+ admitted.baseRevision === undefined
2177
+ || admitted.lens === undefined
2178
+ || admitted.authorityRefs === undefined
2179
+ ) {
2180
+ throw new Error("skill-args transport prompt is missing frozen skill args");
2181
+ }
2182
+ const lines = [buildReviewerSkillArgProjection({
2183
+ baseRevision: admitted.baseRevision,
2184
+ lens: admitted.lens,
2185
+ authorityRefs: admitted.authorityRefs,
2186
+ })];
2187
+ if (!admitted.instructionEmpty && admitted.instruction.trim() !== "") {
2188
+ lines.push("", admitted.instruction);
2189
+ }
2190
+ return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
2191
+ }
1629
2192
  const lines: string[] = [admitted.instructionEmpty ? "" : admitted.instruction];
1630
2193
  if (admitted.attachments.length > 0) {
1631
2194
  lines.push("");
@@ -1637,29 +2200,6 @@ export function buildInstructionTransportPrompt(
1637
2200
  return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
1638
2201
  }
1639
2202
 
1640
- /** Build the Pi prompt transport for an admitted Judge request. */
1641
- export function buildJudgeTransportPrompt(
1642
- admitted: AdmittedJudgeInvocation,
1643
- engineMaterial?: EngineSessionMaterial,
1644
- ): string {
1645
- return buildInstructionTransportPrompt(admitted, engineMaterial);
1646
- }
1647
-
1648
- export function buildSecretariatTransportPrompt(
1649
- admitted: AdmittedSecretariatInvocation,
1650
- engineMaterial?: EngineSessionMaterial,
1651
- ): string {
1652
- return buildInstructionTransportPrompt(admitted, engineMaterial);
1653
- }
1654
-
1655
- export function buildInspectorTransportPrompt(
1656
- admitted: AdmittedInspectorInvocation,
1657
- engineMaterial?: EngineSessionMaterial,
1658
- ): string {
1659
- return buildInstructionTransportPrompt(admitted, engineMaterial);
1660
- }
1661
-
1662
-
1663
2203
  export type AdmitCountersignInvocationOptions = {
1664
2204
  home: string;
1665
2205
  cwd: string;
@@ -1674,102 +2214,40 @@ export type AdmitCountersignInvocationOptions = {
1674
2214
  correlationId?: string;
1675
2215
  /** Same-ticket lookup may select an existing run before a new run is persisted. */
1676
2216
  deferPersistence?: boolean;
2217
+ /** Typed ticket already on this summons. Placement uses it; code does not infer one. */
2218
+ assertedTicketNumber?: number;
1677
2219
  };
1678
2220
 
1679
- /**
1680
- * Admit a Countersign run immediately, or reserve its coordinates for deferred
1681
- * materialization after identity lookup (#572 / ADR 0074 / #863).
1682
- */
1683
- export async function admitCountersignInvocation(
1684
- options: AdmitCountersignInvocationOptions,
1685
- ): Promise<AdmittedCountersignInvocation> {
1686
- // Same shared freeze/coordinate body as judge but role: "countersign" so
1687
- // the ledger and session coordinates use the correct role from the start.
1688
- if (options.project !== undefined) {
1689
- requireOptionPath("--project", options.project);
1690
- }
1691
- const projectRoot = resolve(options.project ?? options.cwd);
1692
- const runId = (options.createRunId ?? uuidv7)();
1693
- const {
1694
- principal,
1695
- sessionDirectory,
1696
- sessionFile,
1697
- runDirectory,
1698
- attachmentsDirectory,
1699
- ledgerHome,
1700
- bookKey,
1701
- } = issueAdmissionPlacement(options.principalAuthority, {
1702
- cwd: projectRoot,
1703
- runId,
2221
+ /** Persist a deferred Countersign admission after same-ticket lookup found no retained run. */
2222
+ export async function materializeCountersignInvocation(
2223
+ admitted: AdmittedCountersignInvocation,
2224
+ options: Pick<AdmitCountersignInvocationOptions, "home" | "principalAuthority" | "model"> & {
2225
+ preparedAttachments: readonly PreparedAttachment[];
2226
+ /** Typed ticket known before the deferred run is written. */
2227
+ ticketNumber?: number;
2228
+ },
2229
+ ): Promise<void> {
2230
+ const placed = await placeRoleAdmission({
1704
2231
  role: "countersign",
1705
- subject: { unbound: true },
1706
2232
  home: options.home,
1707
- materialize: options.deferPersistence !== true,
2233
+ principalAuthority: options.principalAuthority,
2234
+ cwd: admitted.projectRoot,
2235
+ attachmentPaths: [],
2236
+ freezeAttachments: false,
2237
+ runId: admitted.runId,
2238
+ ...(options.ticketNumber === undefined ? {} : { assertedTicketNumber: options.ticketNumber }),
1708
2239
  });
1709
-
1710
- const attachments = options.deferPersistence === true
1711
- ? []
1712
- : await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
1713
- // Ticket binding is LLM-only post-admission (#635); admission stays unbound.
1714
-
1715
- const instruction = options.instruction;
1716
- const instructionEmpty = instruction.trim() === "";
1717
- const admitted = {
1718
- role: "countersign" as const,
1719
- runId,
1720
- bookKey,
1721
- projectRoot,
1722
- runDirectory,
1723
- principal,
1724
- ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }),
1725
- instruction,
1726
- instructionEmpty,
1727
- attachments: attachments.map((a) => ({
1728
- provenancePath: a.provenancePath,
1729
- frozenPath: a.frozenPath,
1730
- byteLength: a.byteLength,
1731
- sha256: a.sha256,
1732
- mediaKind: a.mediaKind,
1733
- })),
1734
- };
1735
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
1736
- if (options.deferPersistence !== true) {
1737
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
1738
- sessionDirectory,
1739
- sessionFile,
1740
- });
1741
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
2240
+ const placement = placed;
2241
+ (admitted as { runDirectory: string }).runDirectory = placement.runDirectory;
2242
+ (admitted as { admittedRequestPath: string }).admittedRequestPath = join(
2243
+ placement.runDirectory,
2244
+ "admitted-request.json",
2245
+ );
2246
+ (admitted as { principal: DurablePrincipal }).principal = placement.principal;
2247
+ const ticketFields = ticketAdmissionFields(options.ticketNumber);
2248
+ if (ticketFields.ticketNumber !== undefined) {
2249
+ (admitted as { ticketNumber?: number }).ticketNumber = ticketFields.ticketNumber;
1742
2250
  }
1743
-
1744
- return {
1745
- role: "countersign",
1746
- runId,
1747
- bookKey,
1748
- projectRoot,
1749
- instruction,
1750
- instructionEmpty,
1751
- attachments,
1752
- runDirectory,
1753
- principal,
1754
- admittedRequestPath,
1755
- ...(options.correlationId === undefined ? {} : { correlationId: options.correlationId }),
1756
- };
1757
- }
1758
-
1759
- /** Persist a deferred Countersign admission after same-ticket lookup found no retained run. */
1760
- export async function materializeCountersignInvocation(
1761
- admitted: AdmittedCountersignInvocation,
1762
- options: Pick<AdmitCountersignInvocationOptions, "home" | "principalAuthority" | "model"> & {
1763
- preparedAttachments: readonly PreparedAttachment[];
1764
- },
1765
- ): Promise<void> {
1766
- const placement = issueAdmissionPlacement(options.principalAuthority, {
1767
- cwd: admitted.projectRoot,
1768
- runId: admitted.runId,
1769
- role: "countersign",
1770
- subject: { unbound: true },
1771
- home: options.home,
1772
- });
1773
2251
  const attachments = await freezePreparedAttachmentsIntoRun(
1774
2252
  options.preparedAttachments,
1775
2253
  admitted.runDirectory,
@@ -1786,14 +2264,6 @@ export async function materializeCountersignInvocation(
1786
2264
  );
1787
2265
  }
1788
2266
 
1789
- /** Build the Pi prompt transport for an admitted Countersign request. */
1790
- export function buildCountersignTransportPrompt(
1791
- admitted: AdmittedCountersignInvocation,
1792
- engineMaterial?: EngineSessionMaterial,
1793
- ): string {
1794
- return buildInstructionTransportPrompt(admitted, engineMaterial);
1795
- }
1796
-
1797
2267
  /** Load admitted-request.json written at admission (Navigator work-context seam). */
1798
2268
  export async function loadAdmittedJudgeRequest(
1799
2269
  runDirectory: string,
@@ -1808,7 +2278,7 @@ export async function loadAdmittedJudgeRequest(
1808
2278
  ) as unknown;
1809
2279
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
1810
2280
  const record = raw as Record<string, unknown>;
1811
- if (record.role !== "judge") return undefined;
2281
+ if (!packagedPublicInstructionSubject(record.role)) return undefined;
1812
2282
  if (typeof record.instruction !== "string") return undefined;
1813
2283
  if (typeof record.instructionEmpty !== "boolean") return undefined;
1814
2284
  if (!Array.isArray(record.attachments)) return undefined;
@@ -1830,125 +2300,6 @@ export async function ensureRunArtifactsDir(runDirectory: string): Promise<strin
1830
2300
  );
1831
2301
  }
1832
2302
 
1833
- export type AdmitCoderInvocationOptions = {
1834
- home: string;
1835
- principalAuthority: DurablePrincipalAuthority;
1836
- cwd: string;
1837
- phase: CoderPhase;
1838
- instruction: string;
1839
- attachmentPaths: readonly string[];
1840
- project?: string;
1841
- createRunId?: () => string;
1842
- /** Effective model for this invocation — written onto invocation.json. */
1843
- model?: InvocationEffectiveModel;
1844
- };
1845
-
1846
- /**
1847
- * Admit a Coder Role run on the common Invocation request.
1848
- * Nonblank task remains authoritative: blank instruction is a structural reject.
1849
- * Phase (default apply / explicit plan) is frozen into the admitted request.
1850
- */
1851
- export async function admitCoderInvocation(
1852
- options: AdmitCoderInvocationOptions,
1853
- ): Promise<AdmittedCoderInvocation> {
1854
- if (options.project !== undefined) {
1855
- requireOptionPath("--project", options.project);
1856
- }
1857
- const instruction = options.instruction;
1858
- if (instruction.trim() === "") {
1859
- throw new CliUsageError(
1860
- "coder requires a nonblank task instruction",
1861
- );
1862
- }
1863
- if (options.phase !== "plan" && options.phase !== "apply") {
1864
- throw new CliUsageError("coder phase must be plan or apply");
1865
- }
1866
-
1867
- const projectRoot = resolve(options.project ?? options.cwd);
1868
- const runId = (options.createRunId ?? uuidv7)();
1869
- const {
1870
- principal,
1871
- sessionDirectory,
1872
- sessionFile,
1873
- runDirectory,
1874
- attachmentsDirectory,
1875
- ledgerHome,
1876
- bookKey,
1877
- } = issueAdmissionPlacement(options.principalAuthority, {
1878
- cwd: projectRoot,
1879
- runId,
1880
- role: "coder",
1881
- subject: { unbound: true },
1882
- home: options.home,
1883
- });
1884
-
1885
- const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
1886
-
1887
- const taskPath = join(runDirectory, "task.md");
1888
- await writeFile(taskPath, instruction, "utf8");
1889
-
1890
- const admitted = {
1891
- role: "coder" as const,
1892
- phase: options.phase,
1893
- runId,
1894
- bookKey,
1895
- projectRoot,
1896
- runDirectory,
1897
- principal,
1898
- instruction,
1899
- instructionEmpty: false,
1900
- taskPath,
1901
- attachments: attachments.map((a) => ({
1902
- provenancePath: a.provenancePath,
1903
- frozenPath: a.frozenPath,
1904
- byteLength: a.byteLength,
1905
- sha256: a.sha256,
1906
- mediaKind: a.mediaKind,
1907
- })),
1908
- };
1909
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
1910
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
1911
- sessionDirectory,
1912
- sessionFile,
1913
- });
1914
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
1915
-
1916
- return {
1917
- role: "coder",
1918
- phase: options.phase,
1919
- runId,
1920
- bookKey,
1921
- projectRoot,
1922
- instruction,
1923
- instructionEmpty: false,
1924
- attachments,
1925
- runDirectory,
1926
- principal,
1927
- admittedRequestPath,
1928
- taskPath,
1929
- };
1930
- }
1931
-
1932
- /**
1933
- * Build the Pi prompt transport for an admitted Coder request.
1934
- * Task bytes already live at taskPath for --ak-coder-task; the prompt carries
1935
- * the same instruction plus frozen Attachment paths.
1936
- */
1937
- export function buildCoderTransportPrompt(
1938
- admitted: AdmittedCoderInvocation,
1939
- engineMaterial?: EngineSessionMaterial,
1940
- ): string {
1941
- const lines: string[] = [admitted.instruction];
1942
- if (admitted.attachments.length > 0) {
1943
- lines.push("");
1944
- lines.push("已受理附件(冻结快照路径):");
1945
- for (const attachment of admitted.attachments) {
1946
- lines.push(`- ${attachment.frozenPath}`);
1947
- }
1948
- }
1949
- return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
1950
- }
1951
-
1952
2303
  export type AdmitFixerInvocationOptions = {
1953
2304
  home: string;
1954
2305
  principalAuthority: DurablePrincipalAuthority;
@@ -1962,156 +2313,10 @@ export type AdmitFixerInvocationOptions = {
1962
2313
  createRunId?: () => string;
1963
2314
  /** Effective model for this invocation — written onto invocation.json. */
1964
2315
  model?: InvocationEffectiveModel;
2316
+ /** Typed ticket already on this summons. Placement uses it; code does not infer one. */
2317
+ assertedTicketNumber?: number;
1965
2318
  };
1966
2319
 
1967
- /**
1968
- * Admit a Fixer Role run on the common Invocation request plus optional prerequisites.
1969
- * Nonblank instruction remains authoritative. Phase defaults to apply at parse time.
1970
- * Prerequisite grammar is structural; unmet/insufficient prerequisites stay Fixer judgments.
1971
- */
1972
- export async function admitFixerInvocation(
1973
- options: AdmitFixerInvocationOptions,
1974
- ): Promise<AdmittedFixerInvocation> {
1975
- if (options.project !== undefined) {
1976
- requireOptionPath("--project", options.project);
1977
- }
1978
- const instruction = options.instruction;
1979
- if (instruction.trim() === "") {
1980
- throw new CliUsageError(
1981
- "fixer requires a nonblank repair instruction",
1982
- );
1983
- }
1984
- if (options.phase !== "plan" && options.phase !== "apply") {
1985
- throw new CliUsageError("fixer phase must be plan or apply");
1986
- }
1987
-
1988
- // Validate/read prerequisites before freezing request materials.
1989
- let prerequisites: readonly FixerPrerequisite[] = Object.freeze([]);
1990
- let prerequisitesSource: string | undefined;
1991
- if (options.prerequisitesPath !== undefined) {
1992
- const absolutePrereq = isAbsolute(options.prerequisitesPath)
1993
- ? options.prerequisitesPath
1994
- : resolve(options.prerequisitesPath);
1995
- try {
1996
- prerequisitesSource = await readFile(absolutePrereq, "utf8");
1997
- } catch (error) {
1998
- throw new CliUsageError(
1999
- `fixer prerequisites path is unreadable: ${options.prerequisitesPath}`,
2000
- { cause: error },
2001
- );
2002
- }
2003
- try {
2004
- prerequisites = parseFixerPrerequisites(prerequisitesSource);
2005
- } catch (error) {
2006
- if (error instanceof FixerPacketValidationError) {
2007
- throw new CliUsageError(error.message, { cause: error });
2008
- }
2009
- throw error;
2010
- }
2011
- }
2012
-
2013
- const projectRoot = resolve(options.project ?? options.cwd);
2014
- const runId = (options.createRunId ?? uuidv7)();
2015
- const {
2016
- principal,
2017
- sessionDirectory,
2018
- sessionFile,
2019
- runDirectory,
2020
- attachmentsDirectory,
2021
- ledgerHome,
2022
- bookKey,
2023
- } = issueAdmissionPlacement(options.principalAuthority, {
2024
- cwd: projectRoot,
2025
- runId,
2026
- role: "fixer",
2027
- subject: { unbound: true },
2028
- home: options.home,
2029
- });
2030
-
2031
- const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
2032
-
2033
- let prerequisitesPath: string | undefined;
2034
- if (prerequisitesSource !== undefined) {
2035
- prerequisitesPath = join(runDirectory, "prerequisites.json");
2036
- await writeFile(
2037
- prerequisitesPath,
2038
- `${JSON.stringify(prerequisites, null, 2)}\n`,
2039
- "utf8",
2040
- );
2041
- }
2042
-
2043
- const packetPath = join(runDirectory, "fix-packet.md");
2044
- await writeFile(packetPath, instruction, "utf8");
2045
-
2046
- const admitted = {
2047
- role: "fixer" as const,
2048
- phase: options.phase,
2049
- runId,
2050
- bookKey,
2051
- projectRoot,
2052
- runDirectory,
2053
- principal,
2054
- instruction,
2055
- instructionEmpty: false,
2056
- packetPath,
2057
- ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
2058
- prerequisites: prerequisites.map((entry) => ({
2059
- id: entry.id,
2060
- requirement: entry.requirement,
2061
- })),
2062
- attachments: attachments.map((a) => ({
2063
- provenancePath: a.provenancePath,
2064
- frozenPath: a.frozenPath,
2065
- byteLength: a.byteLength,
2066
- sha256: a.sha256,
2067
- mediaKind: a.mediaKind,
2068
- })),
2069
- };
2070
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
2071
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
2072
- sessionDirectory,
2073
- sessionFile,
2074
- });
2075
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
2076
-
2077
- return {
2078
- role: "fixer",
2079
- phase: options.phase,
2080
- runId,
2081
- bookKey,
2082
- projectRoot,
2083
- instruction,
2084
- instructionEmpty: false,
2085
- attachments,
2086
- runDirectory,
2087
- principal,
2088
- admittedRequestPath,
2089
- packetPath,
2090
- ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
2091
- prerequisites,
2092
- };
2093
- }
2094
-
2095
- /**
2096
- * Build the Pi prompt transport for an admitted Fixer request.
2097
- * Instruction bytes live at packetPath; prerequisites at optional path.
2098
- * Diagnosis method is available via package --skill, not forced into this prompt.
2099
- */
2100
- export function buildFixerTransportPrompt(
2101
- admitted: AdmittedFixerInvocation,
2102
- engineMaterial?: EngineSessionMaterial,
2103
- ): string {
2104
- const lines: string[] = [admitted.instruction];
2105
- if (admitted.attachments.length > 0) {
2106
- lines.push("");
2107
- lines.push("已受理附件(冻结快照路径):");
2108
- for (const attachment of admitted.attachments) {
2109
- lines.push(`- ${attachment.frozenPath}`);
2110
- }
2111
- }
2112
- return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
2113
- }
2114
-
2115
2320
  function parsePositivePrOption(raw: string | undefined): number {
2116
2321
  if (raw === undefined || raw.trim() === "") throw new CliUsageError("--pr requires a positive pull request number");
2117
2322
  try { return parseCollectorPrNumber(raw); } catch (error) { throw new CliUsageError(error instanceof Error ? error.message : String(error), { cause: error }); }
@@ -2120,79 +2325,6 @@ function parseRepoOption(raw: string | undefined): string {
2120
2325
  if (raw === undefined || raw.trim() === "") throw new CliUsageError("--repo requires owner/repo");
2121
2326
  return raw;
2122
2327
  }
2123
- export function parseCollectorArgv(args: readonly string[]): ParseCollectorArgvResult {
2124
- // Spellings from PUBLIC_OPTION_TABLE.collector (#342).
2125
- const attachmentPaths: string[] = [];
2126
- let project: string | undefined;
2127
- let repo: string | undefined;
2128
- let prNumber: number | undefined;
2129
- let requestManifestPath: string | undefined;
2130
- let waitWindowMs: number | undefined;
2131
- const positional: string[] = [];
2132
- const tokens = [...args];
2133
- const definitions = roleOptions("collector");
2134
- const options = createTypedOptionConsumer(definitions);
2135
- while (tokens.length > 0) {
2136
- if (tokens[0] === "--") {
2137
- tokens.shift();
2138
- positional.push(...tokens);
2139
- break;
2140
- }
2141
- const taken = options.takeDashed(tokens);
2142
- if (taken !== undefined) {
2143
- if (taken.def.id === "attach") {
2144
- attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value));
2145
- continue;
2146
- }
2147
- if (taken.def.id === "project") {
2148
- project = requireOptionPath(taken.def.canonical, taken.value);
2149
- continue;
2150
- }
2151
- if (taken.def.id === "pr") {
2152
- prNumber = parsePositivePrOption(taken.value);
2153
- continue;
2154
- }
2155
- if (taken.def.id === "repo") {
2156
- repo = parseRepoOption(taken.value);
2157
- continue;
2158
- }
2159
- if (taken.def.id === "request-manifest") {
2160
- requestManifestPath = requireOptionPath(taken.def.canonical, taken.value);
2161
- continue;
2162
- }
2163
- if (taken.def.id === "wait-ms") {
2164
- if (taken.value === undefined || !/^[1-9]\d*$/.test(taken.value.trim())) {
2165
- throw new CliUsageError("--wait-ms requires a positive safe-integer millisecond value");
2166
- }
2167
- const parsed = Number(taken.value.trim());
2168
- if (!Number.isSafeInteger(parsed) || parsed < 1) {
2169
- throw new CliUsageError("--wait-ms requires a positive safe-integer millisecond value");
2170
- }
2171
- waitWindowMs = parsed;
2172
- continue;
2173
- }
2174
- throw new CliUsageError(`unknown collector option: ${taken.def.canonical}`);
2175
- }
2176
- const token = tokens.shift()!;
2177
- if (token.startsWith("-") && token !== "-") {
2178
- throw new CliUsageError(`unknown collector option: ${token}`);
2179
- }
2180
- positional.push(token);
2181
- }
2182
- // Unconditional required from typed table via shared consumer (#342).
2183
- // #676 D1: --pr is optional; ambiguous targets reject at admission, not by guessing.
2184
- options.assertRequired();
2185
- return {
2186
- ...(prNumber === undefined ? {} : { prNumber }),
2187
- instruction: positional.join(" "),
2188
- attachmentPaths,
2189
- ...(project === undefined ? {} : { project }),
2190
- ...(repo === undefined ? {} : { repo }),
2191
- ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
2192
- ...(waitWindowMs === undefined ? {} : { waitWindowMs }),
2193
- };
2194
- }
2195
-
2196
2328
  /**
2197
2329
  * Resolve owner/repo from the project's `origin` remote (github.com only).
2198
2330
  * Supports https and SSH GitHub URL shapes; never scrapes instruction prose.
@@ -2250,278 +2382,27 @@ function isGitRemoteMissing(error: unknown): boolean {
2250
2382
  return status === 2;
2251
2383
  }
2252
2384
 
2253
- export type AdmitCollectorInvocationOptions = {
2254
- home: string;
2255
- principalAuthority: DurablePrincipalAuthority;
2256
- cwd: string;
2257
- /** Explicit PR when provided; resolved from context when absent (#676 D1). */
2258
- prNumber?: number;
2259
- instruction?: string;
2260
- attachmentPaths?: readonly string[];
2261
- project?: string;
2262
- /** Explicit owner/repo override; defaults from project origin remote. */
2263
- repo?: string;
2264
- /** Optional public request configuration; copied into the admitted run. */
2265
- requestManifestPath?: string;
2266
- /** Optional wait-window ms (#678 D4). */
2267
- waitWindowMs?: number;
2268
- createRunId?: () => string;
2269
- /** Effective model for this invocation — written onto invocation.json. */
2270
- model?: InvocationEffectiveModel;
2271
- };
2385
+ /** Positive Issue number grammar shared with Doctor case path identity. */
2386
+ const DOCTOR_ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/;
2387
+
2388
+ /** Match retained Doctor case runs roots (ADR 0017 / loadDoctorCase). */
2389
+ /** Canonical Doctor case: `<book>/<ticket>/runs`. Legacy `issues/<n>/runs` is read-only compat. */
2390
+ const DOCTOR_CASE_RUNS_PATH_PATTERN =
2391
+ /\/\.ak-roles\/books\/[^/]+\/(?:issues\/)?([1-9]\d*)\/runs$/;
2272
2392
 
2273
2393
  /**
2274
- * Admit a Collector Role run: assemble the retained leg manifest from typed
2275
- * declarations, resolve repository + PR target (#676 D1), and place the session under #78.
2276
- * Explicit PR is not preflighted for existence; context resolution uses online association.
2394
+ * Parse a positive Issue number for public Doctor admission.
2395
+ * Leading zeros and non-integers are structural rejects.
2277
2396
  */
2278
- export async function admitCollectorInvocation(
2279
- options: AdmitCollectorInvocationOptions,
2280
- ): Promise<AdmittedCollectorInvocation> {
2281
- if (options.project !== undefined) {
2282
- requireOptionPath("--project", options.project);
2283
- }
2284
- let explicitPrNumber: number | undefined;
2285
- if (options.prNumber !== undefined) {
2286
- try {
2287
- explicitPrNumber = parseCollectorPrNumber(options.prNumber);
2288
- } catch (error) {
2289
- const detail = error instanceof Error ? error.message : String(error);
2290
- throw new CliUsageError(detail, { cause: error });
2291
- }
2397
+ export function parseDoctorIssueNumber(raw: string): number {
2398
+ const trimmed = raw.trim();
2399
+ if (!DOCTOR_ISSUE_NUMBER_PATTERN.test(trimmed)) {
2400
+ throw new CliUsageError(
2401
+ `doctor --issue must be a positive integer, got ${raw}`,
2402
+ );
2292
2403
  }
2293
-
2294
- const projectRoot = resolve(options.project ?? options.cwd);
2295
- let repository: CollectorRepository;
2296
- if (options.repo !== undefined) {
2297
- try {
2298
- repository = parseCollectorRepository(options.repo);
2299
- } catch (error) {
2300
- const detail = error instanceof Error ? error.message : String(error);
2301
- throw new CliUsageError(detail, { cause: error });
2302
- }
2303
- } else {
2304
- repository = resolveGitHubRemoteRepository(projectRoot);
2305
- }
2306
-
2307
- // Validate optional request-manifest before freezing request materials.
2308
- let manifest = emptyCollectorManifest();
2309
- let manifestCanonicalJson: string | undefined;
2310
- if (options.requestManifestPath !== undefined) {
2311
- try {
2312
- manifest = await loadCollectorManifest(options.requestManifestPath);
2313
- manifestCanonicalJson = manifest.canonicalJson;
2314
- } catch (error) {
2315
- throw new CliUsageError(error instanceof Error ? error.message : String(error), { cause: error });
2316
- }
2317
- }
2318
- const manifestDigest = manifest.digest;
2319
-
2320
- const runId = (options.createRunId ?? uuidv7)();
2321
- const {
2322
- principal,
2323
- sessionDirectory,
2324
- sessionFile,
2325
- runDirectory,
2326
- attachmentsDirectory,
2327
- ledgerHome,
2328
- bookKey,
2329
- } = issueAdmissionPlacement(options.principalAuthority, {
2330
- cwd: projectRoot,
2331
- runId,
2332
- role: "collector",
2333
- subject: { unbound: true },
2334
- home: options.home,
2335
- });
2336
-
2337
- // #676 A: freeze task materials BEFORE target resolution so the role receives
2338
- // real instruction + attachments. Admission binds only explicit --pr or unique
2339
- // head/commit association — task-text scrape is not a target lock.
2340
- const attachments = await freezeAttachments(options.attachmentPaths ?? [], attachmentsDirectory);
2341
- const instruction = options.instruction ?? "";
2342
- const instructionEmpty = instruction.trim() === "";
2343
-
2344
- const target = await resolveCollectorTarget({
2345
- projectRoot,
2346
- repository,
2347
- ...(explicitPrNumber === undefined ? {} : { explicitPrNumber }),
2348
- });
2349
- const prNumber = target.kind === "bound" ? target.prNumber : undefined;
2350
-
2351
- let requestManifestPath: string | undefined;
2352
- if (manifestCanonicalJson !== undefined) {
2353
- requestManifestPath = join(runDirectory, "request-manifest.json");
2354
- await writeFile(requestManifestPath, manifestCanonicalJson, "utf8");
2355
- }
2356
- const admitted = {
2357
- role: "collector" as const,
2358
- runId,
2359
- bookKey,
2360
- projectRoot,
2361
- runDirectory,
2362
- principal,
2363
- instruction,
2364
- instructionEmpty,
2365
- ...(prNumber === undefined ? {} : { prNumber }),
2366
- repository: repository.canonical,
2367
- repositoryDisplay: repository.display,
2368
- ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
2369
- ...(options.waitWindowMs === undefined ? {} : { waitWindowMs: options.waitWindowMs }),
2370
- manifestDigest,
2371
- attachments: attachments.map((a) => ({
2372
- provenancePath: a.provenancePath,
2373
- frozenPath: a.frozenPath,
2374
- byteLength: a.byteLength,
2375
- sha256: a.sha256,
2376
- mediaKind: a.mediaKind,
2377
- })),
2378
- };
2379
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
2380
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
2381
- sessionDirectory,
2382
- sessionFile,
2383
- });
2384
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
2385
-
2386
- return {
2387
- role: "collector",
2388
- runId,
2389
- bookKey,
2390
- projectRoot,
2391
- instruction,
2392
- instructionEmpty,
2393
- attachments,
2394
- runDirectory,
2395
- principal,
2396
- admittedRequestPath,
2397
- ...(prNumber === undefined ? {} : { prNumber }),
2398
- repository,
2399
- ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
2400
- ...(options.waitWindowMs === undefined ? {} : { waitWindowMs: options.waitWindowMs }),
2401
- manifestDigest,
2402
- };
2403
- }
2404
-
2405
- /**
2406
- * #676 A: Collector consumes the real call task + frozen attachments so the role
2407
- * can identify issue/PR from materials via ak_collector_bind_target. Explicit --pr
2408
- * still wins at admission; unique head/commit association also binds. No fixed
2409
- * kickoff rewrite of the caller task; no mechanical task-text target lock.
2410
- */
2411
- export function buildCollectorTransportPrompt(
2412
- admitted: AdmittedCollectorInvocation,
2413
- engineMaterial?: EngineSessionMaterial,
2414
- ): string {
2415
- return buildInstructionTransportPrompt(admitted, engineMaterial);
2416
- }
2417
-
2418
- /** Positive Issue number grammar shared with Doctor case path identity. */
2419
- const DOCTOR_ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/;
2420
-
2421
- /** Match retained Doctor case runs roots (ADR 0017 / loadDoctorCase). */
2422
- /** Canonical Doctor case: `<book>/<ticket>/runs`. Legacy `issues/<n>/runs` is read-only compat. */
2423
- const DOCTOR_CASE_RUNS_PATH_PATTERN =
2424
- /\/\.ak-roles\/books\/[^/]+\/(?:issues\/)?([1-9]\d*)\/runs$/;
2425
-
2426
- /**
2427
- * Parse a positive Issue number for public Doctor admission.
2428
- * Leading zeros and non-integers are structural rejects.
2429
- */
2430
- export function parseDoctorIssueNumber(raw: string): number {
2431
- const trimmed = raw.trim();
2432
- if (!DOCTOR_ISSUE_NUMBER_PATTERN.test(trimmed)) {
2433
- throw new CliUsageError(
2434
- `doctor --issue must be a positive integer, got ${raw}`,
2435
- );
2436
- }
2437
- return Number(trimmed);
2438
- }
2439
-
2440
- /**
2441
- * Parse Doctor-specific argv after the `doctor` token.
2442
- * Requires --issue; optional confined --runs override; common --attach/--project.
2443
- */
2444
- export function parseDoctorArgv(args: readonly string[]): ParseDoctorArgvResult {
2445
- // Spellings from PUBLIC_OPTION_TABLE.doctor (#342).
2446
- const attachmentPaths: string[] = [];
2447
- let project: string | undefined;
2448
- let issueRaw: string | undefined;
2449
- let runs: string | undefined;
2450
- const positional: string[] = [];
2451
- const tokens = [...args];
2452
- const definitions = roleOptions("doctor");
2453
- const options = createTypedOptionConsumer(definitions);
2454
-
2455
- while (tokens.length > 0) {
2456
- if (tokens[0] === "--") {
2457
- tokens.shift();
2458
- positional.push(...tokens);
2459
- break;
2460
- }
2461
- const taken = options.takeDashed(tokens);
2462
- if (taken !== undefined) {
2463
- if (taken.def.id === "issue") {
2464
- if (taken.value === undefined || taken.value.trim() === "") {
2465
- throw new CliUsageError("doctor --issue requires a positive integer");
2466
- }
2467
- issueRaw = taken.value;
2468
- continue;
2469
- }
2470
- if (taken.def.id === "runs") {
2471
- if (taken.value === undefined || taken.value.trim() === "") {
2472
- throw new CliUsageError("doctor --runs requires a path");
2473
- }
2474
- runs = taken.value;
2475
- continue;
2476
- }
2477
- if (taken.def.id === "attach") {
2478
- attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value));
2479
- continue;
2480
- }
2481
- if (taken.def.id === "project") {
2482
- project = requireOptionPath(taken.def.canonical, taken.value);
2483
- continue;
2484
- }
2485
- throw new CliUsageError(`unknown doctor option: ${taken.def.canonical}`);
2486
- }
2487
- const token = tokens.shift()!;
2488
- if (token.startsWith("-") && token !== "-") {
2489
- throw new CliUsageError(`unknown doctor option: ${token}`);
2490
- }
2491
- positional.push(token);
2492
- }
2493
-
2494
- // Unconditional required (e.g. --issue) from typed table via shared consumer (#342).
2495
- options.assertRequired();
2496
- const issueNumber = parseDoctorIssueNumber(issueRaw!);
2497
-
2498
- if (runs !== undefined && runs.trim() === "") {
2499
- throw new CliUsageError("doctor --runs requires a path");
2500
- }
2501
-
2502
- return {
2503
- issueNumber,
2504
- instruction: positional.join(" "),
2505
- attachmentPaths,
2506
- ...(project === undefined ? {} : { project }),
2507
- ...(runs === undefined ? {} : { runs }),
2508
- };
2509
- }
2510
-
2511
- export type AdmitDoctorInvocationOptions = {
2512
- home: string;
2513
- principalAuthority: DurablePrincipalAuthority;
2514
- cwd: string;
2515
- issueNumber: number;
2516
- /** Optional project-relative retained runs root override. */
2517
- runs?: string;
2518
- instruction?: string;
2519
- attachmentPaths?: readonly string[];
2520
- project?: string;
2521
- createRunId?: () => string;
2522
- /** Effective model for this invocation — written onto invocation.json. */
2523
- model?: InvocationEffectiveModel;
2524
- };
2404
+ return Number(trimmed);
2405
+ }
2525
2406
 
2526
2407
  /**
2527
2408
  * Resolve the retained Doctor case runs root from Issue identity.
@@ -2557,563 +2438,43 @@ export async function resolveDoctorCaseRunsPath(options: {
2557
2438
  "doctor --runs must be a project-relative path",
2558
2439
  );
2559
2440
  }
2560
- const resolved = resolve(options.projectRoot, raw);
2561
- if (
2562
- resolved !== options.projectRoot &&
2563
- !pathContainedIn(options.projectRoot, resolved)
2564
- ) {
2565
- throw new CliUsageError(
2566
- "doctor --runs escapes the project root",
2567
- );
2568
- }
2569
-
2570
- let real: string;
2571
- try {
2572
- real = await realpath(resolved);
2573
- } catch (error) {
2574
- const detail = error instanceof Error ? error.message : String(error);
2575
- throw new CliUsageError(
2576
- `doctor --runs is not a readable retained runs root: ${detail}`,
2577
- { cause: error },
2578
- );
2579
- }
2580
-
2581
- const normalized = real.split(sep).join("/");
2582
- const match = normalized.match(DOCTOR_CASE_RUNS_PATH_PATTERN);
2583
- if (!match) {
2584
- throw new CliUsageError(
2585
- "doctor --runs must be an .ak-roles/books/<book>/<n>/runs directory",
2586
- );
2587
- }
2588
- if (Number(match[1]) !== options.issueNumber) {
2589
- throw new CliUsageError(
2590
- `doctor --runs issue ${match[1]} does not match --issue ${options.issueNumber}`,
2591
- );
2592
- }
2593
- return real;
2594
- }
2595
-
2596
- /**
2597
- * Admit a Doctor Role run: resolve Issue → retained runs root via #78 (or a
2598
- * confined override), construct the structurally exact case identity through
2599
- * loadDoctorCase, and place the Doctor session under the book runs lane.
2600
- * Does not copy session content into a second store.
2601
- */
2602
- export async function admitDoctorInvocation(
2603
- options: AdmitDoctorInvocationOptions,
2604
- ): Promise<AdmittedDoctorInvocation> {
2605
- if (options.project !== undefined) {
2606
- requireOptionPath("--project", options.project);
2607
- }
2608
- if (
2609
- !Number.isInteger(options.issueNumber) ||
2610
- options.issueNumber < 1 ||
2611
- !DOCTOR_ISSUE_NUMBER_PATTERN.test(String(options.issueNumber))
2612
- ) {
2613
- throw new CliUsageError(
2614
- `doctor --issue must be a positive integer, got ${options.issueNumber}`,
2615
- );
2616
- }
2617
-
2618
- const projectRoot = resolve(options.project ?? options.cwd);
2619
- const runId = (options.createRunId ?? uuidv7)();
2620
- const {
2621
- principal,
2622
- sessionDirectory,
2623
- sessionFile,
2624
- runDirectory,
2625
- attachmentsDirectory,
2626
- ledgerHome,
2627
- bookKey,
2628
- } = issueAdmissionPlacement(options.principalAuthority, {
2629
- cwd: projectRoot,
2630
- runId,
2631
- role: "doctor",
2632
- subject: { unbound: true },
2633
- home: options.home,
2634
- });
2635
-
2636
- let caseRunsPath: string;
2637
- try {
2638
- caseRunsPath = await resolveDoctorCaseRunsPath({
2639
- home: options.home,
2640
- projectRoot,
2641
- bookKey,
2642
- issueNumber: options.issueNumber,
2643
- ...(options.runs === undefined ? {} : { runs: options.runs }),
2644
- });
2645
- } catch (error) {
2646
- if (error instanceof CliUsageError) throw error;
2647
- const detail = error instanceof Error ? error.message : String(error);
2648
- throw new CliUsageError(detail, { cause: error });
2649
- }
2650
-
2651
- // Default #78 locator may not exist yet — ensure the empty runs root so
2652
- // loadDoctorCase can form an empty case and Doctor's refusal owns insufficiency.
2653
- if (options.runs === undefined) {
2654
- ensureRealDirectoryTree(ledgerHome, caseRunsPath);
2655
- }
2656
-
2657
- let caseIdentity: DoctorCaseIdentity;
2658
- try {
2659
- const patient = await loadDoctorCase(caseRunsPath);
2660
- if (patient.identity.issueNumber !== options.issueNumber) {
2661
- throw new CliUsageError(
2662
- `doctor case issue ${patient.identity.issueNumber} does not match --issue ${options.issueNumber}`,
2663
- );
2664
- }
2665
- caseIdentity = patient.identity;
2666
- caseRunsPath = await realpath(caseRunsPath);
2667
- } catch (error) {
2668
- if (error instanceof CliUsageError) throw error;
2669
- const detail = error instanceof Error ? error.message : String(error);
2670
- throw new CliUsageError(
2671
- `doctor case could not be constructed from retained evidence: ${detail}`,
2672
- { cause: error },
2673
- );
2674
- }
2675
-
2676
-
2677
- const attachments = await freezeAttachments(options.attachmentPaths ?? [], attachmentsDirectory);
2678
-
2679
- const instruction = options.instruction ?? "";
2680
- const instructionEmpty = instruction.trim() === "";
2681
- const admitted = {
2682
- role: "doctor" as const,
2683
- runId,
2684
- bookKey,
2685
- projectRoot,
2686
- runDirectory,
2687
- principal,
2688
- instruction,
2689
- instructionEmpty,
2690
- issueNumber: options.issueNumber,
2691
- caseRunsPath,
2692
- caseIdentity,
2693
- attachments: attachments.map((a) => ({
2694
- provenancePath: a.provenancePath,
2695
- frozenPath: a.frozenPath,
2696
- byteLength: a.byteLength,
2697
- sha256: a.sha256,
2698
- mediaKind: a.mediaKind,
2699
- })),
2700
- };
2701
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
2702
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
2703
- sessionDirectory,
2704
- sessionFile,
2705
- });
2706
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
2707
-
2708
- return {
2709
- role: "doctor",
2710
- runId,
2711
- bookKey,
2712
- projectRoot,
2713
- instruction,
2714
- instructionEmpty,
2715
- attachments,
2716
- runDirectory,
2717
- principal,
2718
- admittedRequestPath,
2719
- issueNumber: options.issueNumber,
2720
- caseRunsPath,
2721
- caseIdentity,
2722
- };
2723
- }
2724
-
2725
- /** Build the Pi prompt transport for an admitted Doctor request. */
2726
- export function buildDoctorTransportPrompt(
2727
- admitted: AdmittedDoctorInvocation,
2728
- engineMaterial?: EngineSessionMaterial,
2729
- ): string {
2730
- const lines: string[] = [admitted.instructionEmpty ? "" : admitted.instruction];
2731
- if (admitted.attachments.length > 0) {
2732
- lines.push("");
2733
- lines.push("已受理附件(冻结快照路径):");
2734
- for (const attachment of admitted.attachments) {
2735
- lines.push(`- ${attachment.frozenPath}`);
2736
- }
2737
- }
2738
- return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
2739
- }
2740
-
2741
- export type ParseNotaryArgvResult = {
2742
- readonly sourceRun: string;
2743
- readonly project?: string;
2744
- };
2745
-
2746
- /**
2747
- * Parse Notary-specific argv after the `notary` token.
2748
- * Input contract = zero prompt, zero attachment projection (#448 / #276).
2749
- */
2750
- export function parseNotaryArgv(args: readonly string[]): ParseNotaryArgvResult {
2751
- let project: string | undefined;
2752
- let sourceRun: string | undefined;
2753
- const tokens = [...args];
2754
- const definitions = roleOptions("notary");
2755
- const options = createTypedOptionConsumer(definitions);
2756
-
2757
- while (tokens.length > 0) {
2758
- if (tokens[0] === "--") {
2759
- tokens.shift();
2760
- if (tokens.length > 0) {
2761
- throw new CliUsageError(
2762
- "notary rejects caller prompt/instruction; only --source-run locator is admitted",
2763
- );
2764
- }
2765
- break;
2766
- }
2767
- const taken = options.takeDashed(tokens);
2768
- if (taken !== undefined) {
2769
- if (taken.def.id === "project") {
2770
- project = requireOptionPath(taken.def.canonical, taken.value);
2771
- continue;
2772
- }
2773
- if (taken.def.id === "source-run") {
2774
- if (taken.value === undefined || taken.value.trim() === "") {
2775
- throw new CliUsageError("notary --source-run requires a run locator");
2776
- }
2777
- sourceRun = taken.value;
2778
- continue;
2779
- }
2780
- throw new CliUsageError(`unknown notary option: ${taken.def.canonical}`);
2781
- }
2782
- const token = tokens.shift()!;
2783
- if (token.startsWith("-") && token !== "-") {
2784
- throw new CliUsageError(`unknown notary option: ${token}`);
2785
- }
2786
- throw new CliUsageError(
2787
- "notary rejects caller prompt/instruction; only --source-run locator is admitted",
2788
- );
2789
- }
2790
-
2791
- options.assertRequired();
2792
- if (sourceRun === undefined || sourceRun.trim() === "") {
2793
- throw new CliUsageError("notary --source-run requires a run locator");
2794
- }
2795
- return {
2796
- sourceRun,
2797
- ...(project === undefined ? {} : { project }),
2798
- };
2799
- }
2800
-
2801
- export async function admitNotaryInvocation(options: {
2802
- readonly home: string;
2803
- readonly principalAuthority: DurablePrincipalAuthority;
2804
- readonly cwd: string;
2805
- readonly sourceRun: string;
2806
- readonly project?: string;
2807
- readonly runs?: string;
2808
- readonly createRunId?: () => string;
2809
- readonly model?: InvocationEffectiveModel;
2810
- readonly correlationId?: string;
2811
- }): Promise<AdmittedNotaryInvocation> {
2812
- if (options.project !== undefined) {
2813
- requireOptionPath("--project", options.project);
2814
- }
2815
- const projectRoot = resolve(options.project ?? options.cwd);
2816
- let sourceRun: NotarySourceRunLocator;
2817
- try {
2818
- sourceRun = await resolveNotarySourceRunLocator({
2819
- projectRoot,
2820
- sourceRun: options.sourceRun,
2821
- home: options.home,
2822
- });
2823
- } catch (error) {
2824
- if (error instanceof NotarySourceRunError) {
2825
- throw new CliUsageError(error.message, { cause: error });
2826
- }
2827
- throw error;
2828
- }
2829
-
2830
- // Notary inherits board identity only — migration-derived stays display/placement.
2831
- const inheritedTicketNumber = await readBoardTicketNumber(sourceRun.runDirectory);
2832
- const runId = options.createRunId?.() ?? uuidv7();
2833
- const {
2834
- principal,
2835
- sessionDirectory,
2836
- sessionFile,
2837
- runDirectory,
2838
- attachmentsDirectory,
2839
- ledgerHome,
2840
- bookKey,
2841
- } = issueAdmissionPlacement(options.principalAuthority, {
2842
- cwd: projectRoot,
2843
- runId,
2844
- role: "notary",
2845
- subject: inheritedTicketNumber === undefined
2846
- ? { unbound: true }
2847
- : { ticketNumber: inheritedTicketNumber },
2848
- home: options.home,
2849
- });
2850
- const ticketFields = ticketAdmissionFields(inheritedTicketNumber);
2851
-
2852
- const admitted = {
2853
- role: "notary" as const,
2854
- runId,
2855
- bookKey,
2856
- projectRoot,
2857
- runDirectory,
2858
- principal,
2859
- instruction: "",
2860
- instructionEmpty: true,
2861
- attachments: [] as const,
2862
- sourceRunPath: sourceRun.runDirectory,
2863
- sourceRun,
2864
- ...ticketFields,
2865
- ...(options.correlationId === undefined
2866
- ? {}
2867
- : { correlationId: options.correlationId }),
2868
- };
2869
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
2870
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
2871
- sessionDirectory,
2872
- sessionFile,
2873
- });
2874
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
2875
-
2876
- return {
2877
- role: "notary",
2878
- runId,
2879
- bookKey,
2880
- projectRoot,
2881
- instruction: "",
2882
- instructionEmpty: true,
2883
- attachments: [],
2884
- runDirectory,
2885
- principal,
2886
- admittedRequestPath,
2887
- sourceRunPath: sourceRun.runDirectory,
2888
- sourceRun,
2889
- ...ticketFields,
2890
- ...(options.correlationId === undefined
2891
- ? {}
2892
- : { correlationId: options.correlationId }),
2893
- };
2894
- }
2895
-
2896
- /** Package-owned fixed kickoff only — never caller instruction/attachments.
2897
- * Ticket rides admitted → activation → agent-start typed bound (not free-text kickoff). */
2898
- export function buildNotaryTransportPrompt(
2899
- _admitted: AdmittedNotaryInvocation,
2900
- engineMaterial?: EngineSessionMaterial,
2901
- ): string {
2902
- return appendEngineSessionMaterial([NOTARY_FIXED_KICKOFF], engineMaterial).join("\n");
2903
- }
2904
-
2905
- /**
2906
- * Parse Gleaner-Left argv after the `gleaner-left` token.
2907
- * Public flags: --project, required --base. No --attach / ticket face (unanchored self-fetch).
2908
- * Instruction may be empty; callers must not pass directional instruction.
2909
- */
2910
- export function parseGleanerLeftArgv(
2911
- args: readonly string[],
2912
- ): ParseGleanerLeftArgvResult {
2913
- let project: string | undefined;
2914
- let baseRevision: string | undefined;
2915
- const positional: string[] = [];
2916
- const tokens = [...args];
2917
- const definitions = roleOptions("gleaner-left");
2918
- const options = createTypedOptionConsumer(definitions);
2919
-
2920
- while (tokens.length > 0) {
2921
- if (tokens[0] === "--") {
2922
- tokens.shift();
2923
- positional.push(...tokens);
2924
- break;
2925
- }
2926
- const taken = options.takeDashed(tokens);
2927
- if (taken !== undefined) {
2928
- if (taken.def.id === "project") {
2929
- project = requireOptionPath(taken.def.canonical, taken.value);
2930
- continue;
2931
- }
2932
- if (taken.def.id === "base") {
2933
- baseRevision = requireOptionPath(taken.def.canonical, taken.value);
2934
- continue;
2935
- }
2936
- throw new CliUsageError(`unknown gleaner-left option: ${taken.def.canonical}`);
2937
- }
2938
- const token = tokens.shift()!;
2939
- if (token.startsWith("-") && token !== "-") {
2940
- throw new CliUsageError(`unknown gleaner-left option: ${token}`);
2941
- }
2942
- positional.push(token);
2943
- }
2944
-
2945
- options.assertRequired();
2946
- return {
2947
- instruction: positional.join(" "),
2948
- baseRevision: baseRevision!,
2949
- ...(project === undefined ? {} : { project }),
2950
- };
2951
- }
2952
-
2953
- export type AdmitGleanerLeftInvocationOptions = {
2954
- home: string;
2955
- principalAuthority: DurablePrincipalAuthority;
2956
- cwd: string;
2957
- instruction: string;
2958
- baseRevision: string;
2959
- project?: string;
2960
- createRunId?: () => string;
2961
- model?: InvocationEffectiveModel;
2962
- correlationId?: string;
2963
- };
2964
-
2965
- /**
2966
- * Admit a Gleaner-Left Role run on the fixed comparison base.
2967
- * Empty instruction is the lawful path; no attachment/ticket admission face.
2968
- */
2969
- export async function admitGleanerLeftInvocation(
2970
- options: AdmitGleanerLeftInvocationOptions,
2971
- ): Promise<AdmittedGleanerLeftInvocation> {
2972
- if (options.project !== undefined) {
2973
- requireOptionPath("--project", options.project);
2974
- }
2975
- if (options.baseRevision.trim() === "") {
2976
- throw new CliUsageError("--base requires a nonempty revision");
2977
- }
2978
-
2979
- const projectRoot = resolve(options.project ?? options.cwd);
2980
- const runId = (options.createRunId ?? uuidv7)();
2981
- const {
2982
- principal,
2983
- sessionDirectory,
2984
- sessionFile,
2985
- runDirectory,
2986
- attachmentsDirectory,
2987
- ledgerHome,
2988
- bookKey,
2989
- } = issueAdmissionPlacement(options.principalAuthority, {
2990
- cwd: projectRoot,
2991
- runId,
2992
- role: "gleaner-left",
2993
- subject: { unbound: true },
2994
- home: options.home,
2995
- });
2996
-
2997
- const instruction = options.instruction;
2998
- const instructionEmpty = instruction.trim() === "";
2999
- const admitted = {
3000
- role: "gleaner-left" as const,
3001
- runId,
3002
- bookKey,
3003
- projectRoot,
3004
- runDirectory,
3005
- principal,
3006
- instruction,
3007
- instructionEmpty,
3008
- baseRevision: options.baseRevision,
3009
- attachments: [] as const,
3010
- ...(options.correlationId === undefined
3011
- ? {}
3012
- : { correlationId: options.correlationId }),
3013
- };
3014
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
3015
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
3016
- sessionDirectory,
3017
- sessionFile,
3018
- });
3019
- await writeRoleInvocationLedger(
3020
- { ...admitted, sessionDirectory, sessionFile },
3021
- admitted.role,
3022
- options.model,
3023
- );
3024
-
3025
- return {
3026
- role: "gleaner-left",
3027
- runId,
3028
- bookKey,
3029
- projectRoot,
3030
- instruction,
3031
- instructionEmpty,
3032
- attachments: [],
3033
- runDirectory,
3034
- principal,
3035
- admittedRequestPath,
3036
- baseRevision: options.baseRevision,
3037
- ...(options.correlationId === undefined
3038
- ? {}
3039
- : { correlationId: options.correlationId }),
3040
- };
3041
- }
3042
-
3043
- /** Bound comparison base is a typed fact, not a directional instruction. */
3044
- export function buildGleanerLeftTransportPrompt(
3045
- admitted: AdmittedGleanerLeftInvocation,
3046
- engineMaterial?: EngineSessionMaterial,
3047
- ): string {
3048
- const lines: string[] = [
3049
- `左拾遗案已受理。比较基线:${admitted.baseRevision}`,
3050
- ];
3051
- if (!admitted.instructionEmpty) {
3052
- lines.push("", admitted.instruction);
3053
- }
3054
- return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
3055
- }
3056
-
3057
- export function parseReviewerArgv(
3058
- args: readonly string[],
3059
- ): ParseReviewerArgvResult {
3060
- // Spellings from PUBLIC_OPTION_TABLE.reviewer (#342). No --attach face.
3061
- const attachmentPaths: string[] = [];
3062
- const authorityRefs: string[] = [];
3063
- let project: string | undefined;
3064
- let baseRevision: string | undefined;
3065
- let lens: ReviewerLens | undefined;
3066
- const positional: string[] = [];
3067
- const tokens = [...args];
3068
- const definitions = roleOptions("reviewer");
3069
- const options = createTypedOptionConsumer(definitions);
2441
+ const resolved = resolve(options.projectRoot, raw);
2442
+ if (
2443
+ resolved !== options.projectRoot &&
2444
+ !pathContainedIn(options.projectRoot, resolved)
2445
+ ) {
2446
+ throw new CliUsageError(
2447
+ "doctor --runs escapes the project root",
2448
+ );
2449
+ }
3070
2450
 
3071
- while (tokens.length > 0) {
3072
- if (tokens[0] === "--") {
3073
- tokens.shift();
3074
- positional.push(...tokens);
3075
- break;
3076
- }
3077
- const taken = options.takeDashed(tokens);
3078
- if (taken !== undefined) {
3079
- if (taken.def.id === "project") {
3080
- project = requireOptionPath(taken.def.canonical, taken.value);
3081
- continue;
3082
- }
3083
- if (taken.def.id === "base") {
3084
- baseRevision = requireReviewerBaseRevision(taken.value);
3085
- continue;
3086
- }
3087
- if (taken.def.id === "lens") {
3088
- // Public override is single-axis only; omission stays undefined for the parallel branch.
3089
- lens = requireReviewerLens(taken.value);
3090
- continue;
3091
- }
3092
- if (taken.def.id === "authority-ref") {
3093
- authorityRefs.push(requireAuthorityRef(taken.value));
3094
- continue;
3095
- }
3096
- throw new CliUsageError(`unknown reviewer option: ${taken.def.canonical}`);
3097
- }
3098
- const token = tokens.shift()!;
3099
- if (token.startsWith("-") && token !== "-") {
3100
- throw new CliUsageError(`unknown reviewer option: ${token}`);
3101
- }
3102
- positional.push(token);
2451
+ let real: string;
2452
+ try {
2453
+ real = await realpath(resolved);
2454
+ } catch (error) {
2455
+ const detail = error instanceof Error ? error.message : String(error);
2456
+ throw new CliUsageError(
2457
+ `doctor --runs is not a readable retained runs root: ${detail}`,
2458
+ { cause: error },
2459
+ );
3103
2460
  }
3104
2461
 
3105
- // Base and authority are required; omitted lens selects the parallel two-axis mode.
3106
- options.assertRequired();
3107
- return {
3108
- instruction: positional.join(" "),
3109
- attachmentPaths,
3110
- baseRevision: baseRevision!,
3111
- ...(lens === undefined ? {} : { lens }),
3112
- authorityRefs,
3113
- ...(project === undefined ? {} : { project }),
3114
- };
2462
+ const normalized = real.split(sep).join("/");
2463
+ const match = normalized.match(DOCTOR_CASE_RUNS_PATH_PATTERN);
2464
+ if (!match) {
2465
+ throw new CliUsageError(
2466
+ "doctor --runs must be an .ak-roles/books/<book>/<n>/runs directory",
2467
+ );
2468
+ }
2469
+ if (Number(match[1]) !== options.issueNumber) {
2470
+ throw new CliUsageError(
2471
+ `doctor --runs issue ${match[1]} does not match --issue ${options.issueNumber}`,
2472
+ );
2473
+ }
2474
+ return real;
3115
2475
  }
3116
2476
 
2477
+
3117
2478
  export type AdmitReviewerInvocationOptions = {
3118
2479
  home: string;
3119
2480
  principalAuthority: DurablePrincipalAuthority;
@@ -3131,101 +2492,10 @@ export type AdmitReviewerInvocationOptions = {
3131
2492
  correlationId?: string;
3132
2493
  /** Effective model for this invocation — written onto invocation.json. */
3133
2494
  model?: InvocationEffectiveModel;
2495
+ /** Typed ticket already on this summons. Placement uses it; code does not infer one. */
2496
+ assertedTicketNumber?: number;
3134
2497
  };
3135
2498
 
3136
- /**
3137
- * Admit a Reviewer Role run on the fixed base + lens + authority set.
3138
- * Caller instruction is optional provenance; typed fields project Skill inputs.
3139
- * authorityRefs are frozen as durable references only — not Spec prose.
3140
- */
3141
- export async function admitReviewerInvocation(
3142
- options: AdmitReviewerInvocationOptions,
3143
- ): Promise<AdmittedReviewerInvocation> {
3144
- if (options.project !== undefined) {
3145
- requireOptionPath("--project", options.project);
3146
- }
3147
- const baseRevision = requireReviewerBaseRevision(options.baseRevision);
3148
- const lens = requireReviewerLens(options.lens);
3149
- if (options.authorityRefs.length === 0) {
3150
- throw new CliUsageError("reviewer requires --authority-ref <ref>");
3151
- }
3152
- const authorityRefs = Object.freeze(
3153
- options.authorityRefs.map((ref) => requireAuthorityRef(ref)),
3154
- );
3155
-
3156
- const projectRoot = resolve(options.project ?? options.cwd);
3157
- const runId = (options.createRunId ?? uuidv7)();
3158
- const {
3159
- principal,
3160
- sessionDirectory,
3161
- sessionFile,
3162
- runDirectory,
3163
- attachmentsDirectory,
3164
- ledgerHome,
3165
- bookKey,
3166
- } = issueAdmissionPlacement(options.principalAuthority, {
3167
- cwd: projectRoot,
3168
- runId,
3169
- role: "reviewer",
3170
- subject: { unbound: true },
3171
- home: options.home,
3172
- });
3173
-
3174
- // Public parse already rejects attachments; keep freeze loop for structural symmetry.
3175
- const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
3176
-
3177
- const instruction = options.instruction;
3178
- const instructionEmpty = instruction.trim() === "";
3179
- const admitted = {
3180
- role: "reviewer" as const,
3181
- runId,
3182
- bookKey,
3183
- projectRoot,
3184
- runDirectory,
3185
- principal,
3186
- instruction,
3187
- instructionEmpty,
3188
- baseRevision,
3189
- lens,
3190
- authorityRefs: [...authorityRefs],
3191
- ...(options.correlationId === undefined
3192
- ? {}
3193
- : { correlationId: options.correlationId }),
3194
- attachments: attachments.map((a) => ({
3195
- provenancePath: a.provenancePath,
3196
- frozenPath: a.frozenPath,
3197
- byteLength: a.byteLength,
3198
- sha256: a.sha256,
3199
- mediaKind: a.mediaKind,
3200
- })),
3201
- };
3202
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
3203
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
3204
- sessionDirectory,
3205
- sessionFile,
3206
- });
3207
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
3208
-
3209
- return {
3210
- role: "reviewer",
3211
- runId,
3212
- bookKey,
3213
- projectRoot,
3214
- instruction,
3215
- instructionEmpty,
3216
- attachments,
3217
- runDirectory,
3218
- principal,
3219
- admittedRequestPath,
3220
- baseRevision,
3221
- lens,
3222
- authorityRefs,
3223
- ...(options.correlationId === undefined
3224
- ? {}
3225
- : { correlationId: options.correlationId }),
3226
- };
3227
- }
3228
-
3229
2499
  /** Frozen Skill arg projection shared by initial and resume (never reverse-parsed). */
3230
2500
  export function buildReviewerSkillArgProjection(
3231
2501
  admitted: Pick<AdmittedReviewerInvocation, "baseRevision" | "lens" | "authorityRefs">,
@@ -3237,84 +2507,6 @@ export function buildReviewerSkillArgProjection(
3237
2507
  ].join(" ");
3238
2508
  }
3239
2509
 
3240
- /**
3241
- * Build the host-neutral prompt transport for an admitted Reviewer request.
3242
- * Typed base/lens/authority project to Skill invocation args (never reverse-parsed from prose).
3243
- * Optional caller words + engine material follow.
3244
- */
3245
- export function buildReviewerTransportPrompt(
3246
- admitted: AdmittedReviewerInvocation,
3247
- engineMaterial?: EngineSessionMaterial,
3248
- ): string {
3249
- const lines = [buildReviewerSkillArgProjection(admitted)];
3250
- if (!admitted.instructionEmpty && admitted.instruction.trim() !== "") {
3251
- lines.push("", admitted.instruction);
3252
- }
3253
- return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
3254
- }
3255
-
3256
-
3257
- /**
3258
- * Parse Merger-specific argv after the `merger` token.
3259
- * Spellings from PUBLIC_OPTION_TABLE.merger; internal packet fields rejected (#342).
3260
- */
3261
- export function parseMergerArgv(args: readonly string[]): ParseMergerArgvResult {
3262
- const attachmentPaths: string[] = [];
3263
- let project: string | undefined;
3264
- const positional: string[] = [];
3265
- const tokens = [...args];
3266
- const definitions = roleOptions("merger");
3267
- const options = createTypedOptionConsumer(definitions);
3268
-
3269
- while (tokens.length > 0) {
3270
- if (tokens[0] === "--") {
3271
- tokens.shift();
3272
- positional.push(...tokens);
3273
- break;
3274
- }
3275
- const taken = options.takeDashed(tokens);
3276
- if (taken !== undefined) {
3277
- if (taken.def.id === "attach") {
3278
- attachmentPaths.push(requireOptionPath(taken.def.canonical, taken.value));
3279
- continue;
3280
- }
3281
- if (taken.def.id === "project") {
3282
- project = requireOptionPath(taken.def.canonical, taken.value);
3283
- continue;
3284
- }
3285
- throw new CliUsageError(`unknown merger option: ${taken.def.canonical}`);
3286
- }
3287
- const token = tokens.shift()!;
3288
- // Rejected public spellings (#342) plus other internal packet field faces.
3289
- if (
3290
- isRejectedPublicSpelling("merger", token) ||
3291
- token === "--targetObjectId" ||
3292
- token.startsWith("--targetObjectId=") ||
3293
- token === "--sourceObjectId" ||
3294
- token.startsWith("--sourceObjectId=") ||
3295
- token === "--expectedConflictPaths" ||
3296
- token.startsWith("--expectedConflictPaths=") ||
3297
- token === "--resolutionScope" ||
3298
- token.startsWith("--resolutionScope=")
3299
- ) {
3300
- throw new CliUsageError(
3301
- "merger does not accept public packet fields; the adapter reads Git merge materials",
3302
- );
3303
- }
3304
- if (token.startsWith("-") && token !== "-") {
3305
- throw new CliUsageError(`unknown merger option: ${token}`);
3306
- }
3307
- positional.push(token);
3308
- }
3309
-
3310
- options.assertRequired();
3311
- return {
3312
- instruction: positional.join(" "),
3313
- attachmentPaths,
3314
- ...(project === undefined ? {} : { project }),
3315
- };
3316
- }
3317
-
3318
2510
  function mergerMaterialFromUtf8(text: string): MergerInput["materials"]["task"] {
3319
2511
  const bytes = Buffer.from(text, "utf8");
3320
2512
  return Object.freeze({
@@ -3351,155 +2543,12 @@ export type AdmitMergerInvocationOptions = {
3351
2543
  attachmentPaths: readonly string[];
3352
2544
  project?: string;
3353
2545
  createRunId?: () => string;
3354
- /** Test seam; production binds createProductionMergerGitState(projectRoot). */
3355
- gitState?: MergerGitState;
3356
2546
  /** Effective model for this invocation — written onto invocation.json. */
3357
2547
  model?: InvocationEffectiveModel;
2548
+ /** Typed ticket already on this summons. Placement uses it; code does not infer one. */
2549
+ assertedTicketNumber?: number;
3358
2550
  };
3359
2551
 
3360
- /**
3361
- * Admit a Merger Role run on the common Invocation request.
3362
- * Git materials (parents, conflicts, scope) are read from the worktree and
3363
- * handed to the role as assignment materials — including when empty (#827).
3364
- * Callers never supply public packet fields for those facts.
3365
- */
3366
- export async function admitMergerInvocation(
3367
- options: AdmitMergerInvocationOptions,
3368
- ): Promise<AdmittedMergerInvocation> {
3369
- if (options.project !== undefined) {
3370
- requireOptionPath("--project", options.project);
3371
- }
3372
- const instruction = options.instruction;
3373
- if (instruction.trim() === "") {
3374
- throw new CliUsageError("merger requires a nonblank task instruction");
3375
- }
3376
-
3377
- const projectRoot = resolve(options.project ?? options.cwd);
3378
- const derived = await deriveMergerEnvelopeFromActiveMerge(
3379
- projectRoot,
3380
- options.gitState ?? createProductionMergerGitState(projectRoot),
3381
- );
3382
-
3383
- const runId = (options.createRunId ?? uuidv7)();
3384
- const {
3385
- principal,
3386
- sessionDirectory,
3387
- sessionFile,
3388
- runDirectory,
3389
- attachmentsDirectory,
3390
- ledgerHome,
3391
- bookKey,
3392
- } = issueAdmissionPlacement(options.principalAuthority, {
3393
- cwd: projectRoot,
3394
- runId,
3395
- role: "merger",
3396
- subject: { unbound: true },
3397
- home: options.home,
3398
- });
3399
-
3400
- const attachments = await freezeAttachments(options.attachmentPaths, attachmentsDirectory);
3401
-
3402
- // Intent materials seed primary-source investigation; the method owns the work.
3403
- const targetLabel = derived.targetObjectId === "" ? "(none observed)" : derived.targetObjectId;
3404
- const sourceLabel = derived.sourceObjectId === "" ? "(none observed)" : derived.sourceObjectId;
3405
- const targetIntent = mergerMaterialFromUtf8(
3406
- `Investigate primary sources for target parent ${targetLabel}. Do not invent intent.`,
3407
- );
3408
- const sourceIntent = mergerMaterialFromUtf8(
3409
- `Investigate primary sources for source parent ${sourceLabel}. Do not invent intent.`,
3410
- );
3411
- const taskMaterial = mergerMaterialFromUtf8(instruction);
3412
- const authorityMaterial = mergerMaterialFromUtf8(instruction);
3413
-
3414
- const mergerInput = validateMergerInput({
3415
- attemptId: runId,
3416
- targetObjectId: derived.targetObjectId,
3417
- sourceObjectId: derived.sourceObjectId,
3418
- materials: {
3419
- task: taskMaterial,
3420
- authority: authorityMaterial,
3421
- targetIntent,
3422
- sourceIntent,
3423
- },
3424
- expectedConflictPaths: [...derived.expectedConflictPaths],
3425
- resolutionScope: [...derived.resolutionScope],
3426
- authorizedChecks: [],
3427
- });
3428
-
3429
- const mergerInputPath = join(runDirectory, "merger-input.json");
3430
- await writeFile(
3431
- mergerInputPath,
3432
- `${JSON.stringify(mergerInput, null, 2)}\n`,
3433
- "utf8",
3434
- );
3435
-
3436
- const admitted = {
3437
- role: "merger" as const,
3438
- runId,
3439
- bookKey,
3440
- projectRoot,
3441
- runDirectory,
3442
- principal,
3443
- instruction,
3444
- instructionEmpty: false,
3445
- mergerInputPath,
3446
- derived: {
3447
- targetObjectId: derived.targetObjectId,
3448
- sourceObjectId: derived.sourceObjectId,
3449
- expectedConflictPaths: [...derived.expectedConflictPaths],
3450
- resolutionScope: [...derived.resolutionScope],
3451
- },
3452
- attachments: attachments.map((a) => ({
3453
- provenancePath: a.provenancePath,
3454
- frozenPath: a.frozenPath,
3455
- byteLength: a.byteLength,
3456
- sha256: a.sha256,
3457
- mediaKind: a.mediaKind,
3458
- })),
3459
- };
3460
- const admittedRequestPath = join(runDirectory, "admitted-request.json");
3461
- await writeAdmittedRequestPersistence(admittedRequestPath, admitted, {
3462
- sessionDirectory,
3463
- sessionFile,
3464
- });
3465
- await writeRoleInvocationLedger({ ...admitted, sessionDirectory, sessionFile }, admitted.role, options.model);
3466
-
3467
- return {
3468
- role: "merger",
3469
- runId,
3470
- bookKey,
3471
- projectRoot,
3472
- instruction,
3473
- instructionEmpty: false,
3474
- attachments,
3475
- runDirectory,
3476
- principal,
3477
- admittedRequestPath,
3478
- mergerInputPath,
3479
- derived: admitted.derived,
3480
- };
3481
- }
3482
-
3483
- /**
3484
- * Build the host-neutral prompt transport for an admitted Merger request.
3485
- * Method material binds on RoleTurnRequest.methods; Pi-native `/skill:` form
3486
- * is adapter-internal only (ADR 0082).
3487
- */
3488
- export function buildMergerTransportPrompt(
3489
- admitted: AdmittedMergerInvocation,
3490
- engineMaterial?: EngineSessionMaterial,
3491
- ): string {
3492
- const lines: string[] = [admitted.instruction];
3493
- if (admitted.attachments.length > 0) {
3494
- lines.push("");
3495
- lines.push("已受理附件(冻结快照路径):");
3496
- for (const attachment of admitted.attachments) {
3497
- lines.push(`- ${attachment.frozenPath}`);
3498
- }
3499
- }
3500
- return appendEngineSessionMaterial(lines, engineMaterial).join("\n");
3501
- }
3502
-
3503
2552
  const ANALYST_TICKET_NUMBER_PATTERN = /^[1-9]\d*$/;
3504
2553
 
3505
2554
  /**
@@ -3651,7 +2700,6 @@ function requireOptionValue(
3651
2700
  */
3652
2701
  export function parseAnalystArgv(args: readonly string[]): ParseAnalystArgvResult {
3653
2702
  const valueLists = new Map<string, string[]>();
3654
- const tokens = [...args];
3655
2703
  const definitions = roleOptions("analyst");
3656
2704
  // Shared typed consumer: dashed + positional take, repeatable, required (#342).
3657
2705
  const options = createTypedOptionConsumer(definitions);
@@ -3662,40 +2710,32 @@ export function parseAnalystArgv(args: readonly string[]): ParseAnalystArgvResul
3662
2710
  else existing.push(value);
3663
2711
  };
3664
2712
 
3665
- while (tokens.length > 0) {
3666
- if (tokens[0] === "--") {
3667
- tokens.shift();
3668
- if (tokens.length > 0) {
3669
- throw new CliUsageError(`unexpected analyst argument: ${tokens[0]}`);
3670
- }
3671
- break;
3672
- }
3673
- const taken = options.takeDashed(tokens);
3674
- if (taken !== undefined) {
2713
+ scanPublicArgv(args, options, {
2714
+ onDashed(taken) {
3675
2715
  if (taken.def.valueMetavar === null) {
3676
2716
  pushValue(taken.def.id, "");
3677
- continue;
2717
+ return;
3678
2718
  }
3679
2719
  if (taken.def.id === "ticket") {
3680
2720
  if (taken.value === undefined || taken.value.trim() === "") {
3681
2721
  throw new CliUsageError("analyst --ticket requires a positive integer");
3682
2722
  }
3683
2723
  pushValue("ticket", taken.value);
3684
- continue;
2724
+ return;
3685
2725
  }
3686
2726
  if (taken.def.id === "attach") {
3687
2727
  pushValue(
3688
2728
  "attach",
3689
2729
  requireOptionPath(taken.def.canonical, taken.value),
3690
2730
  );
3691
- continue;
2731
+ return;
3692
2732
  }
3693
2733
  if (taken.def.id === "group-a-label" || taken.def.id === "group-b-label") {
3694
2734
  pushValue(
3695
2735
  taken.def.id,
3696
2736
  requireOptionValue(taken.def.canonical, taken.value, "a label"),
3697
2737
  );
3698
- continue;
2738
+ return;
3699
2739
  }
3700
2740
  if (
3701
2741
  taken.def.id === "group-a-issues" || taken.def.id === "group-b-issues"
@@ -3708,36 +2748,42 @@ export function parseAnalystArgv(args: readonly string[]): ParseAnalystArgvResul
3708
2748
  "a comma-separated list of N or book:N",
3709
2749
  ),
3710
2750
  );
3711
- continue;
2751
+ return;
3712
2752
  }
3713
2753
  throw new CliUsageError(`unknown analyst option: ${taken.def.canonical}`);
3714
- }
3715
- const token = tokens.shift()!;
3716
- // #399: deleted --project-root; disabled --model-groups public face.
3717
- if (isRejectedPublicSpelling("analyst", token)) {
3718
- if (token === "--project-root" || token.startsWith("--project-root=")) {
3719
- throw new CliUsageError(
3720
- "analyst no longer accepts --project-root (deleted); use bare call for whole book or --ticket N (cwd git common-dir selects the book)",
3721
- );
2754
+ },
2755
+ onBare(token) {
2756
+ // #399: deleted --project-root; disabled --model-groups public face.
2757
+ if (isRejectedPublicSpelling("analyst", token)) {
2758
+ if (token === "--project-root" || token.startsWith("--project-root=")) {
2759
+ throw new CliUsageError(
2760
+ "analyst no longer accepts --project-root (deleted); use bare call for whole book or --ticket N (cwd git common-dir selects the book)",
2761
+ );
2762
+ }
2763
+ if (token === "--model-groups" || token.startsWith("--model-groups=")) {
2764
+ throw new CliUsageError(
2765
+ "analyst --model-groups public CLI face is disabled; input face is being redesigned for multi-issue comparison (see follow-up ticket)",
2766
+ );
2767
+ }
2768
+ throw new CliUsageError(`unknown analyst option: ${token}`);
3722
2769
  }
3723
- if (token === "--model-groups" || token.startsWith("--model-groups=")) {
3724
- throw new CliUsageError(
3725
- "analyst --model-groups public CLI face is disabled; input face is being redesigned for multi-issue comparison (see follow-up ticket)",
3726
- );
2770
+ if (token.startsWith("-") && token !== "-") {
2771
+ throw new CliUsageError(`unknown analyst option: ${token}`);
3727
2772
  }
3728
- throw new CliUsageError(`unknown analyst option: ${token}`);
3729
- }
3730
- if (token.startsWith("-") && token !== "-") {
3731
- throw new CliUsageError(`unknown analyst option: ${token}`);
3732
- }
3733
- // Positional selectors (e.g. sweep) via shared typed consumer — not a parallel list.
3734
- const positional = options.takePositional(token);
3735
- if (positional !== undefined) {
3736
- pushValue(positional.id, "");
3737
- continue;
3738
- }
3739
- throw new CliUsageError(`unexpected analyst argument: ${token}`);
3740
- }
2773
+ // Positional selectors (e.g. sweep) via shared typed consumer — not a parallel list.
2774
+ const positional = options.takePositional(token);
2775
+ if (positional !== undefined) {
2776
+ pushValue(positional.id, "");
2777
+ return;
2778
+ }
2779
+ throw new CliUsageError(`unexpected analyst argument: ${token}`);
2780
+ },
2781
+ onDoubleDash(rest) {
2782
+ if (rest.length > 0) {
2783
+ throw new CliUsageError(`unexpected analyst argument: ${rest[0]}`);
2784
+ }
2785
+ },
2786
+ });
3741
2787
 
3742
2788
  const counts = new Map<string, number>();
3743
2789
  for (const [id, values] of valueLists) {