@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,23 @@
1
+ /**
2
+ * Same-directory temp + rename atomic file replace.
3
+ * Shared primitive for ledger-adjacent typed pages (analyst metrics, etc.).
4
+ * Does not open/truncate an existing destination inode, so hard-linked twins
5
+ * keep prior bytes until the directory entry is swapped.
6
+ * Parent directory must already exist — callers that write under the package
7
+ * ledger home own confinement via ensureRealDirectoryTree (ADR 0038).
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { rename, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ export async function writeFileAtomically(destination, contents) {
13
+ const parent = dirname(destination);
14
+ const temporary = join(parent, `.atomic-write-${randomUUID()}.tmp`);
15
+ try {
16
+ await writeFile(temporary, contents);
17
+ await rename(temporary, destination);
18
+ }
19
+ catch (error) {
20
+ await rm(temporary, { force: true }).catch(() => undefined);
21
+ throw error;
22
+ }
23
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- export const COLLECTOR_ELIGIBILITY_MS = 15 * 60 * 1000;
2
+ /** Default collector wait window (D4): 10 minutes from the work-step open, not from session activation. */
3
+ export const COLLECTOR_DEFAULT_WAIT_WINDOW_MS = 10 * 60 * 1000;
3
4
  export function createSystemCollectorClock() {
4
5
  const start = process.hrtime.bigint();
5
6
  return {
@@ -76,6 +77,7 @@ export function normalizePullRequestEvidence(pr, observedAt) {
76
77
  number: pr.number,
77
78
  state: pr.state,
78
79
  headOid: pr.headOid,
80
+ createdAt: pr.createdAt ?? null,
79
81
  updatedAt: pr.updatedAt ?? null,
80
82
  htmlUrl: pr.url,
81
83
  });
@@ -89,6 +91,7 @@ export function normalizePullRequestEvidence(pr, observedAt) {
89
91
  state: pr.state,
90
92
  commitOid: pr.headOid,
91
93
  htmlUrl: pr.url,
94
+ // Version clock stays updatedAt. Create success for the wait window is snapshot.prCreatedAt only (#678).
92
95
  authoritativeTime: pr.updatedAt ?? null,
93
96
  firstObservedAt: observedAt,
94
97
  raw: pr.raw,
@@ -289,6 +289,7 @@ export function normalizePullRequest(raw) {
289
289
  number,
290
290
  state,
291
291
  headOid: head["sha"],
292
+ ...(typeof raw["created_at"] === "string" ? { createdAt: raw["created_at"] } : {}),
292
293
  ...(typeof raw["updated_at"] === "string" ? { updatedAt: raw["updated_at"] } : {}),
293
294
  url: htmlUrl,
294
295
  raw,
@@ -0,0 +1,142 @@
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
+ import { writeFileAtomically } from "./atomic-write.js";
12
+ import { activationBookDirectory, assertLedgerFileInsideHome, ensureRealDirectoryTree, resolveActivationLedgerHomeForPath, } from "./activation-ledger-topology.js";
13
+ import { COLLECTOR_HANDBOOK_MAX_BYTES } from "./collector-tool-schemas.js";
14
+ /**
15
+ * Derive handbook root from an admitted session/run path under
16
+ * `.ak-roles/books/<bookKey>/...`. Fails closed when topology is absent.
17
+ */
18
+ export function resolveCollectorHandbookRoot(sessionPath) {
19
+ if (typeof sessionPath !== "string" || sessionPath.trim().length === 0) {
20
+ throw new Error("通进司手册要求非空 session 路径,且位于 books/<bookKey>/");
21
+ }
22
+ // Platform path segments only — never rewrite non-separator characters (POSIX `\\` is literal).
23
+ const segments = sessionPath.split(sep);
24
+ let bookKey;
25
+ for (let i = 0; i + 3 < segments.length; i += 1) {
26
+ if (segments[i] === ".ak-roles" && segments[i + 1] === "books") {
27
+ const candidate = segments[i + 2];
28
+ // Require a following segment so bookKey is a true path component under books/.
29
+ if (candidate.length > 0) {
30
+ bookKey = candidate;
31
+ break;
32
+ }
33
+ }
34
+ }
35
+ if (bookKey === undefined) {
36
+ throw new Error(`通进司手册要求 session 位于 books/<bookKey>/;收到 ${sessionPath}`);
37
+ }
38
+ if (bookKey === "." || bookKey === "..") {
39
+ throw new Error(`通进司手册拒绝不安全 bookKey ${JSON.stringify(bookKey)}`);
40
+ }
41
+ const ledgerHome = resolveActivationLedgerHomeForPath(sessionPath);
42
+ const root = join(activationBookDirectory(ledgerHome, bookKey), "collector-handbook");
43
+ return { ledgerHome, bookKey, root };
44
+ }
45
+ /** Flat repo file name under handbook/repos/ — avoids nested owner/repo dirs. */
46
+ function collectorHandbookRepoFileName(repositoryCanonical) {
47
+ if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(repositoryCanonical)) {
48
+ throw new Error(`通进司手册仓库文件要求规范 owner/repo,收到 ${JSON.stringify(repositoryCanonical)}`);
49
+ }
50
+ return `${repositoryCanonical.replaceAll("/", "__")}.md`;
51
+ }
52
+ export function createCollectorHandbookStore(input) {
53
+ const generalPath = join(input.handbookRoot, "general.md");
54
+ const repoDir = join(input.handbookRoot, "repos");
55
+ const repoPath = join(repoDir, collectorHandbookRepoFileName(input.repositoryCanonical));
56
+ /**
57
+ * ADR 0038: confine at the real read seam. Parent dirs must be physical under
58
+ * ledger home; the leaf must not be a pre-existing symlink (even in-home).
59
+ */
60
+ /** Sole UTF-8 budget seam — write and read share COLLECTOR_HANDBOOK_MAX_BYTES. */
61
+ const assertHandbookBudget = (body, label) => {
62
+ const byteLength = Buffer.byteLength(body, "utf8");
63
+ if (byteLength > COLLECTOR_HANDBOOK_MAX_BYTES) {
64
+ throw new Error(`通进司手册${label} UTF-8 至多 ${COLLECTOR_HANDBOOK_MAX_BYTES} 字节,收到 ${byteLength}`);
65
+ }
66
+ return byteLength;
67
+ };
68
+ const readOptional = async (path, parentDir) => {
69
+ ensureRealDirectoryTree(input.ledgerHome, parentDir);
70
+ assertLedgerFileInsideHome(path, input.ledgerHome);
71
+ try {
72
+ const body = await readFile(path, "utf8");
73
+ assertHandbookBudget(body, "正文");
74
+ return body;
75
+ }
76
+ catch (error) {
77
+ if (isNotFound(error))
78
+ return undefined;
79
+ throw error;
80
+ }
81
+ };
82
+ return {
83
+ root: input.handbookRoot,
84
+ repositoryCanonical: input.repositoryCanonical,
85
+ async read() {
86
+ const bookGeneral = await readOptional(generalPath, input.handbookRoot);
87
+ const bookRepo = await readOptional(repoPath, repoDir);
88
+ if (bookGeneral !== undefined) {
89
+ return {
90
+ general: bookGeneral,
91
+ repo: bookRepo ?? "",
92
+ generalSource: "book",
93
+ repoSource: bookRepo === undefined ? "empty" : "book",
94
+ generalPath,
95
+ repoPath,
96
+ };
97
+ }
98
+ const seed = input.seedGeneral;
99
+ if (typeof seed === "string" && seed.length > 0) {
100
+ assertHandbookBudget(seed, "正文");
101
+ return {
102
+ general: seed,
103
+ repo: bookRepo ?? "",
104
+ generalSource: "seed",
105
+ repoSource: bookRepo === undefined ? "empty" : "book",
106
+ generalPath,
107
+ repoPath,
108
+ };
109
+ }
110
+ return {
111
+ general: "",
112
+ repo: bookRepo ?? "",
113
+ generalSource: "empty",
114
+ repoSource: bookRepo === undefined ? "empty" : "book",
115
+ generalPath,
116
+ repoPath,
117
+ };
118
+ },
119
+ async write(scope, body) {
120
+ // body string shape = collectorHandbookWriteArgsSchema; byte budget is business (UTF-8).
121
+ const byteLength = assertHandbookBudget(body, "正文");
122
+ ensureRealDirectoryTree(input.ledgerHome, input.handbookRoot);
123
+ const path = scope === "general" ? generalPath : repoPath;
124
+ if (scope === "repo") {
125
+ ensureRealDirectoryTree(input.ledgerHome, repoDir);
126
+ }
127
+ assertLedgerFileInsideHome(path, input.ledgerHome);
128
+ await writeFileAtomically(path, body);
129
+ return {
130
+ scope,
131
+ path,
132
+ byteLength,
133
+ };
134
+ },
135
+ };
136
+ }
137
+ function isNotFound(error) {
138
+ return typeof error === "object"
139
+ && error !== null
140
+ && "code" in error
141
+ && error.code === "ENOENT";
142
+ }
@@ -90,6 +90,17 @@ export class CollectorNonOpenRequestError extends CorrectableSubmissionError {
90
90
  this.name = "CollectorNonOpenRequestError";
91
91
  }
92
92
  }
93
+ /** #678: wait window not open or already ended — bounce without latching fatal so materials still seal. */
94
+ export class CollectorWaitWindowClosedError extends CorrectableSubmissionError {
95
+ constructor(action) {
96
+ super(action === "request"
97
+ ? "通进司请求不在资格截止前"
98
+ : action === "wait-before-open"
99
+ ? "通进司等待需要先在工作步骤开启等待窗(新建 PR 用创建成功时刻;已有 PR 在触发阶段结束后开启)"
100
+ : "通进司等待不在资格截止前");
101
+ this.name = "CollectorWaitWindowClosedError";
102
+ }
103
+ }
93
104
  function candidateRecord(candidate) {
94
105
  if (candidate === undefined || candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
95
106
  return undefined;
@@ -1,22 +1,26 @@
1
- import Value from "typebox/value";
2
- import { applyEvidenceVersionHistory, assignWindowRelations, COLLECTOR_ELIGIBILITY_MS, measureNormalizedBytes, normalizeAuthenticatedUserEvidence, normalizeIssueCommentEvidence, normalizePullRequestEvidence, normalizePullRequestReactionEvidence, normalizeReviewCommentEvidence, normalizeReviewEvidence, sha256Text, } from "./collector-evidence.js";
1
+ import { applyEvidenceVersionHistory, assignWindowRelations, COLLECTOR_DEFAULT_WAIT_WINDOW_MS, measureNormalizedBytes, normalizeAuthenticatedUserEvidence, normalizeIssueCommentEvidence, normalizePullRequestEvidence, normalizePullRequestReactionEvidence, normalizeReviewCommentEvidence, normalizeReviewEvidence, sha256Text, } from "./collector-evidence.js";
3
2
  import { buildCollectorRequestBody, } from "./collector-github.js";
4
- import { CollectorNonOpenRequestError } from "./collector-identity.js";
5
- import { collectorObserveArgsSchema, collectorOutputArgsSchema, collectorReadArgsSchema, collectorRequestArgsSchema, collectorWaitArgsSchema, } from "./collector-tool-schemas.js";
3
+ import { CollectorNonOpenRequestError, CollectorWaitWindowClosedError, } from "./collector-identity.js";
6
4
  import { COLLECTOR_OUTPUT_TOOL } from "./package-contracts/collector-output.js";
7
5
  export const COLLECTOR_OBSERVE_TOOL = "ak_collector_observe";
8
6
  export const COLLECTOR_READ_TOOL = "ak_collector_read";
9
7
  export const COLLECTOR_REQUEST_TOOL = "ak_collector_request";
10
8
  export const COLLECTOR_WAIT_TOOL = "ak_collector_wait";
9
+ /** #678 D4: open wait window at a work step — business tool, ledger-booked. */
10
+ export const COLLECTOR_OPEN_WAIT_WINDOW_TOOL = "ak_collector_open_wait_window";
11
11
  /** #676 A: role-decided target bind — business tool, ledger-booked. */
12
12
  export const COLLECTOR_BIND_TARGET_TOOL = "ak_collector_bind_target";
13
+ /** #677: opaque handbook write — business tool, ledger-booked. */
14
+ export const COLLECTOR_HANDBOOK_WRITE_TOOL = "ak_collector_handbook_write";
13
15
  export { COLLECTOR_OUTPUT_TOOL };
14
16
  export const COLLECTOR_OPERATIONAL_TOOLS = [
15
17
  COLLECTOR_BIND_TARGET_TOOL,
16
18
  COLLECTOR_OBSERVE_TOOL,
17
19
  COLLECTOR_READ_TOOL,
18
20
  COLLECTOR_REQUEST_TOOL,
21
+ COLLECTOR_OPEN_WAIT_WINDOW_TOOL,
19
22
  COLLECTOR_WAIT_TOOL,
23
+ COLLECTOR_HANDBOOK_WRITE_TOOL,
20
24
  ];
21
25
  /**
22
26
  * #641 chain①: observe context projects at most this many bytes of each
@@ -24,7 +28,7 @@ export const COLLECTOR_OPERATIONAL_TOOLS = [
24
28
  * (evidenceId + htmlUrl) and is preserved in the volume, never unconditionally
25
29
  * transcribed into model context or receipt.
26
30
  */
27
- export const COLLECTOR_OBSERVE_BODY_HEAD_BYTES = 512;
31
+ const COLLECTOR_OBSERVE_BODY_HEAD_BYTES = 512;
28
32
  function utf8SafePrefix(text, maxBytes) {
29
33
  if (Buffer.byteLength(text, "utf8") <= maxBytes)
30
34
  return text;
@@ -41,7 +45,7 @@ function utf8SafePrefix(text, maxBytes) {
41
45
  * (collector-side; provider-visible observe details carry only this bounded
42
46
  * projection), never unconditionally transcribed into model context.
43
47
  */
44
- export function projectObserveContextView(modelView) {
48
+ function projectObserveContextView(modelView) {
45
49
  return {
46
50
  ...modelView,
47
51
  evidence: modelView.evidence.map((entry) => entry.body === undefined
@@ -50,39 +54,34 @@ export function projectObserveContextView(modelView) {
50
54
  };
51
55
  }
52
56
  export const COLLECTOR_ACTIVATION_ENTRY_TYPE = "ak-collector-activation";
53
- export const COLLECTOR_SNAPSHOT_ENTRY_TYPE = "ak-collector-snapshot";
54
- export const COLLECTOR_REQUEST_ENTRY_TYPE = "ak-collector-request";
55
- export const COLLECTOR_WAIT_ENTRY_TYPE = "ak-collector-wait";
57
+ const COLLECTOR_SNAPSHOT_ENTRY_TYPE = "ak-collector-snapshot";
58
+ const COLLECTOR_REQUEST_ENTRY_TYPE = "ak-collector-request";
59
+ const COLLECTOR_WAIT_ENTRY_TYPE = "ak-collector-wait";
56
60
  function isOperationalTool(name) {
57
61
  return COLLECTOR_OPERATIONAL_TOOLS.includes(name);
58
62
  }
59
- /** Shared Check owner for Collector tool argument shapes (schema seam, not batch law). */
60
- export function collectorToolArgumentsValid(name, args) {
61
- // Residual envelope: reject missing/null args before schema check.
62
- if (args === undefined || args === null)
63
- return false;
64
- switch (name) {
65
- case COLLECTOR_OBSERVE_TOOL:
66
- return Value.Check(collectorObserveArgsSchema, args);
67
- case COLLECTOR_READ_TOOL:
68
- return Value.Check(collectorReadArgsSchema, args);
69
- case COLLECTOR_REQUEST_TOOL:
70
- return Value.Check(collectorRequestArgsSchema, args);
71
- case COLLECTOR_WAIT_TOOL:
72
- return Value.Check(collectorWaitArgsSchema, args);
73
- case COLLECTOR_OUTPUT_TOOL:
74
- return Value.Check(collectorOutputArgsSchema, args);
75
- default:
76
- return false;
63
+ function resolveWaitWindowMs(raw) {
64
+ if (raw === undefined)
65
+ return COLLECTOR_DEFAULT_WAIT_WINDOW_MS;
66
+ if (!Number.isSafeInteger(raw) || raw < 1) {
67
+ throw new Error("通进司 waitWindowMs 须为正安全整数毫秒");
77
68
  }
69
+ return raw;
78
70
  }
79
- export function createCollectorLedger(config, options) {
71
+ export function createCollectorLedger(input, options) {
72
+ const config = {
73
+ repository: input.repository,
74
+ prNumber: input.prNumber,
75
+ manifest: input.manifest,
76
+ waitWindowMs: resolveWaitWindowMs(input.waitWindowMs),
77
+ };
80
78
  const clock = options?.clock;
81
79
  const journal = options?.journal;
82
80
  let fatal = false;
83
81
  let fatalReason;
84
82
  let outputCandidate = false;
85
83
  let pendingOutputCallId;
84
+ let sessionReady = false;
86
85
  let activationTime;
87
86
  let deadlineTime;
88
87
  let activationMono;
@@ -129,13 +128,13 @@ export function createCollectorLedger(config, options) {
129
128
  };
130
129
  const remainingMs = (clock) => {
131
130
  if (deadlineMono === undefined)
132
- return COLLECTOR_ELIGIBILITY_MS;
131
+ return config.waitWindowMs;
133
132
  return Math.max(0, deadlineMono - monoNowOrThrow(clock));
134
133
  };
135
134
  const prIdentity = (pr) => `${pr.state}|${pr.headOid}|${pr.updatedAt ?? ""}`;
136
135
  const requireBoundPr = () => {
137
136
  if (config.prNumber === undefined) {
138
- throw new Error("Collector PR target is unbound; call ak_collector_bind_target with the role-decided issue/PR or pass --pr");
137
+ throw new Error("通进司 PR 目标未绑定");
139
138
  }
140
139
  return config.prNumber;
141
140
  };
@@ -200,8 +199,10 @@ export function createCollectorLedger(config, options) {
200
199
  return copy;
201
200
  };
202
201
  const commitActivationWindow = (nextActivation, nextDeadline) => {
202
+ // D6: once open, PR updates / second open must not reset the window.
203
203
  if (activationTime !== undefined)
204
204
  return;
205
+ sessionReady = true;
205
206
  activationTime = nextActivation;
206
207
  deadlineTime = nextDeadline;
207
208
  bindDeadlineMonoFromWall();
@@ -329,6 +330,9 @@ export function createCollectorLedger(config, options) {
329
330
  if (typeof data.activationTime === "string" && typeof data.deadlineTime === "string") {
330
331
  commitActivationWindow(new Date(data.activationTime), new Date(data.deadlineTime));
331
332
  }
333
+ else if (data.sessionReady === true) {
334
+ sessionReady = true;
335
+ }
332
336
  continue;
333
337
  }
334
338
  if (entry.customType === COLLECTOR_SNAPSHOT_ENTRY_TYPE) {
@@ -389,7 +393,7 @@ export function createCollectorLedger(config, options) {
389
393
  return outputCandidate || pendingOutputCallId !== undefined;
390
394
  },
391
395
  get activationRecorded() {
392
- return activationTime !== undefined;
396
+ return sessionReady;
393
397
  },
394
398
  get activationTime() {
395
399
  return activationTime;
@@ -426,17 +430,39 @@ export function createCollectorLedger(config, options) {
426
430
  },
427
431
  latchFatal,
428
432
  assertNotFatal,
429
- recordActivation(clock) {
433
+ recordActivation(_clock) {
430
434
  assertNotFatal();
431
- if (activationTime !== undefined)
435
+ if (sessionReady)
432
436
  return;
433
- activationTime = clock.wallNow();
434
- activationMono = clock.monoNow();
435
- deadlineTime = new Date(activationTime.getTime() + COLLECTOR_ELIGIBILITY_MS);
436
- deadlineMono = activationMono + COLLECTOR_ELIGIBILITY_MS;
437
+ sessionReady = true;
438
+ // Session ready only — wait window opens later at a work step (#678 D4).
439
+ appendJournal(COLLECTOR_ACTIVATION_ENTRY_TYPE, {
440
+ sessionReady: true,
441
+ });
442
+ },
443
+ openWaitWindow(clock, options) {
444
+ assertNotFatal();
445
+ if (!sessionReady) {
446
+ throw latchFatal("通进司开启等待窗需要先激活会话");
447
+ }
448
+ if (activationTime !== undefined)
449
+ return; // D6: do not reset
450
+ const startedAt = options?.startedAt ?? clock.wallNow();
451
+ if (!(startedAt instanceof Date) || Number.isNaN(startedAt.getTime())) {
452
+ throw new Error("通进司等待窗 startedAt 须为有效时间");
453
+ }
454
+ const nextDeadline = new Date(startedAt.getTime() + config.waitWindowMs);
455
+ // Bind mono relative to current clock so a past startedAt shortens remaining correctly.
456
+ activationTime = startedAt;
457
+ deadlineTime = nextDeadline;
458
+ const wallNow = clock.wallNow().getTime();
459
+ const monoNow = clock.monoNow();
460
+ activationMono = monoNow - (wallNow - startedAt.getTime());
461
+ deadlineMono = monoNow + (nextDeadline.getTime() - wallNow);
437
462
  appendJournal(COLLECTOR_ACTIVATION_ENTRY_TYPE, {
438
463
  activationTime: activationTime.toISOString(),
439
464
  deadlineTime: deadlineTime.toISOString(),
465
+ waitWindowMs: config.waitWindowMs,
440
466
  });
441
467
  },
442
468
  recordOutputCandidate() {
@@ -449,10 +475,10 @@ export function createCollectorLedger(config, options) {
449
475
  throw new Error("通进司已产出输出候选,本局不再受理目标绑定");
450
476
  }
451
477
  if (!Number.isSafeInteger(prNumber) || prNumber < 1) {
452
- throw new Error("Collector bind target requires a positive safe-integer PR number");
478
+ throw new Error("通进司绑定目标要求正安全整数 PR 号");
453
479
  }
454
480
  if (config.prNumber !== undefined && config.prNumber !== prNumber) {
455
- throw new Error(`Collector target already bound to PR ${config.prNumber}; cannot rebind to ${prNumber}`);
481
+ throw new Error(`通进司目标已绑定 PR ${config.prNumber},不可改绑为 ${prNumber}`);
456
482
  }
457
483
  config.prNumber = prNumber;
458
484
  appendJournal("ak-collector-target-bound", {
@@ -521,7 +547,7 @@ export function createCollectorLedger(config, options) {
521
547
  },
522
548
  async observe(transport, clock, signal) {
523
549
  assertNotFatal();
524
- if (activationTime === undefined) {
550
+ if (!sessionReady) {
525
551
  throw latchFatal("通进司观察需要激活");
526
552
  }
527
553
  if (signal?.aborted) {
@@ -611,6 +637,7 @@ export function createCollectorLedger(config, options) {
611
637
  prNumber: requireBoundPr(),
612
638
  prState: pr.state,
613
639
  headOid: pr.headOid,
640
+ ...(pr.createdAt === undefined ? {} : { prCreatedAt: pr.createdAt }),
614
641
  complete: true,
615
642
  evidenceIds: storedIds,
616
643
  pageDiagnostics,
@@ -644,15 +671,29 @@ export function createCollectorLedger(config, options) {
644
671
  },
645
672
  async request(input, transport, clock, signal) {
646
673
  assertNotFatal();
647
- if (activationTime === undefined || deadlineTime === undefined) {
674
+ if (!sessionReady) {
648
675
  throw latchFatal("通进司请求需要激活");
649
676
  }
650
677
  if (ledger.unresolvedTransportFailure) {
651
678
  throw latchFatal("通进司请求时存在未恢复的传输失败");
652
679
  }
653
- const request = config.manifest.requests.find((item) => item.id === input.requestId);
654
- if (request === undefined) {
655
- throw new Error(`未知通进司 requestId "${input.requestId}"`);
680
+ // Sole request identity seam: trim once, then use everywhere (lookup/marker/attemptKey).
681
+ // Schema pattern rejects leading/trailing whitespace at host; trim still collapses
682
+ // direct ledger callers and any residual padding into one stable id (#677 D2).
683
+ if (typeof input.requestId !== "string") {
684
+ throw new Error("通进司 requestId 须为字符串");
685
+ }
686
+ const requestId = input.requestId.trim();
687
+ if (requestId.length === 0) {
688
+ throw new Error("通进司 requestId 须为非空(首尾空白不构成身份)");
689
+ }
690
+ const configured = config.manifest.requests.find((item) => item.id === requestId);
691
+ const roleBody = typeof input.body === "string" ? input.body : undefined;
692
+ if (configured === undefined) {
693
+ // #677: role-decided trigger from handbook/field activity — body required when not in manifest.
694
+ if (roleBody === undefined || roleBody.trim().length === 0) {
695
+ throw new Error(`未知通进司 requestId "${requestId}" 且未提供 body;请提交角色判定的请求正文或使用 request-manifest`);
696
+ }
656
697
  }
657
698
  const snapshot = snapshots.find((item) => item.snapshotId === input.snapshotId);
658
699
  if (snapshot === undefined) {
@@ -667,12 +708,15 @@ export function createCollectorLedger(config, options) {
667
708
  }
668
709
  if (pastCutoff(clock)) {
669
710
  finalObservationRequired = true;
670
- throw latchFatal("通进司请求不在资格截止前");
711
+ // #678: do not latch fatal — materials must still seal after the window ends.
712
+ throw new CollectorWaitWindowClosedError("request");
671
713
  }
714
+ // Caller manifest body wins when requestId is configured; otherwise role body.
715
+ const configuredBody = configured?.requestBody ?? roleBody;
672
716
  const { body, marker } = buildCollectorRequestBody({
673
- configuredBody: request.requestBody,
717
+ configuredBody,
674
718
  manifestDigest: config.manifest.digest,
675
- requestId: request.id,
719
+ requestId,
676
720
  headOid: snapshot.headOid,
677
721
  });
678
722
  const existingMarker = snapshot.evidenceIds.some((id) => {
@@ -683,23 +727,23 @@ export function createCollectorLedger(config, options) {
683
727
  record.body.includes(marker);
684
728
  });
685
729
  if (existingMarker) {
686
- throw new Error(`通进司在此 HEAD 已有同 marker 的已认证请求 "${input.requestId}"`);
730
+ throw new Error(`通进司在此 HEAD 已有同 marker 的已认证请求 "${requestId}"`);
687
731
  }
688
732
  const boundPr = requireBoundPr();
689
733
  const attemptKey = [
690
734
  config.repository.canonical,
691
735
  String(boundPr),
692
736
  snapshot.headOid,
693
- request.id,
737
+ requestId,
694
738
  ].join("|");
695
739
  if (attemptKeys.has(attemptKey)) {
696
- throw new Error(`通进司进程内请求 "${request.id}" 在 HEAD ${snapshot.headOid} 的 attempt 已用`);
740
+ throw new Error(`通进司进程内请求 "${requestId}" 在 HEAD ${snapshot.headOid} 的 attempt 已用`);
697
741
  }
698
742
  const startedAt = clock.wallNow().toISOString();
699
743
  const attemptId = sha256Text(`${attemptKey}:${startedAt}`).slice(0, 16);
700
744
  const attempt = {
701
745
  attemptId,
702
- requestId: request.id,
746
+ requestId,
703
747
  observedHead: snapshot.headOid,
704
748
  snapshotId: snapshot.snapshotId,
705
749
  marker,
@@ -741,7 +785,7 @@ export function createCollectorLedger(config, options) {
741
785
  return {
742
786
  status: "succeeded",
743
787
  attemptId,
744
- requestId: request.id,
788
+ requestId,
745
789
  observedHead: snapshot.headOid,
746
790
  marker,
747
791
  commentEvidenceId: record.evidenceId,
@@ -755,7 +799,7 @@ export function createCollectorLedger(config, options) {
755
799
  return {
756
800
  status: "ambiguous_loss",
757
801
  attemptId,
758
- requestId: request.id,
802
+ requestId,
759
803
  observedHead: snapshot.headOid,
760
804
  marker,
761
805
  diagnostics: result.diagnostics,
@@ -769,23 +813,22 @@ export function createCollectorLedger(config, options) {
769
813
  },
770
814
  async wait(input, clock, signal) {
771
815
  assertNotFatal();
772
- if (activationTime === undefined) {
816
+ if (!sessionReady) {
773
817
  throw latchFatal("通进司等待需要激活");
774
818
  }
775
- if (!Number.isSafeInteger(input.durationMs) || input.durationMs < 1) {
776
- throw new Error("通进司等待 durationMs 须为正安全整数");
777
- }
778
- if (input.durationMs > COLLECTOR_ELIGIBILITY_MS) {
779
- throw new Error(`通进司等待 durationMs 至多为 ${COLLECTOR_ELIGIBILITY_MS}`);
819
+ // #678: wait never invents a window start. Work-step open (create-success / trigger-end)
820
+ // must be explicit — silent "now" would break the new-PR create-success clock.
821
+ if (activationTime === undefined || deadlineTime === undefined) {
822
+ throw new CollectorWaitWindowClosedError("wait-before-open");
780
823
  }
824
+ // durationMs shape authority = collectorWaitArgsSchema (host parameters).
781
825
  if (pastCutoff(clock)) {
782
826
  finalObservationRequired = true;
783
- throw latchFatal("通进司等待不在资格截止前");
827
+ throw new CollectorWaitWindowClosedError("wait");
784
828
  }
785
829
  const remaining = remainingMs(clock);
786
- // Single-wait runtime cadence cap (v2 §6 / §9); schema max stays 15m.
787
- const COLLECTOR_SINGLE_WAIT_MAX_MS = 300_000;
788
- const effectiveMs = Math.min(input.durationMs, remaining, COLLECTOR_SINGLE_WAIT_MAX_MS);
830
+ // Cap only by remaining window — no package-local single-wait ceiling (ADR 0035 / #678).
831
+ const effectiveMs = Math.min(input.durationMs, remaining);
789
832
  const startedAt = clock.wallNow().toISOString();
790
833
  const waitId = sha256Text(`wait:${startedAt}:${effectiveMs}`).slice(0, 16);
791
834
  await clock.sleep(effectiveMs, signal);
@@ -868,6 +911,9 @@ function buildObserveModelView(input) {
868
911
  completedAt: input.snapshot.completedAt,
869
912
  prState: input.snapshot.prState,
870
913
  headOid: input.snapshot.headOid,
914
+ ...(input.snapshot.prCreatedAt === undefined
915
+ ? {}
916
+ : { prCreatedAt: input.snapshot.prCreatedAt }),
871
917
  complete: input.snapshot.complete,
872
918
  evidence: relevant.map((record) => projectEvidenceEntryView(record)),
873
919
  requestAttempts: input.attempts.map((attempt) => ({