@akagilnc/pi-workflow-roles 0.1.3783 → 0.1.3795

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 (37) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +3 -1
  3. package/dist/acp-host/production-host.js +681 -269
  4. package/dist/atomic-write.js +23 -0
  5. package/dist/collector-evidence.js +4 -1
  6. package/dist/collector-github.js +1 -0
  7. package/dist/collector-handbook.js +142 -0
  8. package/dist/collector-identity.js +11 -0
  9. package/dist/collector-ledger.js +109 -63
  10. package/dist/collector-role.js +182 -18
  11. package/dist/collector-tool-schemas.js +40 -3
  12. package/dist/headless-host/production-host.js +682 -270
  13. package/dist/pi/role-turn-host.js +1 -0
  14. package/dist/public-cli/invocation.js +15 -0
  15. package/dist/public-cli/main.js +45 -77
  16. package/dist/public-cli/option-definitions.js +16 -2
  17. package/dist/public-cli/run-lifecycle.js +8 -0
  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/src/acp-host/production-host.ts +4 -0
  23. package/src/acp-host/role-envelope.ts +1 -0
  24. package/src/collector-evidence.ts +6 -1
  25. package/src/collector-github.ts +3 -0
  26. package/src/collector-handbook.ts +194 -0
  27. package/src/collector-identity.ts +14 -0
  28. package/src/collector-ledger.ts +132 -80
  29. package/src/collector-role.ts +198 -14
  30. package/src/collector-tool-schemas.ts +42 -3
  31. package/src/host-contracts.ts +2 -0
  32. package/src/pi/role-turn-host.ts +1 -0
  33. package/src/public-cli/collector-run.ts +2 -0
  34. package/src/public-cli/invocation.ts +21 -0
  35. package/src/public-cli/option-definitions.ts +16 -2
  36. package/src/public-cli/run-lifecycle.ts +10 -0
  37. 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
+ }
@@ -151,6 +151,20 @@ export class CollectorNonOpenRequestError extends CorrectableSubmissionError {
151
151
  }
152
152
  }
153
153
 
154
+ /** #678: wait window not open or already ended — bounce without latching fatal so materials still seal. */
155
+ export class CollectorWaitWindowClosedError extends CorrectableSubmissionError {
156
+ constructor(action: "request" | "wait" | "wait-before-open") {
157
+ super(
158
+ action === "request"
159
+ ? "通进司请求不在资格截止前"
160
+ : action === "wait-before-open"
161
+ ? "通进司等待需要先在工作步骤开启等待窗(新建 PR 用创建成功时刻;已有 PR 在触发阶段结束后开启)"
162
+ : "通进司等待不在资格截止前",
163
+ );
164
+ this.name = "CollectorWaitWindowClosedError";
165
+ }
166
+ }
167
+
154
168
  function candidateRecord(candidate: unknown): Record<string, unknown> | undefined {
155
169
  if (candidate === undefined || candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
156
170
  return undefined;
@@ -1,10 +1,8 @@
1
- import Value from "typebox/value";
2
-
3
1
  import type { CollectorManifest, CollectorRepository } from "./collector-config.ts";
4
2
  import {
5
3
  applyEvidenceVersionHistory,
6
4
  assignWindowRelations,
7
- COLLECTOR_ELIGIBILITY_MS,
5
+ COLLECTOR_DEFAULT_WAIT_WINDOW_MS,
8
6
  measureNormalizedBytes,
9
7
  normalizeAuthenticatedUserEvidence,
10
8
  normalizeIssueCommentEvidence,
@@ -25,22 +23,22 @@ import {
25
23
  type GitHubPageDiagnostics,
26
24
  type GitHubPullRequest,
27
25
  } from "./collector-github.ts";
28
- import { CollectorNonOpenRequestError } from "./collector-identity.ts";
29
26
  import {
30
- collectorObserveArgsSchema,
31
- collectorOutputArgsSchema,
32
- collectorReadArgsSchema,
33
- collectorRequestArgsSchema,
34
- collectorWaitArgsSchema,
35
- } from "./collector-tool-schemas.ts";
27
+ CollectorNonOpenRequestError,
28
+ CollectorWaitWindowClosedError,
29
+ } from "./collector-identity.ts";
36
30
  import { COLLECTOR_OUTPUT_TOOL } from "./package-contracts/collector-output.ts";
37
31
 
38
32
  export const COLLECTOR_OBSERVE_TOOL = "ak_collector_observe";
39
33
  export const COLLECTOR_READ_TOOL = "ak_collector_read";
40
34
  export const COLLECTOR_REQUEST_TOOL = "ak_collector_request";
41
35
  export const COLLECTOR_WAIT_TOOL = "ak_collector_wait";
36
+ /** #678 D4: open wait window at a work step — business tool, ledger-booked. */
37
+ export const COLLECTOR_OPEN_WAIT_WINDOW_TOOL = "ak_collector_open_wait_window";
42
38
  /** #676 A: role-decided target bind — business tool, ledger-booked. */
43
39
  export const COLLECTOR_BIND_TARGET_TOOL = "ak_collector_bind_target";
40
+ /** #677: opaque handbook write — business tool, ledger-booked. */
41
+ export const COLLECTOR_HANDBOOK_WRITE_TOOL = "ak_collector_handbook_write";
44
42
  export { COLLECTOR_OUTPUT_TOOL };
45
43
 
46
44
  export const COLLECTOR_OPERATIONAL_TOOLS = [
@@ -48,7 +46,9 @@ export const COLLECTOR_OPERATIONAL_TOOLS = [
48
46
  COLLECTOR_OBSERVE_TOOL,
49
47
  COLLECTOR_READ_TOOL,
50
48
  COLLECTOR_REQUEST_TOOL,
49
+ COLLECTOR_OPEN_WAIT_WINDOW_TOOL,
51
50
  COLLECTOR_WAIT_TOOL,
51
+ COLLECTOR_HANDBOOK_WRITE_TOOL,
52
52
  ] as const;
53
53
 
54
54
  /**
@@ -57,7 +57,7 @@ export const COLLECTOR_OPERATIONAL_TOOLS = [
57
57
  * (evidenceId + htmlUrl) and is preserved in the volume, never unconditionally
58
58
  * transcribed into model context or receipt.
59
59
  */
60
- export const COLLECTOR_OBSERVE_BODY_HEAD_BYTES = 512;
60
+ const COLLECTOR_OBSERVE_BODY_HEAD_BYTES = 512;
61
61
 
62
62
  function utf8SafePrefix(text: string, maxBytes: number): string {
63
63
  if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
@@ -89,6 +89,8 @@ export type ObserveModelView = {
89
89
  completedAt: string;
90
90
  prState: string;
91
91
  headOid: string;
92
+ /** PR create success time when known — new-PR wait-window startedAt (#678 D4). */
93
+ prCreatedAt?: string;
92
94
  complete: boolean;
93
95
  evidence: ObserveEvidenceEntry[];
94
96
  requestAttempts: Array<{
@@ -108,7 +110,7 @@ export type ObserveModelView = {
108
110
  * (collector-side; provider-visible observe details carry only this bounded
109
111
  * projection), never unconditionally transcribed into model context.
110
112
  */
111
- export function projectObserveContextView(modelView: ObserveModelView): ObserveModelView {
113
+ function projectObserveContextView(modelView: ObserveModelView): ObserveModelView {
112
114
  return {
113
115
  ...modelView,
114
116
  evidence: modelView.evidence.map((entry) =>
@@ -121,9 +123,9 @@ export function projectObserveContextView(modelView: ObserveModelView): ObserveM
121
123
  export type CollectorOperationalTool = (typeof COLLECTOR_OPERATIONAL_TOOLS)[number];
122
124
 
123
125
  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;
126
+ const COLLECTOR_SNAPSHOT_ENTRY_TYPE = "ak-collector-snapshot" as const;
127
+ const COLLECTOR_REQUEST_ENTRY_TYPE = "ak-collector-request" as const;
128
+ const COLLECTOR_WAIT_ENTRY_TYPE = "ak-collector-wait" as const;
127
129
 
128
130
  export type CollectorRequestAttempt = {
129
131
  attemptId: string;
@@ -164,6 +166,15 @@ export type CollectorConfigState = {
164
166
  /** Bound PR target; undefined until explicit flag, admission bind, or role bind-target tool. */
165
167
  prNumber: number | undefined;
166
168
  manifest: CollectorManifest;
169
+ /**
170
+ * Wait window duration (#678 D4). Default 10 minutes; caller-configurable without code change.
171
+ * Window opens at a work step (PR create success / trigger-phase end), not session activation.
172
+ */
173
+ waitWindowMs: number;
174
+ };
175
+
176
+ export type CollectorLedgerConfigInput = Omit<CollectorConfigState, "waitWindowMs"> & {
177
+ waitWindowMs?: number;
167
178
  };
168
179
 
169
180
  /** Durable session journal sink owned by the shared execution seam. */
@@ -183,8 +194,11 @@ export type CollectorLedger = {
183
194
  readonly fatal: boolean;
184
195
  readonly fatalReason: string | undefined;
185
196
  readonly outputCandidate: boolean;
197
+ /** Session ready (before_agent_start); not the wait window. */
186
198
  readonly activationRecorded: boolean;
199
+ /** Wait-window start (receipt activationTime); undefined until openWaitWindow. */
187
200
  readonly activationTime: Date | undefined;
201
+ /** Wait-window end; undefined until openWaitWindow. */
188
202
  readonly deadlineTime: Date | undefined;
189
203
  readonly activationMono: number | undefined;
190
204
  readonly deadlineMono: number | undefined;
@@ -198,7 +212,13 @@ export type CollectorLedger = {
198
212
 
199
213
  latchFatal(reason: string, cause?: unknown): Error;
200
214
  assertNotFatal(): void;
215
+ /** Mark session ready for prep observe/request; does not open the wait window (#678). */
201
216
  recordActivation(clock: CollectorClock): void;
217
+ /**
218
+ * Open the wait window once at a work step. Optional startedAt covers new-PR create success;
219
+ * omit for existing-PR trigger-phase end (= now). Second call is a no-op (D6: updates do not reset).
220
+ */
221
+ openWaitWindow(clock: CollectorClock, options?: { startedAt?: Date }): void;
202
222
  recordOutputCandidate(): void;
203
223
  /** #676 A: role-decided unique PR bind (business fact on the ledger). */
204
224
  bindTarget(prNumber: number): void;
@@ -217,7 +237,7 @@ export type CollectorLedger = {
217
237
  }>;
218
238
 
219
239
  request(
220
- input: { requestId: string; snapshotId: string },
240
+ input: { requestId: string; snapshotId: string; body?: string },
221
241
  transport: CollectorGitHubTransport,
222
242
  clock: CollectorClock,
223
243
  signal?: AbortSignal,
@@ -243,39 +263,31 @@ function isOperationalTool(name: string): name is CollectorOperationalTool {
243
263
  return (COLLECTOR_OPERATIONAL_TOOLS as readonly string[]).includes(name);
244
264
  }
245
265
 
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
+ function resolveWaitWindowMs(raw: number | undefined): number {
267
+ if (raw === undefined) return COLLECTOR_DEFAULT_WAIT_WINDOW_MS;
268
+ if (!Number.isSafeInteger(raw) || raw < 1) {
269
+ throw new Error("通进司 waitWindowMs 须为正安全整数毫秒");
266
270
  }
271
+ return raw;
267
272
  }
268
273
 
269
274
  export function createCollectorLedger(
270
- config: CollectorConfigState,
275
+ input: CollectorLedgerConfigInput,
271
276
  options?: CollectorLedgerOptions,
272
277
  ): CollectorLedger {
278
+ const config: CollectorConfigState = {
279
+ repository: input.repository,
280
+ prNumber: input.prNumber,
281
+ manifest: input.manifest,
282
+ waitWindowMs: resolveWaitWindowMs(input.waitWindowMs),
283
+ };
273
284
  const clock = options?.clock;
274
285
  const journal = options?.journal;
275
286
  let fatal = false;
276
287
  let fatalReason: string | undefined;
277
288
  let outputCandidate = false;
278
289
  let pendingOutputCallId: string | undefined;
290
+ let sessionReady = false;
279
291
  let activationTime: Date | undefined;
280
292
  let deadlineTime: Date | undefined;
281
293
  let activationMono: number | undefined;
@@ -326,7 +338,7 @@ export function createCollectorLedger(
326
338
  };
327
339
 
328
340
  const remainingMs = (clock: CollectorClock): number => {
329
- if (deadlineMono === undefined) return COLLECTOR_ELIGIBILITY_MS;
341
+ if (deadlineMono === undefined) return config.waitWindowMs;
330
342
  return Math.max(0, deadlineMono - monoNowOrThrow(clock));
331
343
  };
332
344
 
@@ -335,9 +347,7 @@ export function createCollectorLedger(
335
347
 
336
348
  const requireBoundPr = (): number => {
337
349
  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
- );
350
+ throw new Error("通进司 PR 目标未绑定");
341
351
  }
342
352
  return config.prNumber;
343
353
  };
@@ -411,7 +421,9 @@ export function createCollectorLedger(
411
421
  };
412
422
 
413
423
  const commitActivationWindow = (nextActivation: Date, nextDeadline: Date): void => {
424
+ // D6: once open, PR updates / second open must not reset the window.
414
425
  if (activationTime !== undefined) return;
426
+ sessionReady = true;
415
427
  activationTime = nextActivation;
416
428
  deadlineTime = nextDeadline;
417
429
  bindDeadlineMonoFromWall();
@@ -551,6 +563,8 @@ export function createCollectorLedger(
551
563
  if (entry.customType === COLLECTOR_ACTIVATION_ENTRY_TYPE) {
552
564
  if (typeof data.activationTime === "string" && typeof data.deadlineTime === "string") {
553
565
  commitActivationWindow(new Date(data.activationTime), new Date(data.deadlineTime));
566
+ } else if (data.sessionReady === true) {
567
+ sessionReady = true;
554
568
  }
555
569
  continue;
556
570
  }
@@ -612,7 +626,7 @@ export function createCollectorLedger(
612
626
  return outputCandidate || pendingOutputCallId !== undefined;
613
627
  },
614
628
  get activationRecorded() {
615
- return activationTime !== undefined;
629
+ return sessionReady;
616
630
  },
617
631
  get activationTime() {
618
632
  return activationTime;
@@ -651,16 +665,38 @@ export function createCollectorLedger(
651
665
  latchFatal,
652
666
  assertNotFatal,
653
667
 
654
- recordActivation(clock) {
668
+ recordActivation(_clock) {
669
+ assertNotFatal();
670
+ if (sessionReady) return;
671
+ sessionReady = true;
672
+ // Session ready only — wait window opens later at a work step (#678 D4).
673
+ appendJournal(COLLECTOR_ACTIVATION_ENTRY_TYPE, {
674
+ sessionReady: true,
675
+ });
676
+ },
677
+
678
+ openWaitWindow(clock, options) {
655
679
  assertNotFatal();
656
- if (activationTime !== undefined) return;
657
- activationTime = clock.wallNow();
658
- activationMono = clock.monoNow();
659
- deadlineTime = new Date(activationTime.getTime() + COLLECTOR_ELIGIBILITY_MS);
660
- deadlineMono = activationMono + COLLECTOR_ELIGIBILITY_MS;
680
+ if (!sessionReady) {
681
+ throw latchFatal("通进司开启等待窗需要先激活会话");
682
+ }
683
+ if (activationTime !== undefined) return; // D6: do not reset
684
+ const startedAt = options?.startedAt ?? clock.wallNow();
685
+ if (!(startedAt instanceof Date) || Number.isNaN(startedAt.getTime())) {
686
+ throw new Error("通进司等待窗 startedAt 须为有效时间");
687
+ }
688
+ const nextDeadline = new Date(startedAt.getTime() + config.waitWindowMs);
689
+ // Bind mono relative to current clock so a past startedAt shortens remaining correctly.
690
+ activationTime = startedAt;
691
+ deadlineTime = nextDeadline;
692
+ const wallNow = clock.wallNow().getTime();
693
+ const monoNow = clock.monoNow();
694
+ activationMono = monoNow - (wallNow - startedAt.getTime());
695
+ deadlineMono = monoNow + (nextDeadline.getTime() - wallNow);
661
696
  appendJournal(COLLECTOR_ACTIVATION_ENTRY_TYPE, {
662
697
  activationTime: activationTime.toISOString(),
663
698
  deadlineTime: deadlineTime.toISOString(),
699
+ waitWindowMs: config.waitWindowMs,
664
700
  });
665
701
  },
666
702
 
@@ -675,11 +711,11 @@ export function createCollectorLedger(
675
711
  throw new Error("通进司已产出输出候选,本局不再受理目标绑定");
676
712
  }
677
713
  if (!Number.isSafeInteger(prNumber) || prNumber < 1) {
678
- throw new Error("Collector bind target requires a positive safe-integer PR number");
714
+ throw new Error("通进司绑定目标要求正安全整数 PR 号");
679
715
  }
680
716
  if (config.prNumber !== undefined && config.prNumber !== prNumber) {
681
717
  throw new Error(
682
- `Collector target already bound to PR ${config.prNumber}; cannot rebind to ${prNumber}`,
718
+ `通进司目标已绑定 PR ${config.prNumber},不可改绑为 ${prNumber}`,
683
719
  );
684
720
  }
685
721
  config.prNumber = prNumber;
@@ -764,7 +800,7 @@ export function createCollectorLedger(
764
800
 
765
801
  async observe(transport, clock, signal) {
766
802
  assertNotFatal();
767
- if (activationTime === undefined) {
803
+ if (!sessionReady) {
768
804
  throw latchFatal("通进司观察需要激活");
769
805
  }
770
806
  if (signal?.aborted) {
@@ -872,6 +908,7 @@ export function createCollectorLedger(
872
908
  prNumber: requireBoundPr(),
873
909
  prState: pr.state,
874
910
  headOid: pr.headOid,
911
+ ...(pr.createdAt === undefined ? {} : { prCreatedAt: pr.createdAt }),
875
912
  complete: true,
876
913
  evidenceIds: storedIds,
877
914
  pageDiagnostics,
@@ -908,16 +945,32 @@ export function createCollectorLedger(
908
945
 
909
946
  async request(input, transport, clock, signal) {
910
947
  assertNotFatal();
911
- if (activationTime === undefined || deadlineTime === undefined) {
948
+ if (!sessionReady) {
912
949
  throw latchFatal("通进司请求需要激活");
913
950
  }
914
951
  if (ledger.unresolvedTransportFailure) {
915
952
  throw latchFatal("通进司请求时存在未恢复的传输失败");
916
953
  }
917
954
 
918
- const request = config.manifest.requests.find((item) => item.id === input.requestId);
919
- if (request === undefined) {
920
- throw new Error(`未知通进司 requestId "${input.requestId}"`);
955
+ // Sole request identity seam: trim once, then use everywhere (lookup/marker/attemptKey).
956
+ // Schema pattern rejects leading/trailing whitespace at host; trim still collapses
957
+ // direct ledger callers and any residual padding into one stable id (#677 D2).
958
+ if (typeof input.requestId !== "string") {
959
+ throw new Error("通进司 requestId 须为字符串");
960
+ }
961
+ const requestId = input.requestId.trim();
962
+ if (requestId.length === 0) {
963
+ throw new Error("通进司 requestId 须为非空(首尾空白不构成身份)");
964
+ }
965
+ const configured = config.manifest.requests.find((item) => item.id === requestId);
966
+ const roleBody = typeof input.body === "string" ? input.body : undefined;
967
+ if (configured === undefined) {
968
+ // #677: role-decided trigger from handbook/field activity — body required when not in manifest.
969
+ if (roleBody === undefined || roleBody.trim().length === 0) {
970
+ throw new Error(
971
+ `未知通进司 requestId "${requestId}" 且未提供 body;请提交角色判定的请求正文或使用 request-manifest`,
972
+ );
973
+ }
921
974
  }
922
975
 
923
976
  const snapshot = snapshots.find((item) => item.snapshotId === input.snapshotId);
@@ -933,13 +986,16 @@ export function createCollectorLedger(
933
986
  }
934
987
  if (pastCutoff(clock)) {
935
988
  finalObservationRequired = true;
936
- throw latchFatal("通进司请求不在资格截止前");
989
+ // #678: do not latch fatal — materials must still seal after the window ends.
990
+ throw new CollectorWaitWindowClosedError("request");
937
991
  }
938
992
 
993
+ // Caller manifest body wins when requestId is configured; otherwise role body.
994
+ const configuredBody = configured?.requestBody ?? roleBody!;
939
995
  const { body, marker } = buildCollectorRequestBody({
940
- configuredBody: request.requestBody,
996
+ configuredBody,
941
997
  manifestDigest: config.manifest.digest,
942
- requestId: request.id,
998
+ requestId,
943
999
  headOid: snapshot.headOid,
944
1000
  });
945
1001
  const existingMarker = snapshot.evidenceIds.some((id) => {
@@ -951,7 +1007,7 @@ export function createCollectorLedger(
951
1007
  });
952
1008
  if (existingMarker) {
953
1009
  throw new Error(
954
- `通进司在此 HEAD 已有同 marker 的已认证请求 "${input.requestId}"`,
1010
+ `通进司在此 HEAD 已有同 marker 的已认证请求 "${requestId}"`,
955
1011
  );
956
1012
  }
957
1013
 
@@ -960,11 +1016,11 @@ export function createCollectorLedger(
960
1016
  config.repository.canonical,
961
1017
  String(boundPr),
962
1018
  snapshot.headOid,
963
- request.id,
1019
+ requestId,
964
1020
  ].join("|");
965
1021
  if (attemptKeys.has(attemptKey)) {
966
1022
  throw new Error(
967
- `通进司进程内请求 "${request.id}" 在 HEAD ${snapshot.headOid} 的 attempt 已用`,
1023
+ `通进司进程内请求 "${requestId}" 在 HEAD ${snapshot.headOid} 的 attempt 已用`,
968
1024
  );
969
1025
  }
970
1026
 
@@ -972,7 +1028,7 @@ export function createCollectorLedger(
972
1028
  const attemptId = sha256Text(`${attemptKey}:${startedAt}`).slice(0, 16);
973
1029
  const attempt: CollectorRequestAttempt = {
974
1030
  attemptId,
975
- requestId: request.id,
1031
+ requestId,
976
1032
  observedHead: snapshot.headOid,
977
1033
  snapshotId: snapshot.snapshotId,
978
1034
  marker,
@@ -1017,7 +1073,7 @@ export function createCollectorLedger(
1017
1073
  return {
1018
1074
  status: "succeeded",
1019
1075
  attemptId,
1020
- requestId: request.id,
1076
+ requestId,
1021
1077
  observedHead: snapshot.headOid,
1022
1078
  marker,
1023
1079
  commentEvidenceId: record.evidenceId,
@@ -1032,7 +1088,7 @@ export function createCollectorLedger(
1032
1088
  return {
1033
1089
  status: "ambiguous_loss",
1034
1090
  attemptId,
1035
- requestId: request.id,
1091
+ requestId,
1036
1092
  observedHead: snapshot.headOid,
1037
1093
  marker,
1038
1094
  diagnostics: result.diagnostics,
@@ -1048,30 +1104,23 @@ export function createCollectorLedger(
1048
1104
 
1049
1105
  async wait(input, clock, signal) {
1050
1106
  assertNotFatal();
1051
- if (activationTime === undefined) {
1107
+ if (!sessionReady) {
1052
1108
  throw latchFatal("通进司等待需要激活");
1053
1109
  }
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
- );
1110
+ // #678: wait never invents a window start. Work-step open (create-success / trigger-end)
1111
+ // must be explicit — silent "now" would break the new-PR create-success clock.
1112
+ if (activationTime === undefined || deadlineTime === undefined) {
1113
+ throw new CollectorWaitWindowClosedError("wait-before-open");
1061
1114
  }
1115
+ // durationMs shape authority = collectorWaitArgsSchema (host parameters).
1062
1116
  if (pastCutoff(clock)) {
1063
1117
  finalObservationRequired = true;
1064
- throw latchFatal("通进司等待不在资格截止前");
1118
+ throw new CollectorWaitWindowClosedError("wait");
1065
1119
  }
1066
1120
 
1067
1121
  const remaining = remainingMs(clock);
1068
- // Single-wait runtime cadence cap (v2 §6 / §9); schema max stays 15m.
1069
- const COLLECTOR_SINGLE_WAIT_MAX_MS = 300_000;
1070
- const effectiveMs = Math.min(
1071
- input.durationMs,
1072
- remaining,
1073
- COLLECTOR_SINGLE_WAIT_MAX_MS,
1074
- );
1122
+ // Cap only by remaining window — no package-local single-wait ceiling (ADR 0035 / #678).
1123
+ const effectiveMs = Math.min(input.durationMs, remaining);
1075
1124
  const startedAt = clock.wallNow().toISOString();
1076
1125
  const waitId = sha256Text(`wait:${startedAt}:${effectiveMs}`).slice(0, 16);
1077
1126
  await clock.sleep(effectiveMs, signal);
@@ -1163,6 +1212,9 @@ function buildObserveModelView(input: {
1163
1212
  completedAt: input.snapshot.completedAt,
1164
1213
  prState: input.snapshot.prState,
1165
1214
  headOid: input.snapshot.headOid,
1215
+ ...(input.snapshot.prCreatedAt === undefined
1216
+ ? {}
1217
+ : { prCreatedAt: input.snapshot.prCreatedAt }),
1166
1218
  complete: input.snapshot.complete,
1167
1219
  evidence: relevant.map((record): ObserveEvidenceEntry => projectEvidenceEntryView(record)),
1168
1220
  requestAttempts: input.attempts.map((attempt) => ({