@akagilnc/pi-workflow-roles 0.1.3771 → 0.1.3789

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 (40) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/production-host.js +793 -393
  4. package/dist/atomic-write.js +23 -0
  5. package/dist/collector-handbook.js +142 -0
  6. package/dist/collector-ledger.js +39 -47
  7. package/dist/collector-role.js +103 -14
  8. package/dist/collector-tool-schemas.js +25 -1
  9. package/dist/headless-host/description.js +77 -0
  10. package/dist/headless-host/mcp-relay.mjs +119 -0
  11. package/dist/headless-host/production-host.js +26841 -0
  12. package/dist/host-descriptions.js +44 -3
  13. package/dist/public-cli/load-production-external-host.js +19 -0
  14. package/dist/public-cli/load-production-headless-host.js +36 -0
  15. package/dist/public-cli/main.js +244 -234
  16. package/dist/public-cli/option-definitions.js +2 -2
  17. package/dist/public-role-summons.js +40 -5
  18. package/dist/role-runtime.js +3 -0
  19. package/extensions/role-runtime.ts +2 -0
  20. package/package.json +1 -1
  21. package/resources/collector-bot-handbook.md +33 -0
  22. package/scripts/build-package.mjs +28 -0
  23. package/src/acp-host/production-host.ts +4 -0
  24. package/src/acp-host/role-envelope.ts +141 -84
  25. package/src/acp-host/role-turn-host.ts +12 -0
  26. package/src/collector-handbook.ts +194 -0
  27. package/src/collector-ledger.ts +42 -62
  28. package/src/collector-role.ts +117 -11
  29. package/src/collector-tool-schemas.ts +25 -1
  30. package/src/headless-host/description.ts +123 -0
  31. package/src/headless-host/production-host.ts +75 -0
  32. package/src/headless-host/role-turn-host.ts +424 -0
  33. package/src/host-descriptions.ts +53 -6
  34. package/src/public-cli/cli.ts +2 -2
  35. package/src/public-cli/load-production-external-host.ts +29 -0
  36. package/src/public-cli/load-production-headless-host.ts +48 -0
  37. package/src/public-cli/main.ts +5 -0
  38. package/src/public-cli/option-definitions.ts +2 -2
  39. package/src/public-role-summons.ts +62 -9
  40. package/src/role-runtime.ts +5 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Collector bot handbook — opaque working memory under the book topology.
3
+ * Runtime stores and delivers UTF-8 text only; never parses free text into
4
+ * code state rules (parent #673 D3 / #677).
5
+ *
6
+ * Placement reuses the machine ledger home (ADR 0048) when the admitted
7
+ * session already sits under books/<bookKey>/ — no parallel persistence frame.
8
+ */
9
+ import { readFile } from "node:fs/promises";
10
+ import { join, sep } from "node:path";
11
+
12
+ import { writeFileAtomically } from "./atomic-write.ts";
13
+ import {
14
+ activationBookDirectory,
15
+ assertLedgerFileInsideHome,
16
+ ensureRealDirectoryTree,
17
+ resolveActivationLedgerHomeForPath,
18
+ } from "./activation-ledger-topology.ts";
19
+ import { COLLECTOR_HANDBOOK_MAX_BYTES } from "./collector-tool-schemas.ts";
20
+
21
+ export type CollectorHandbookScope = "general" | "repo";
22
+
23
+ export type CollectorHandbookRead = {
24
+ readonly general: string;
25
+ readonly repo: string;
26
+ readonly generalSource: "book" | "seed" | "empty";
27
+ readonly repoSource: "book" | "empty";
28
+ readonly generalPath: string;
29
+ readonly repoPath: string;
30
+ };
31
+
32
+ export type CollectorHandbookWriteResult = {
33
+ readonly scope: CollectorHandbookScope;
34
+ readonly path: string;
35
+ readonly byteLength: number;
36
+ };
37
+
38
+ export type CollectorHandbookStore = {
39
+ readonly root: string;
40
+ readonly repositoryCanonical: string;
41
+ read(): Promise<CollectorHandbookRead>;
42
+ write(scope: CollectorHandbookScope, body: string): Promise<CollectorHandbookWriteResult>;
43
+ };
44
+
45
+ export type CollectorHandbookPlacement = {
46
+ readonly ledgerHome: string;
47
+ readonly bookKey: string;
48
+ readonly root: string;
49
+ };
50
+
51
+ /**
52
+ * Derive handbook root from an admitted session/run path under
53
+ * `.ak-roles/books/<bookKey>/...`. Fails closed when topology is absent.
54
+ */
55
+ export function resolveCollectorHandbookRoot(sessionPath: string): CollectorHandbookPlacement {
56
+ if (typeof sessionPath !== "string" || sessionPath.trim().length === 0) {
57
+ throw new Error("通进司手册要求非空 session 路径,且位于 books/<bookKey>/");
58
+ }
59
+ // Platform path segments only — never rewrite non-separator characters (POSIX `\\` is literal).
60
+ const segments = sessionPath.split(sep);
61
+ let bookKey: string | undefined;
62
+ for (let i = 0; i + 3 < segments.length; i += 1) {
63
+ if (segments[i] === ".ak-roles" && segments[i + 1] === "books") {
64
+ const candidate = segments[i + 2]!;
65
+ // Require a following segment so bookKey is a true path component under books/.
66
+ if (candidate.length > 0) {
67
+ bookKey = candidate;
68
+ break;
69
+ }
70
+ }
71
+ }
72
+ if (bookKey === undefined) {
73
+ throw new Error(
74
+ `通进司手册要求 session 位于 books/<bookKey>/;收到 ${sessionPath}`,
75
+ );
76
+ }
77
+ if (bookKey === "." || bookKey === "..") {
78
+ throw new Error(`通进司手册拒绝不安全 bookKey ${JSON.stringify(bookKey)}`);
79
+ }
80
+ const ledgerHome = resolveActivationLedgerHomeForPath(sessionPath);
81
+ const root = join(activationBookDirectory(ledgerHome, bookKey), "collector-handbook");
82
+ return { ledgerHome, bookKey, root };
83
+ }
84
+
85
+ /** Flat repo file name under handbook/repos/ — avoids nested owner/repo dirs. */
86
+ function collectorHandbookRepoFileName(repositoryCanonical: string): string {
87
+ if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(repositoryCanonical)) {
88
+ throw new Error(
89
+ `通进司手册仓库文件要求规范 owner/repo,收到 ${JSON.stringify(repositoryCanonical)}`,
90
+ );
91
+ }
92
+ return `${repositoryCanonical.replaceAll("/", "__")}.md`;
93
+ }
94
+
95
+ export function createCollectorHandbookStore(input: {
96
+ readonly ledgerHome: string;
97
+ readonly handbookRoot: string;
98
+ readonly repositoryCanonical: string;
99
+ readonly seedGeneral?: string;
100
+ }): CollectorHandbookStore {
101
+ const generalPath = join(input.handbookRoot, "general.md");
102
+ const repoDir = join(input.handbookRoot, "repos");
103
+ const repoPath = join(repoDir, collectorHandbookRepoFileName(input.repositoryCanonical));
104
+
105
+ /**
106
+ * ADR 0038: confine at the real read seam. Parent dirs must be physical under
107
+ * ledger home; the leaf must not be a pre-existing symlink (even in-home).
108
+ */
109
+ /** Sole UTF-8 budget seam — write and read share COLLECTOR_HANDBOOK_MAX_BYTES. */
110
+ const assertHandbookBudget = (body: string, label: string): number => {
111
+ const byteLength = Buffer.byteLength(body, "utf8");
112
+ if (byteLength > COLLECTOR_HANDBOOK_MAX_BYTES) {
113
+ throw new Error(
114
+ `通进司手册${label} UTF-8 至多 ${COLLECTOR_HANDBOOK_MAX_BYTES} 字节,收到 ${byteLength}`,
115
+ );
116
+ }
117
+ return byteLength;
118
+ };
119
+
120
+ const readOptional = async (path: string, parentDir: string): Promise<string | undefined> => {
121
+ ensureRealDirectoryTree(input.ledgerHome, parentDir);
122
+ assertLedgerFileInsideHome(path, input.ledgerHome);
123
+ try {
124
+ const body = await readFile(path, "utf8");
125
+ assertHandbookBudget(body, "正文");
126
+ return body;
127
+ } catch (error) {
128
+ if (isNotFound(error)) return undefined;
129
+ throw error;
130
+ }
131
+ };
132
+
133
+ return {
134
+ root: input.handbookRoot,
135
+ repositoryCanonical: input.repositoryCanonical,
136
+ async read() {
137
+ const bookGeneral = await readOptional(generalPath, input.handbookRoot);
138
+ const bookRepo = await readOptional(repoPath, repoDir);
139
+ if (bookGeneral !== undefined) {
140
+ return {
141
+ general: bookGeneral,
142
+ repo: bookRepo ?? "",
143
+ generalSource: "book",
144
+ repoSource: bookRepo === undefined ? "empty" : "book",
145
+ generalPath,
146
+ repoPath,
147
+ };
148
+ }
149
+ const seed = input.seedGeneral;
150
+ if (typeof seed === "string" && seed.length > 0) {
151
+ assertHandbookBudget(seed, "正文");
152
+ return {
153
+ general: seed,
154
+ repo: bookRepo ?? "",
155
+ generalSource: "seed",
156
+ repoSource: bookRepo === undefined ? "empty" : "book",
157
+ generalPath,
158
+ repoPath,
159
+ };
160
+ }
161
+ return {
162
+ general: "",
163
+ repo: bookRepo ?? "",
164
+ generalSource: "empty",
165
+ repoSource: bookRepo === undefined ? "empty" : "book",
166
+ generalPath,
167
+ repoPath,
168
+ };
169
+ },
170
+ async write(scope, body) {
171
+ // body string shape = collectorHandbookWriteArgsSchema; byte budget is business (UTF-8).
172
+ const byteLength = assertHandbookBudget(body, "正文");
173
+ ensureRealDirectoryTree(input.ledgerHome, input.handbookRoot);
174
+ const path = scope === "general" ? generalPath : repoPath;
175
+ if (scope === "repo") {
176
+ ensureRealDirectoryTree(input.ledgerHome, repoDir);
177
+ }
178
+ assertLedgerFileInsideHome(path, input.ledgerHome);
179
+ await writeFileAtomically(path, body);
180
+ return {
181
+ scope,
182
+ path,
183
+ byteLength,
184
+ };
185
+ },
186
+ };
187
+ }
188
+
189
+ function isNotFound(error: unknown): boolean {
190
+ return typeof error === "object"
191
+ && error !== null
192
+ && "code" in error
193
+ && (error as { code?: unknown }).code === "ENOENT";
194
+ }
@@ -1,5 +1,3 @@
1
- import Value from "typebox/value";
2
-
3
1
  import type { CollectorManifest, CollectorRepository } from "./collector-config.ts";
4
2
  import {
5
3
  applyEvidenceVersionHistory,
@@ -26,13 +24,6 @@ import {
26
24
  type GitHubPullRequest,
27
25
  } from "./collector-github.ts";
28
26
  import { CollectorNonOpenRequestError } from "./collector-identity.ts";
29
- import {
30
- collectorObserveArgsSchema,
31
- collectorOutputArgsSchema,
32
- collectorReadArgsSchema,
33
- collectorRequestArgsSchema,
34
- collectorWaitArgsSchema,
35
- } from "./collector-tool-schemas.ts";
36
27
  import { COLLECTOR_OUTPUT_TOOL } from "./package-contracts/collector-output.ts";
37
28
 
38
29
  export const COLLECTOR_OBSERVE_TOOL = "ak_collector_observe";
@@ -41,6 +32,8 @@ export const COLLECTOR_REQUEST_TOOL = "ak_collector_request";
41
32
  export const COLLECTOR_WAIT_TOOL = "ak_collector_wait";
42
33
  /** #676 A: role-decided target bind — business tool, ledger-booked. */
43
34
  export const COLLECTOR_BIND_TARGET_TOOL = "ak_collector_bind_target";
35
+ /** #677: opaque handbook write — business tool, ledger-booked. */
36
+ export const COLLECTOR_HANDBOOK_WRITE_TOOL = "ak_collector_handbook_write";
44
37
  export { COLLECTOR_OUTPUT_TOOL };
45
38
 
46
39
  export const COLLECTOR_OPERATIONAL_TOOLS = [
@@ -49,6 +42,7 @@ export const COLLECTOR_OPERATIONAL_TOOLS = [
49
42
  COLLECTOR_READ_TOOL,
50
43
  COLLECTOR_REQUEST_TOOL,
51
44
  COLLECTOR_WAIT_TOOL,
45
+ COLLECTOR_HANDBOOK_WRITE_TOOL,
52
46
  ] as const;
53
47
 
54
48
  /**
@@ -57,7 +51,7 @@ export const COLLECTOR_OPERATIONAL_TOOLS = [
57
51
  * (evidenceId + htmlUrl) and is preserved in the volume, never unconditionally
58
52
  * transcribed into model context or receipt.
59
53
  */
60
- export const COLLECTOR_OBSERVE_BODY_HEAD_BYTES = 512;
54
+ const COLLECTOR_OBSERVE_BODY_HEAD_BYTES = 512;
61
55
 
62
56
  function utf8SafePrefix(text: string, maxBytes: number): string {
63
57
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
@@ -108,7 +102,7 @@ export type ObserveModelView = {
108
102
  * (collector-side; provider-visible observe details carry only this bounded
109
103
  * projection), never unconditionally transcribed into model context.
110
104
  */
111
- export function projectObserveContextView(modelView: ObserveModelView): ObserveModelView {
105
+ function projectObserveContextView(modelView: ObserveModelView): ObserveModelView {
112
106
  return {
113
107
  ...modelView,
114
108
  evidence: modelView.evidence.map((entry) =>
@@ -121,9 +115,9 @@ export function projectObserveContextView(modelView: ObserveModelView): ObserveM
121
115
  export type CollectorOperationalTool = (typeof COLLECTOR_OPERATIONAL_TOOLS)[number];
122
116
 
123
117
  export const COLLECTOR_ACTIVATION_ENTRY_TYPE = "ak-collector-activation" as const;
124
- export const COLLECTOR_SNAPSHOT_ENTRY_TYPE = "ak-collector-snapshot" as const;
125
- export const COLLECTOR_REQUEST_ENTRY_TYPE = "ak-collector-request" as const;
126
- export const COLLECTOR_WAIT_ENTRY_TYPE = "ak-collector-wait" as const;
118
+ const COLLECTOR_SNAPSHOT_ENTRY_TYPE = "ak-collector-snapshot" as const;
119
+ const COLLECTOR_REQUEST_ENTRY_TYPE = "ak-collector-request" as const;
120
+ const COLLECTOR_WAIT_ENTRY_TYPE = "ak-collector-wait" as const;
127
121
 
128
122
  export type CollectorRequestAttempt = {
129
123
  attemptId: string;
@@ -217,7 +211,7 @@ export type CollectorLedger = {
217
211
  }>;
218
212
 
219
213
  request(
220
- input: { requestId: string; snapshotId: string },
214
+ input: { requestId: string; snapshotId: string; body?: string },
221
215
  transport: CollectorGitHubTransport,
222
216
  clock: CollectorClock,
223
217
  signal?: AbortSignal,
@@ -243,29 +237,6 @@ function isOperationalTool(name: string): name is CollectorOperationalTool {
243
237
  return (COLLECTOR_OPERATIONAL_TOOLS as readonly string[]).includes(name);
244
238
  }
245
239
 
246
- /** Shared Check owner for Collector tool argument shapes (schema seam, not batch law). */
247
- export function collectorToolArgumentsValid(
248
- name: string,
249
- args: unknown,
250
- ): boolean {
251
- // Residual envelope: reject missing/null args before schema check.
252
- if (args === undefined || args === null) return false;
253
- switch (name) {
254
- case COLLECTOR_OBSERVE_TOOL:
255
- return Value.Check(collectorObserveArgsSchema, args);
256
- case COLLECTOR_READ_TOOL:
257
- return Value.Check(collectorReadArgsSchema, args);
258
- case COLLECTOR_REQUEST_TOOL:
259
- return Value.Check(collectorRequestArgsSchema, args);
260
- case COLLECTOR_WAIT_TOOL:
261
- return Value.Check(collectorWaitArgsSchema, args);
262
- case COLLECTOR_OUTPUT_TOOL:
263
- return Value.Check(collectorOutputArgsSchema, args);
264
- default:
265
- return false;
266
- }
267
- }
268
-
269
240
  export function createCollectorLedger(
270
241
  config: CollectorConfigState,
271
242
  options?: CollectorLedgerOptions,
@@ -335,9 +306,7 @@ export function createCollectorLedger(
335
306
 
336
307
  const requireBoundPr = (): number => {
337
308
  if (config.prNumber === undefined) {
338
- throw new Error(
339
- "Collector PR target is unbound; call ak_collector_bind_target with the role-decided issue/PR or pass --pr",
340
- );
309
+ throw new Error("通进司 PR 目标未绑定");
341
310
  }
342
311
  return config.prNumber;
343
312
  };
@@ -675,11 +644,11 @@ export function createCollectorLedger(
675
644
  throw new Error("通进司已产出输出候选,本局不再受理目标绑定");
676
645
  }
677
646
  if (!Number.isSafeInteger(prNumber) || prNumber < 1) {
678
- throw new Error("Collector bind target requires a positive safe-integer PR number");
647
+ throw new Error("通进司绑定目标要求正安全整数 PR 号");
679
648
  }
680
649
  if (config.prNumber !== undefined && config.prNumber !== prNumber) {
681
650
  throw new Error(
682
- `Collector target already bound to PR ${config.prNumber}; cannot rebind to ${prNumber}`,
651
+ `通进司目标已绑定 PR ${config.prNumber},不可改绑为 ${prNumber}`,
683
652
  );
684
653
  }
685
654
  config.prNumber = prNumber;
@@ -915,9 +884,25 @@ export function createCollectorLedger(
915
884
  throw latchFatal("通进司请求时存在未恢复的传输失败");
916
885
  }
917
886
 
918
- const request = config.manifest.requests.find((item) => item.id === input.requestId);
919
- if (request === undefined) {
920
- throw new Error(`未知通进司 requestId "${input.requestId}"`);
887
+ // Sole request identity seam: trim once, then use everywhere (lookup/marker/attemptKey).
888
+ // Schema pattern rejects leading/trailing whitespace at host; trim still collapses
889
+ // direct ledger callers and any residual padding into one stable id (#677 D2).
890
+ if (typeof input.requestId !== "string") {
891
+ throw new Error("通进司 requestId 须为字符串");
892
+ }
893
+ const requestId = input.requestId.trim();
894
+ if (requestId.length === 0) {
895
+ throw new Error("通进司 requestId 须为非空(首尾空白不构成身份)");
896
+ }
897
+ const configured = config.manifest.requests.find((item) => item.id === requestId);
898
+ const roleBody = typeof input.body === "string" ? input.body : undefined;
899
+ if (configured === undefined) {
900
+ // #677: role-decided trigger from handbook/field activity — body required when not in manifest.
901
+ if (roleBody === undefined || roleBody.trim().length === 0) {
902
+ throw new Error(
903
+ `未知通进司 requestId "${requestId}" 且未提供 body;请提交角色判定的请求正文或使用 request-manifest`,
904
+ );
905
+ }
921
906
  }
922
907
 
923
908
  const snapshot = snapshots.find((item) => item.snapshotId === input.snapshotId);
@@ -936,10 +921,12 @@ export function createCollectorLedger(
936
921
  throw latchFatal("通进司请求不在资格截止前");
937
922
  }
938
923
 
924
+ // Caller manifest body wins when requestId is configured; otherwise role body.
925
+ const configuredBody = configured?.requestBody ?? roleBody!;
939
926
  const { body, marker } = buildCollectorRequestBody({
940
- configuredBody: request.requestBody,
927
+ configuredBody,
941
928
  manifestDigest: config.manifest.digest,
942
- requestId: request.id,
929
+ requestId,
943
930
  headOid: snapshot.headOid,
944
931
  });
945
932
  const existingMarker = snapshot.evidenceIds.some((id) => {
@@ -951,7 +938,7 @@ export function createCollectorLedger(
951
938
  });
952
939
  if (existingMarker) {
953
940
  throw new Error(
954
- `通进司在此 HEAD 已有同 marker 的已认证请求 "${input.requestId}"`,
941
+ `通进司在此 HEAD 已有同 marker 的已认证请求 "${requestId}"`,
955
942
  );
956
943
  }
957
944
 
@@ -960,11 +947,11 @@ export function createCollectorLedger(
960
947
  config.repository.canonical,
961
948
  String(boundPr),
962
949
  snapshot.headOid,
963
- request.id,
950
+ requestId,
964
951
  ].join("|");
965
952
  if (attemptKeys.has(attemptKey)) {
966
953
  throw new Error(
967
- `通进司进程内请求 "${request.id}" 在 HEAD ${snapshot.headOid} 的 attempt 已用`,
954
+ `通进司进程内请求 "${requestId}" 在 HEAD ${snapshot.headOid} 的 attempt 已用`,
968
955
  );
969
956
  }
970
957
 
@@ -972,7 +959,7 @@ export function createCollectorLedger(
972
959
  const attemptId = sha256Text(`${attemptKey}:${startedAt}`).slice(0, 16);
973
960
  const attempt: CollectorRequestAttempt = {
974
961
  attemptId,
975
- requestId: request.id,
962
+ requestId,
976
963
  observedHead: snapshot.headOid,
977
964
  snapshotId: snapshot.snapshotId,
978
965
  marker,
@@ -1017,7 +1004,7 @@ export function createCollectorLedger(
1017
1004
  return {
1018
1005
  status: "succeeded",
1019
1006
  attemptId,
1020
- requestId: request.id,
1007
+ requestId,
1021
1008
  observedHead: snapshot.headOid,
1022
1009
  marker,
1023
1010
  commentEvidenceId: record.evidenceId,
@@ -1032,7 +1019,7 @@ export function createCollectorLedger(
1032
1019
  return {
1033
1020
  status: "ambiguous_loss",
1034
1021
  attemptId,
1035
- requestId: request.id,
1022
+ requestId,
1036
1023
  observedHead: snapshot.headOid,
1037
1024
  marker,
1038
1025
  diagnostics: result.diagnostics,
@@ -1051,14 +1038,7 @@ export function createCollectorLedger(
1051
1038
  if (activationTime === undefined) {
1052
1039
  throw latchFatal("通进司等待需要激活");
1053
1040
  }
1054
- if (!Number.isSafeInteger(input.durationMs) || input.durationMs < 1) {
1055
- throw new Error("通进司等待 durationMs 须为正安全整数");
1056
- }
1057
- if (input.durationMs > COLLECTOR_ELIGIBILITY_MS) {
1058
- throw new Error(
1059
- `通进司等待 durationMs 至多为 ${COLLECTOR_ELIGIBILITY_MS}`,
1060
- );
1061
- }
1041
+ // durationMs shape authority = collectorWaitArgsSchema (host parameters).
1062
1042
  if (pastCutoff(clock)) {
1063
1043
  finalObservationRequired = true;
1064
1044
  throw latchFatal("通进司等待不在资格截止前");
@@ -25,8 +25,14 @@ import {
25
25
  listPullRequestNumbersByTicket,
26
26
  type CollectorGitHubTransport,
27
27
  } from "./collector-github.ts";
28
+ import {
29
+ createCollectorHandbookStore,
30
+ resolveCollectorHandbookRoot,
31
+ type CollectorHandbookStore,
32
+ } from "./collector-handbook.ts";
28
33
  import {
29
34
  COLLECTOR_BIND_TARGET_TOOL,
35
+ COLLECTOR_HANDBOOK_WRITE_TOOL,
30
36
  COLLECTOR_OBSERVE_TOOL,
31
37
  COLLECTOR_OUTPUT_TOOL,
32
38
  COLLECTOR_READ_TOOL,
@@ -42,6 +48,7 @@ import {
42
48
  } from "./collector-receipt.ts";
43
49
  import {
44
50
  collectorBindTargetArgsSchema,
51
+ collectorHandbookWriteArgsSchema,
45
52
  collectorObserveArgsSchema,
46
53
  collectorOutputArgsSchema,
47
54
  collectorReadArgsSchema,
@@ -76,6 +83,7 @@ export class CollectorTargetBindError extends CorrectableSubmissionError {
76
83
 
77
84
  export {
78
85
  COLLECTOR_BIND_TARGET_TOOL,
86
+ COLLECTOR_HANDBOOK_WRITE_TOOL,
79
87
  COLLECTOR_OBSERVE_TOOL,
80
88
  COLLECTOR_OUTPUT_TOOL,
81
89
  COLLECTOR_READ_TOOL,
@@ -89,6 +97,7 @@ export const COLLECTOR_REQUIRED_TOOLS = [
89
97
  COLLECTOR_READ_TOOL,
90
98
  COLLECTOR_REQUEST_TOOL,
91
99
  COLLECTOR_WAIT_TOOL,
100
+ COLLECTOR_HANDBOOK_WRITE_TOOL,
92
101
  COLLECTOR_OUTPUT_TOOL,
93
102
  ] as const;
94
103
 
@@ -125,12 +134,14 @@ const readSchema = collectorReadArgsSchema;
125
134
  const requestSchema = collectorRequestArgsSchema;
126
135
  const waitSchema = collectorWaitArgsSchema;
127
136
  const bindSchema = collectorBindTargetArgsSchema;
137
+ const handbookWriteSchema = collectorHandbookWriteArgsSchema;
128
138
  const outputSchema = collectorOutputArgsSchema;
129
139
 
130
140
  type RequestParams = Static<typeof requestSchema>;
131
141
  type ReadParams = Static<typeof readSchema>;
132
142
  type WaitParams = Static<typeof waitSchema>;
133
143
  type BindParams = Static<typeof bindSchema>;
144
+ type HandbookWriteParams = Static<typeof handbookWriteSchema>;
134
145
  type OutputParams = Static<typeof outputSchema>;
135
146
 
136
147
  export type CollectorRoleDependencies = {
@@ -138,6 +149,8 @@ export type CollectorRoleDependencies = {
138
149
  createTransport(): CollectorGitHubTransport;
139
150
  createClock?(): CollectorClock;
140
151
  createLedger(config: CollectorConfigState, clock: CollectorClock, ctx: HostContext): CollectorLedger;
152
+ /** Optional packaged seed for first-use general handbook (#677). */
153
+ loadHandbookSeed?(): Promise<string>;
141
154
  };
142
155
 
143
156
  export type CollectorRoleHostActions = {
@@ -151,25 +164,51 @@ export type CollectorActivation = {
151
164
  ledger: CollectorLedger;
152
165
  transport: CollectorGitHubTransport;
153
166
  clock: CollectorClock;
167
+ handbook: CollectorHandbookStore;
168
+ /** Loaded at activate; refreshed after handbook write for same-session materials. */
169
+ handbookView: {
170
+ general: string;
171
+ repo: string;
172
+ generalSource: "book" | "seed" | "empty";
173
+ repoSource: "book" | "empty";
174
+ generalPath: string;
175
+ repoPath: string;
176
+ };
154
177
  };
155
178
 
156
179
  function buildMethodContext(activation: CollectorActivation): string {
157
180
  const pr = activation.ledger.config.prNumber;
158
- return [
181
+ const handbook = activation.handbookView;
182
+ const lines = [
159
183
  "<collector_method>",
160
184
  `host: github.com`,
161
185
  `repository: ${activation.repository.canonical}`,
162
- `prNumber: ${pr === undefined ? "unbound — call ak_collector_bind_target with the role-decided issue/PR before observe" : String(pr)}`,
186
+ `prNumber: ${pr === undefined ? "未绑定" : String(pr)}`,
163
187
  `requests: ${JSON.stringify(activation.manifest.requests.map((request) => ({ id: request.id })))}`,
188
+ `handbookGeneralSource: ${handbook.generalSource}`,
189
+ `handbookRepoSource: ${handbook.repoSource}`,
164
190
  "</collector_method>",
165
- ].join("\n");
191
+ ];
192
+ // Opaque working memory only — no directional instructions (ADR 0073).
193
+ // JSON + `<` → \u003c keeps bodies from forging the delivery close tag (#677).
194
+ if (handbook.general.length > 0 || handbook.repo.length > 0) {
195
+ const payload = JSON.stringify({
196
+ general: handbook.general,
197
+ repo: handbook.repo,
198
+ generalSource: handbook.generalSource,
199
+ repoSource: handbook.repoSource,
200
+ repository: activation.repository.canonical,
201
+ }).replaceAll("<", "\\u003c");
202
+ lines.push("", "<collector_handbook>", payload, "</collector_handbook>");
203
+ }
204
+ return lines.join("\n");
166
205
  }
167
206
 
168
207
  function parsePositiveTicket(raw: unknown, label: string): number | undefined {
169
208
  if (raw === undefined || raw === null) return undefined;
170
209
  if (typeof raw === "number" && Number.isSafeInteger(raw) && raw >= 1) return raw;
171
210
  if (typeof raw === "string" && /^[1-9]\d*$/.test(raw.trim())) return Number(raw.trim());
172
- throw new CollectorTargetBindError(`ak_collector_bind_target ${label} must be a positive safe integer`);
211
+ throw new CollectorTargetBindError(`通进司绑定 ${label} 须为正安全整数`);
173
212
  }
174
213
 
175
214
  /**
@@ -228,6 +267,26 @@ export function createCollectorRoleRuntime(
228
267
  ctx,
229
268
  );
230
269
 
270
+ // #677: handbook under admitted book topology (session path → books/<key>/collector-handbook).
271
+ const sessionPath = ctx.sessionManager?.getSessionFile?.()
272
+ ?? ctx.sessionManager?.getSessionDir?.();
273
+ if (typeof sessionPath !== "string" || sessionPath.length === 0) {
274
+ throw new Error("通进司手册要求 session 路径位于 books/<bookKey>/");
275
+ }
276
+ const placement = resolveCollectorHandbookRoot(sessionPath);
277
+ const seedGeneral = dependencies.loadHandbookSeed === undefined
278
+ ? undefined
279
+ : (await dependencies.loadHandbookSeed()).trim();
280
+ const handbook = createCollectorHandbookStore({
281
+ ledgerHome: placement.ledgerHome,
282
+ handbookRoot: placement.root,
283
+ repositoryCanonical: repository.canonical,
284
+ ...(seedGeneral === undefined || seedGeneral.length === 0
285
+ ? {}
286
+ : { seedGeneral }),
287
+ });
288
+ const handbookView = await handbook.read();
289
+
231
290
  return {
232
291
  soul,
233
292
  repository,
@@ -235,6 +294,8 @@ export function createCollectorRoleRuntime(
235
294
  ledger,
236
295
  transport,
237
296
  clock,
297
+ handbook,
298
+ handbookView,
238
299
  };
239
300
  },
240
301
 
@@ -297,7 +358,7 @@ export function createCollectorRoleRuntime(
297
358
  const issueNumber = parsePositiveTicket(params.issueNumber, "issueNumber");
298
359
  if (prNumber === undefined && issueNumber === undefined) {
299
360
  throw new CollectorTargetBindError(
300
- "ak_collector_bind_target requires role-decided prNumber and/or issueNumber",
361
+ "通进司绑定须由角色判定 prNumber 与/或 issueNumber",
301
362
  );
302
363
  }
303
364
 
@@ -310,18 +371,18 @@ export function createCollectorRoleRuntime(
310
371
  });
311
372
  if (associated.length === 0) {
312
373
  throw new CollectorTargetBindError(
313
- `no PR associated with issue #${issueNumber} in ${activation.repository.canonical}; pass an explicit --pr or a different issueNumber`,
374
+ `${activation.repository.canonical} 的 issue #${issueNumber} 无关联 PR;请改用明确 --pr 或其他 issueNumber`,
314
375
  );
315
376
  }
316
377
  if (associated.length > 1) {
317
378
  throw new CollectorTargetBindError(
318
- `multiple PRs associated with issue #${issueNumber}: ${associated.join(", ")}; pass an explicit prNumber or --pr`,
379
+ `issue #${issueNumber} 关联多个 PR:${associated.join("、")};请改用明确 prNumber 或 --pr`,
319
380
  );
320
381
  }
321
382
  const fromIssue = associated[0]!;
322
383
  if (prNumber !== undefined && prNumber !== fromIssue) {
323
384
  throw new CollectorTargetBindError(
324
- `prNumber ${prNumber} conflicts with issue #${issueNumber} association PR ${fromIssue}`,
385
+ `prNumber ${prNumber} 与 issue #${issueNumber} 关联 PR ${fromIssue} 冲突`,
325
386
  );
326
387
  }
327
388
  bound = fromIssue;
@@ -422,8 +483,8 @@ export function createCollectorRoleRuntime(
422
483
  pi.registerTool({
423
484
  name: COLLECTOR_REQUEST_TOOL,
424
485
  label: "通进司请求",
425
- description: "按配置请求体与关联标记,在所引最新快照 HEAD 发一次请求。",
426
- promptSnippet: "按配置发一次请求",
486
+ description: "在所引最新快照 HEAD 发一次请求。requestId 可取配置清单,或角色依手册/现场判定的稳定 id;后者须同时提供 body。",
487
+ promptSnippet: "发一次评审请求",
427
488
  parameters: requestSchema,
428
489
  async execute(toolCallId: string, params: RequestParams, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: HostContext) {
429
490
  const activation = getActivation();
@@ -431,7 +492,11 @@ export function createCollectorRoleRuntime(
431
492
  try {
432
493
  activation.ledger.beginOperational(COLLECTOR_REQUEST_TOOL, toolCallId);
433
494
  const details = await activation.ledger.request(
434
- params,
495
+ {
496
+ requestId: params.requestId,
497
+ snapshotId: params.snapshotId,
498
+ ...(typeof params.body === "string" ? { body: params.body } : {}),
499
+ },
435
500
  activation.transport,
436
501
  activation.clock,
437
502
  signal,
@@ -451,6 +516,47 @@ export function createCollectorRoleRuntime(
451
516
  },
452
517
  });
453
518
 
519
+ pi.registerTool({
520
+ name: COLLECTOR_HANDBOOK_WRITE_TOOL,
521
+ label: "通进司手册写入",
522
+ description: "写入通用手册或当前仓库差异全文(整份替换)。",
523
+ promptSnippet: "更新 bot 手册",
524
+ parameters: handbookWriteSchema,
525
+ async execute(toolCallId: string, params: HandbookWriteParams, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: HostContext) {
526
+ const activation = getActivation();
527
+ if (activation === undefined) throw new Error("通进司未激活");
528
+ try {
529
+ activation.ledger.beginOperational(COLLECTOR_HANDBOOK_WRITE_TOOL, toolCallId);
530
+ // scope/body shape authority = collectorHandbookWriteArgsSchema (host parameters).
531
+ const details = await activation.handbook.write(params.scope, params.body);
532
+ activation.handbookView = await activation.handbook.read();
533
+ activation.ledger.completeOperational(toolCallId);
534
+ return {
535
+ content: [{
536
+ type: "text" as const,
537
+ text: `手册已写入:${details.scope}`,
538
+ }],
539
+ details: {
540
+ scope: details.scope,
541
+ path: details.path,
542
+ byteLength: details.byteLength,
543
+ generalSource: activation.handbookView.generalSource,
544
+ repoSource: activation.handbookView.repoSource,
545
+ },
546
+ };
547
+ } catch (error) {
548
+ if (isCorrectableExecuteError(error)) throw error;
549
+ hostActions.failInfrastructure(error, ctx, toolCallId);
550
+ } finally {
551
+ try {
552
+ activation.ledger.completeOperational(toolCallId);
553
+ } catch {
554
+ // already completed or not begun
555
+ }
556
+ }
557
+ },
558
+ });
559
+
454
560
  pi.registerTool({
455
561
  name: COLLECTOR_WAIT_TOOL,
456
562
  label: "通进司等待",