@akagilnc/pi-workflow-roles 0.1.1751

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 (192) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +104 -0
  3. package/README.zh-CN.md +133 -0
  4. package/THIRD_PARTY_NOTICES.md +60 -0
  5. package/dist/activation-ledger-git.js +68 -0
  6. package/dist/activation-ledger-session.js +120 -0
  7. package/dist/activation-ledger-topology.js +239 -0
  8. package/dist/activation-reconciliation.js +61 -0
  9. package/dist/audit-escalation.js +108 -0
  10. package/dist/auditor-dossier-tool.js +35 -0
  11. package/dist/canonical-json.js +78 -0
  12. package/dist/compliance-transport.js +77 -0
  13. package/dist/doctor-contracts.js +172 -0
  14. package/dist/dossier-resolution.js +103 -0
  15. package/dist/evidence-child-executor.js +661 -0
  16. package/dist/exact-utf8.js +12 -0
  17. package/dist/git-object-id.js +7 -0
  18. package/dist/in-process-session.js +50 -0
  19. package/dist/merger-contracts.js +76 -0
  20. package/dist/navigator-attendance.js +995 -0
  21. package/dist/navigator-invocation-identity.js +220 -0
  22. package/dist/open-tool-schema.js +39 -0
  23. package/dist/package-contracts/collector-output.js +50 -0
  24. package/dist/package-contracts/fixer-output.js +72 -0
  25. package/dist/package-contracts/fixer-packet.js +77 -0
  26. package/dist/package-contracts/judge-output.js +17 -0
  27. package/dist/package-contracts/reviewer-output.js +82 -0
  28. package/dist/package-contracts/terminating-tools.js +173 -0
  29. package/dist/package-contracts/worker-output.js +13 -0
  30. package/dist/package-owned-tool-idle.js +104 -0
  31. package/dist/packaged-role-registry.js +34 -0
  32. package/dist/public-cli/main.js +23867 -0
  33. package/dist/public-command-renderer.js +20 -0
  34. package/dist/reviewer-agent.js +93 -0
  35. package/dist/reviewer-child-executor.js +23 -0
  36. package/dist/reviewer-construction.js +95 -0
  37. package/dist/reviewer-dispatch.js +77 -0
  38. package/dist/reviewer-execution-ledger.js +160 -0
  39. package/dist/reviewer-failure-diagnostic.js +17 -0
  40. package/dist/reviewer-git-snapshot.js +38 -0
  41. package/dist/reviewer-pinned-git.js +146 -0
  42. package/dist/reviewer-preflight-error.js +15 -0
  43. package/dist/reviewer-prompt-identity.js +10 -0
  44. package/dist/reviewer-scope-prompt.js +21 -0
  45. package/dist/reviewer-workspace.js +151 -0
  46. package/dist/sha256.js +5 -0
  47. package/dist/sitian-record-entry.js +33 -0
  48. package/dist/stderr-jsonl.js +26 -0
  49. package/dist/stream-idle-guard.js +75 -0
  50. package/dist/tool-execution-observation.js +141 -0
  51. package/dist/uuidv7.js +21 -0
  52. package/dist/work-subject-identity.js +53 -0
  53. package/extensions/role-runtime.ts +303 -0
  54. package/package.json +69 -0
  55. package/packets/fixer-prerequisites.json +6 -0
  56. package/packets/fixer-repair.md +5 -0
  57. package/packets/judge-apply.md +77 -0
  58. package/packets/judge-authority.md +64 -0
  59. package/packets/judge-plan.md +55 -0
  60. package/packets/judge-review.md +49 -0
  61. package/packets/judge-submission.md +34 -0
  62. package/resources/methods/code-review/SKILL.md +92 -0
  63. package/resources/methods/code-review/agents/openai.yaml +3 -0
  64. package/resources/methods/code-review/provenance.json +26 -0
  65. package/resources/methods/diagnosing-bugs/SKILL.md +134 -0
  66. package/resources/methods/diagnosing-bugs/agents/openai.yaml +3 -0
  67. package/resources/methods/diagnosing-bugs/provenance.json +31 -0
  68. package/resources/methods/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
  69. package/resources/methods/resolving-merge-conflicts/SKILL.md +14 -0
  70. package/resources/methods/resolving-merge-conflicts/agents/openai.yaml +3 -0
  71. package/resources/methods/resolving-merge-conflicts/provenance.json +26 -0
  72. package/resources/methods/tdd/SKILL.md +38 -0
  73. package/resources/methods/tdd/agents/openai.yaml +3 -0
  74. package/resources/methods/tdd/mocking.md +59 -0
  75. package/resources/methods/tdd/provenance.json +36 -0
  76. package/resources/methods/tdd/tests.md +77 -0
  77. package/resources/navigator-route-playbook.md +32 -0
  78. package/schemas/tool-execution-observation.schema.json +107 -0
  79. package/scripts/build-package.mjs +65 -0
  80. package/scripts/generate-tool-execution-observation-schema.ts +7 -0
  81. package/souls/coder.md +10 -0
  82. package/souls/collector.md +11 -0
  83. package/souls/doctor-auditor.md +23 -0
  84. package/souls/doctor.md +8 -0
  85. package/souls/fixer-auditor.md +33 -0
  86. package/souls/fixer.md +13 -0
  87. package/souls/judge-auditor.md +33 -0
  88. package/souls/judge.md +74 -0
  89. package/souls/merger.md +5 -0
  90. package/souls/navigator.md +5 -0
  91. package/souls/reviewer-auditor.md +25 -0
  92. package/souls/reviewer.md +11 -0
  93. package/src/activation-ledger-git.ts +96 -0
  94. package/src/activation-ledger-session.ts +188 -0
  95. package/src/activation-ledger-topology.ts +301 -0
  96. package/src/activation-ledger.ts +240 -0
  97. package/src/activation-reconciliation.ts +163 -0
  98. package/src/activation-trace.ts +38 -0
  99. package/src/audit-escalation.ts +177 -0
  100. package/src/auditor-dossier-tool.ts +48 -0
  101. package/src/auditor-soul.ts +28 -0
  102. package/src/canonical-json.ts +74 -0
  103. package/src/canonical-skill-binding.ts +107 -0
  104. package/src/collector-config.ts +89 -0
  105. package/src/collector-evidence.ts +461 -0
  106. package/src/collector-github.ts +656 -0
  107. package/src/collector-identity.ts +161 -0
  108. package/src/collector-ledger.ts +827 -0
  109. package/src/collector-receipt.ts +87 -0
  110. package/src/collector-role.ts +592 -0
  111. package/src/collector-tool-schemas.ts +19 -0
  112. package/src/compliance-transport.ts +130 -0
  113. package/src/doctor-auditor.ts +53 -0
  114. package/src/doctor-contracts.ts +166 -0
  115. package/src/doctor-evidence.ts +47 -0
  116. package/src/doctor-role.ts +18 -0
  117. package/src/dossier-resolution.ts +137 -0
  118. package/src/evidence-child-executor.ts +775 -0
  119. package/src/exact-utf8.ts +9 -0
  120. package/src/factory-board.ts +1822 -0
  121. package/src/git-object-id.ts +11 -0
  122. package/src/human-format.ts +65 -0
  123. package/src/in-process-session.ts +78 -0
  124. package/src/judge-auditor.ts +55 -0
  125. package/src/judge-recording-anti-forge.ts +53 -0
  126. package/src/judge-role.ts +160 -0
  127. package/src/merger-contracts.ts +71 -0
  128. package/src/merger-git-state.ts +76 -0
  129. package/src/merger-role.ts +60 -0
  130. package/src/navigator-attendance.ts +1254 -0
  131. package/src/navigator-invocation-identity.ts +446 -0
  132. package/src/open-tool-schema.ts +46 -0
  133. package/src/package-contracts/collector-output.ts +109 -0
  134. package/src/package-contracts/fixer-output.ts +81 -0
  135. package/src/package-contracts/fixer-packet.ts +93 -0
  136. package/src/package-contracts/judge-output.ts +39 -0
  137. package/src/package-contracts/reviewer-output.ts +115 -0
  138. package/src/package-contracts/terminating-tools.ts +259 -0
  139. package/src/package-contracts/worker-output.ts +36 -0
  140. package/src/package-owned-tool-idle.ts +134 -0
  141. package/src/package-resources/method-skill-binding.ts +87 -0
  142. package/src/package-resources/method-skill.ts +358 -0
  143. package/src/packaged-role-registry.ts +36 -0
  144. package/src/public-cli/cli-errors.ts +11 -0
  145. package/src/public-cli/cli-io.ts +4 -0
  146. package/src/public-cli/cli.ts +912 -0
  147. package/src/public-cli/coder-run.ts +575 -0
  148. package/src/public-cli/collector-run.ts +375 -0
  149. package/src/public-cli/command-renderer.ts +8 -0
  150. package/src/public-cli/config.ts +346 -0
  151. package/src/public-cli/doctor-run.ts +355 -0
  152. package/src/public-cli/explicit-internal.ts +274 -0
  153. package/src/public-cli/fixer-run.ts +587 -0
  154. package/src/public-cli/host-pi-runtime.ts +112 -0
  155. package/src/public-cli/invocation.ts +1958 -0
  156. package/src/public-cli/judge-run.ts +507 -0
  157. package/src/public-cli/main.ts +15 -0
  158. package/src/public-cli/merger-run.ts +681 -0
  159. package/src/public-cli/public-run-credentials.ts +71 -0
  160. package/src/public-cli/registry.ts +153 -0
  161. package/src/public-cli/reviewer-run.ts +561 -0
  162. package/src/public-cli/run-lifecycle.ts +884 -0
  163. package/src/public-cli/settlement.ts +3765 -0
  164. package/src/public-cli/terminal.ts +325 -0
  165. package/src/public-command-renderer.ts +43 -0
  166. package/src/reviewer-agent.ts +94 -0
  167. package/src/reviewer-auditor.ts +53 -0
  168. package/src/reviewer-child-executor.ts +31 -0
  169. package/src/reviewer-construction.ts +137 -0
  170. package/src/reviewer-dispatch.ts +94 -0
  171. package/src/reviewer-execution-ledger.ts +206 -0
  172. package/src/reviewer-failure-diagnostic.ts +18 -0
  173. package/src/reviewer-git-snapshot.ts +53 -0
  174. package/src/reviewer-pinned-git.ts +144 -0
  175. package/src/reviewer-preflight-error.ts +14 -0
  176. package/src/reviewer-prompt-identity.ts +17 -0
  177. package/src/reviewer-role.ts +193 -0
  178. package/src/reviewer-scope-prompt.ts +24 -0
  179. package/src/reviewer-settlement.ts +63 -0
  180. package/src/reviewer-workspace.ts +111 -0
  181. package/src/role-runtime.ts +884 -0
  182. package/src/sha256.ts +6 -0
  183. package/src/sitian-record-entry.ts +57 -0
  184. package/src/stderr-jsonl.ts +28 -0
  185. package/src/stream-idle-guard.ts +98 -0
  186. package/src/ticket-snapshot.ts +662 -0
  187. package/src/ticket-trajectory.ts +1000 -0
  188. package/src/tool-execution-observation.ts +168 -0
  189. package/src/uuidv7.ts +1 -0
  190. package/src/work-subject-identity.ts +94 -0
  191. package/src/worker-role.ts +434 -0
  192. package/src/worker-submission-gates.ts +225 -0
@@ -0,0 +1,1000 @@
1
+ /**
2
+ * Single-ticket trajectory tracer (factory board S1).
3
+ *
4
+ * Mechanism, not a role: deterministic scan → HTML. Unique seam:
5
+ * (ledgerDir, ticketSnapshot, now) → HTML
6
+ *
7
+ * Station resolution (four-layer fallback):
8
+ * 1) terminating tool name inside the session (ak_<role>_output)
9
+ * 2) invocation.json role
10
+ * 3) run-directory name heuristic
11
+ * 4) unknown (still listed; never dropped)
12
+ *
13
+ * Receipt trust: only successful toolResults that pass typed contract
14
+ * validation count as round results. Prior rejected attempts stay attempts.
15
+ *
16
+ * Page lifecycle: startTicketTrajectoryPage owns refresh regeneration to an
17
+ * explicit output path outside the ledger and declares the bound; one-shot
18
+ * write does not advertise refresh. Regeneration faults surface the original
19
+ * cause via handle.closed / stop(). Caller stops the handle.
20
+ */
21
+ import { randomUUID } from "node:crypto";
22
+ import { lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
23
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+
26
+ import {
27
+ formatDurationZh,
28
+ formatLocalDateTime,
29
+ formatTokensCompact,
30
+ formatUsdPrecise,
31
+ } from "./human-format.ts";
32
+ import {
33
+ AcceptedDetailsContractError,
34
+ acceptedFacts,
35
+ isTerminatingToolName,
36
+ validateAcceptedDetails,
37
+ type TerminatingToolName,
38
+ } from "./package-contracts/terminating-tools.ts";
39
+ import { PACKAGED_ROLE_REGISTRY } from "./packaged-role-registry.ts";
40
+
41
+ /** Declared refresh bound for the same viewing surface (seconds). */
42
+ export const DEFAULT_REFRESH_BOUNDARY_SECONDS = 30;
43
+
44
+ /** Minimal ticket snapshot stub for S1 (no GitHub adapter). */
45
+ export type TicketSnapshot = {
46
+ issueNumber: number;
47
+ };
48
+
49
+ export type StationSource = "tool" | "invocation" | "name" | "unknown";
50
+
51
+ /** Injected clock + scheduler so tests drive the production lifecycle without wall sleep. */
52
+ export type TrajectoryClock = () => Date;
53
+ export type TrajectoryScheduler = {
54
+ /** Schedule `tick` every `ms` milliseconds; return a cancel function. */
55
+ every: (ms: number, tick: () => void) => () => void;
56
+ };
57
+
58
+ export type TicketTrajectoryPageHandle = {
59
+ readonly outputPath: string;
60
+ /** Settles when the first page write finishes (or rejects on first failure). */
61
+ readonly started: Promise<{ outputPath: string; html: string }>;
62
+ /**
63
+ * Settles when the lifecycle ends.
64
+ * Resolves on a clean stop with no regeneration fault; rejects with the
65
+ * original cause when a post-start regeneration fails (or the initial write fails).
66
+ */
67
+ readonly closed: Promise<void>;
68
+ /**
69
+ * Stop further regeneration. In-flight write is awaited.
70
+ * Re-throws the original regeneration failure when the lifecycle faulted.
71
+ */
72
+ stop: () => Promise<void>;
73
+ };
74
+
75
+ type SessionRow = Record<string, unknown>;
76
+
77
+ /** One ledger run as loaded by the S1 tracer (shared with the S2/S3 board). */
78
+ export type TicketTrajectoryRun = {
79
+ runId: string;
80
+ ledgerCoord: string;
81
+ evidenceHref: string;
82
+ startedAt?: string;
83
+ /** Last session-record timestamp (parent session only; axis legs excluded). */
84
+ endedAt?: string;
85
+ /**
86
+ * Newest session-record timestamp across parent session + axis-leg sessions.
87
+ * Differs from endedAt when an axis leg recorded activity after the parent.
88
+ */
89
+ lastActivityAt?: string;
90
+ /** Latest mtime among parent session + axis-leg session files (ms since epoch). */
91
+ mtimeMs: number;
92
+ /** Sum of message.usage.cost.total across parent session + axis legs. */
93
+ costUsd: number;
94
+ /** Sum of message.usage.totalTokens across parent session + axis legs. */
95
+ totalTokens: number;
96
+ /** Sum of first→last wall ms across axis-leg sessions (parent excluded). */
97
+ axisWallMs: number;
98
+ /**
99
+ * Display wall ms for this run when a consumer precomputes it (board applies
100
+ * the "latest unaccepted ends at now" rule). Absent → first→last of parent.
101
+ */
102
+ wallMs?: number;
103
+ station: string;
104
+ stationSource: StationSource;
105
+ attemptCount: number;
106
+ hasResult: boolean;
107
+ /** Receipt-level status when the terminating contract carries one. */
108
+ resultStatus: string;
109
+ model: string;
110
+ provider: string;
111
+ thinking: string;
112
+ };
113
+
114
+ type ParsedRun = TicketTrajectoryRun;
115
+
116
+ function isRecord(value: unknown): value is Record<string, unknown> {
117
+ return typeof value === "object" && value !== null && !Array.isArray(value);
118
+ }
119
+
120
+ function isMissingPathError(error: unknown): boolean {
121
+ return error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
122
+ }
123
+
124
+ /** realpath when the node exists; lexical path only for recognized absence — never for other errors. */
125
+ async function realpathOrLexicalIfMissing(path: string): Promise<string> {
126
+ try {
127
+ return await realpath(path);
128
+ } catch (error) {
129
+ if (isMissingPathError(error)) return path;
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ const TOOL_TO_ROLE: ReadonlyMap<string, string> = new Map(
135
+ PACKAGED_ROLE_REGISTRY.map((entry) => [entry.outputTool, entry.role]),
136
+ );
137
+
138
+ const NAME_PREFIX_ROLES: readonly string[] = PACKAGED_ROLE_REGISTRY.map((entry) => entry.role);
139
+
140
+ function roleFromToolName(toolName: string): string | undefined {
141
+ return TOOL_TO_ROLE.get(toolName);
142
+ }
143
+
144
+ function roleFromRunName(runId: string): string | undefined {
145
+ const base = runId.split("@")[0] ?? runId;
146
+ const lower = base.toLowerCase();
147
+ // plan-court / *-court* → judge (ticket-court family)
148
+ if (/(^|[-_])court([-_]|$)/.test(lower) || lower.startsWith("plan-court")) return "judge";
149
+ for (const role of NAME_PREFIX_ROLES) {
150
+ if (lower === role || lower.startsWith(`${role}-`) || lower.startsWith(`${role}_`)) return role;
151
+ }
152
+ // review-* shorthand used heavily in the home ledger
153
+ if (lower.startsWith("review")) return "reviewer";
154
+ return undefined;
155
+ }
156
+
157
+ function escapeHtml(text: string): string {
158
+ return text
159
+ .replaceAll("&", "&amp;")
160
+ .replaceAll("<", "&lt;")
161
+ .replaceAll(">", "&gt;")
162
+ .replaceAll('"', "&quot;")
163
+ .replaceAll("'", "&#39;");
164
+ }
165
+
166
+ function attr(value: string): string {
167
+ return escapeHtml(value);
168
+ }
169
+
170
+ /**
171
+ * Read session JSONL with honest live-tail semantics:
172
+ * a malformed line is tolerated only when it is an unfinished final
173
+ * fragment at EOF (no record terminator after it). Any malformed line
174
+ * completed by a line terminator must fail loudly with file and 1-based
175
+ * line context — even when no non-empty record follows — never silently
176
+ * under-count.
177
+ */
178
+ export async function readLedgerSessionJsonl(path: string): Promise<SessionRow[]> {
179
+ const text = await readFile(path, "utf8");
180
+ // split keeps a trailing empty segment iff text ends with "\n", so
181
+ // index < lines.length - 1 means this segment was terminated.
182
+ const lines = text.split("\n");
183
+ const rows: SessionRow[] = [];
184
+ for (let index = 0; index < lines.length; index += 1) {
185
+ const line = lines[index]!;
186
+ if (!line.trim()) continue;
187
+ let row: unknown;
188
+ try {
189
+ row = JSON.parse(line);
190
+ } catch (error) {
191
+ if (!(error instanceof SyntaxError)) throw error;
192
+ const completedByTerminator = index < lines.length - 1;
193
+ if (completedByTerminator) {
194
+ throw new Error(
195
+ `malformed JSONL record in ${path} at line ${index + 1}: ${error.message}`,
196
+ );
197
+ }
198
+ // unfinished fragment at EOF — keep prior complete rows
199
+ break;
200
+ }
201
+ // Syntactically complete line: must be a session object. Silent omission
202
+ // would under-count ledger evidence (failure honesty).
203
+ if (!isRecord(row)) {
204
+ const kind = row === null ? "null" : Array.isArray(row) ? "array" : typeof row;
205
+ throw new Error(
206
+ `complete non-object JSONL record in ${path} at line ${index + 1}: expected object, got ${kind}`,
207
+ );
208
+ }
209
+ rows.push(row);
210
+ }
211
+ return rows;
212
+ }
213
+
214
+ function extractModelFields(rows: SessionRow[]): { model: string; provider: string; thinking: string } {
215
+ let model = "";
216
+ let provider = "";
217
+ let thinking = "";
218
+ for (const row of rows) {
219
+ if (row.type === "model_change") {
220
+ if (typeof row.provider === "string" && row.provider) provider = row.provider;
221
+ if (typeof row.modelId === "string" && row.modelId) model = row.modelId;
222
+ }
223
+ if (row.type === "thinking_level_change" && typeof row.thinkingLevel === "string" && row.thinkingLevel) {
224
+ thinking = row.thinkingLevel;
225
+ }
226
+ const message = isRecord(row.message) ? row.message : undefined;
227
+ if (message?.role === "assistant") {
228
+ if (typeof message.model === "string" && message.model) model = message.model;
229
+ if (typeof message.provider === "string" && message.provider) provider = message.provider;
230
+ }
231
+ }
232
+ return { model, provider, thinking };
233
+ }
234
+
235
+ /** First and last record timestamps in encounter order. */
236
+ function extractTimestampSpan(rows: SessionRow[]): { startedAt?: string; endedAt?: string } {
237
+ let startedAt: string | undefined;
238
+ let endedAt: string | undefined;
239
+ for (const row of rows) {
240
+ if (typeof row.timestamp !== "string" || !row.timestamp) continue;
241
+ if (startedAt === undefined) startedAt = row.timestamp;
242
+ endedAt = row.timestamp;
243
+ }
244
+ return {
245
+ ...(startedAt !== undefined ? { startedAt } : {}),
246
+ ...(endedAt !== undefined ? { endedAt } : {}),
247
+ };
248
+ }
249
+
250
+ /** Sum budget dollars and tokens from message.usage on session rows. */
251
+ function extractUsageTotals(rows: SessionRow[]): { costUsd: number; totalTokens: number } {
252
+ let costUsd = 0;
253
+ let totalTokens = 0;
254
+ for (const row of rows) {
255
+ const message = isRecord(row.message) ? row.message : undefined;
256
+ const usage = message && isRecord(message.usage) ? message.usage : isRecord(row.usage) ? row.usage : undefined;
257
+ if (!usage) continue;
258
+ if (typeof usage.totalTokens === "number" && Number.isFinite(usage.totalTokens)) {
259
+ totalTokens += usage.totalTokens;
260
+ }
261
+ const cost = isRecord(usage.cost) ? usage.cost : undefined;
262
+ if (cost && typeof cost.total === "number" && Number.isFinite(cost.total)) {
263
+ costUsd += cost.total;
264
+ }
265
+ }
266
+ return { costUsd, totalTokens };
267
+ }
268
+
269
+ function wallMsBetween(startedAt: string | undefined, endedAt: string | undefined): number {
270
+ if (!startedAt || !endedAt) return 0;
271
+ const start = Date.parse(startedAt);
272
+ const end = Date.parse(endedAt);
273
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0;
274
+ return end - start;
275
+ }
276
+
277
+ function extractTerminatingLifecycle(rows: SessionRow[]): {
278
+ attemptCount: number;
279
+ toolNames: string[];
280
+ hasResult: boolean;
281
+ resultStatus: string;
282
+ } {
283
+ let callAttempts = 0;
284
+ let resultAttempts = 0;
285
+ const toolNames: string[] = [];
286
+ let hasResult = false;
287
+ let resultStatus = "";
288
+
289
+ for (const row of rows) {
290
+ const message = isRecord(row.message) ? row.message : undefined;
291
+ if (!message) continue;
292
+
293
+ if (message.role === "assistant" && Array.isArray(message.content)) {
294
+ for (const part of message.content) {
295
+ if (!isRecord(part) || part.type !== "toolCall") continue;
296
+ const name = part.name;
297
+ if (typeof name === "string" && isTerminatingToolName(name)) {
298
+ callAttempts += 1;
299
+ toolNames.push(name);
300
+ }
301
+ }
302
+ }
303
+
304
+ if (message.role === "toolResult" && typeof message.toolName === "string" && isTerminatingToolName(message.toolName)) {
305
+ resultAttempts += 1;
306
+ toolNames.push(message.toolName);
307
+ if (message.isError === true) continue;
308
+ if (!isRecord(message.details)) continue;
309
+ try {
310
+ const details = validateAcceptedDetails(message.toolName as TerminatingToolName, message.details);
311
+ const facts = acceptedFacts(message.toolName as TerminatingToolName, details);
312
+ hasResult = true;
313
+ resultStatus = facts.status ?? "";
314
+ } catch (error) {
315
+ if (error instanceof AcceptedDetailsContractError) continue;
316
+ throw error;
317
+ }
318
+ }
319
+ }
320
+
321
+ // Prefer toolCall count; fall back to toolResult count when calls were clipped away.
322
+ const attemptCount = callAttempts > 0 ? callAttempts : resultAttempts;
323
+ return { attemptCount, toolNames, hasResult, resultStatus };
324
+ }
325
+
326
+ type InvocationInfo = {
327
+ role?: string;
328
+ model?: string;
329
+ provider?: string;
330
+ thinking?: string;
331
+ };
332
+
333
+ async function readInvocation(runDir: string): Promise<InvocationInfo | undefined> {
334
+ try {
335
+ const raw = await readFile(join(runDir, "invocation.json"), "utf8");
336
+ const parsed: unknown = JSON.parse(raw);
337
+ if (!isRecord(parsed)) return undefined;
338
+ const info: InvocationInfo = {};
339
+ if (typeof parsed.role === "string" && parsed.role.trim()) info.role = parsed.role.trim();
340
+ if (typeof parsed.thinking === "string" && parsed.thinking.trim()) info.thinking = parsed.thinking.trim();
341
+ if (typeof parsed.model === "string" && parsed.model.trim()) {
342
+ const rawModel = parsed.model.trim();
343
+ if (rawModel.includes("/")) {
344
+ const slash = rawModel.indexOf("/");
345
+ info.provider = rawModel.slice(0, slash);
346
+ info.model = rawModel.slice(slash + 1);
347
+ } else {
348
+ info.model = rawModel;
349
+ }
350
+ }
351
+ return info;
352
+ } catch (error) {
353
+ // Only genuine absence activates the invocation fallback. Malformed JSON and
354
+ // unexpected IO failures retain their cause — never relabeled as "no invocation."
355
+ if (isMissingPathError(error)) return undefined;
356
+ throw error;
357
+ }
358
+ }
359
+
360
+ function resolveStation(input: {
361
+ toolNames: string[];
362
+ invocationRole?: string;
363
+ runId: string;
364
+ }): { station: string; stationSource: StationSource } {
365
+ for (const toolName of input.toolNames) {
366
+ const role = roleFromToolName(toolName);
367
+ if (role) return { station: role, stationSource: "tool" };
368
+ }
369
+ // Also accept tool names observed only via accepted results order
370
+ if (input.invocationRole) {
371
+ return { station: input.invocationRole, stationSource: "invocation" };
372
+ }
373
+ const byName = roleFromRunName(input.runId);
374
+ if (byName) return { station: byName, stationSource: "name" };
375
+ return { station: "unknown", stationSource: "unknown" };
376
+ }
377
+
378
+ async function listSessionFiles(sessionDir: string): Promise<string[]> {
379
+ try {
380
+ const entries = await readdir(sessionDir, { withFileTypes: true });
381
+ return entries
382
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
383
+ .map((entry) => join(sessionDir, entry.name))
384
+ .sort();
385
+ } catch (error) {
386
+ if (isMissingPathError(error)) return [];
387
+ throw error;
388
+ }
389
+ }
390
+
391
+ /** Reviewer parallel axis-leg sessions live under session/reviewer-legs/. */
392
+ async function listAxisLegSessionFiles(sessionDir: string): Promise<string[]> {
393
+ return listSessionFiles(join(sessionDir, "reviewer-legs"));
394
+ }
395
+
396
+ async function maxMtimeMs(paths: readonly string[]): Promise<number> {
397
+ let max = 0;
398
+ for (const path of paths) {
399
+ try {
400
+ const st = await lstat(path);
401
+ const ms = st.mtimeMs;
402
+ if (Number.isFinite(ms) && ms > max) max = ms;
403
+ } catch (error) {
404
+ if (isMissingPathError(error)) continue;
405
+ throw error;
406
+ }
407
+ }
408
+ return max;
409
+ }
410
+
411
+ async function parseRun(ledgerDir: string, issueNumber: number, runId: string): Promise<ParsedRun> {
412
+ const runDir = join(ledgerDir, "issues", String(issueNumber), "runs", runId);
413
+ const ledgerCoord = ["issues", String(issueNumber), "runs", runId].join("/");
414
+ const evidenceTarget = await realpathOrLexicalIfMissing(runDir);
415
+ const evidenceHref = pathToFileURL(evidenceTarget).href;
416
+ const sessionDir = join(runDir, "session");
417
+ const sessionFiles = await listSessionFiles(sessionDir);
418
+ const axisLegFiles = await listAxisLegSessionFiles(sessionDir);
419
+ const rows: SessionRow[] = [];
420
+ for (const file of sessionFiles) {
421
+ rows.push(...(await readLedgerSessionJsonl(file)));
422
+ }
423
+
424
+ // Prefer explicit session header timestamp as start; else first record.
425
+ let startedAt: string | undefined;
426
+ for (const row of rows) {
427
+ if (row.type === "session" && typeof row.timestamp === "string") {
428
+ startedAt = row.timestamp;
429
+ break;
430
+ }
431
+ if (!startedAt && typeof row.timestamp === "string") startedAt = row.timestamp;
432
+ }
433
+ const parentSpan = extractTimestampSpan(rows);
434
+ if (startedAt === undefined) startedAt = parentSpan.startedAt;
435
+ const endedAt = parentSpan.endedAt;
436
+
437
+ const parentUsage = extractUsageTotals(rows);
438
+ let costUsd = parentUsage.costUsd;
439
+ let totalTokens = parentUsage.totalTokens;
440
+ let axisWallMs = 0;
441
+ // Newest content activity across parent + axis (not parent-only endedAt).
442
+ let lastActivityAt = parentSpan.endedAt;
443
+ for (const file of axisLegFiles) {
444
+ const legRows = await readLedgerSessionJsonl(file);
445
+ const legUsage = extractUsageTotals(legRows);
446
+ costUsd += legUsage.costUsd;
447
+ totalTokens += legUsage.totalTokens;
448
+ const legSpan = extractTimestampSpan(legRows);
449
+ axisWallMs += wallMsBetween(legSpan.startedAt, legSpan.endedAt);
450
+ if (
451
+ legSpan.endedAt !== undefined &&
452
+ (lastActivityAt === undefined || legSpan.endedAt > lastActivityAt)
453
+ ) {
454
+ lastActivityAt = legSpan.endedAt;
455
+ }
456
+ }
457
+
458
+ const mtimeMs = await maxMtimeMs([...sessionFiles, ...axisLegFiles]);
459
+
460
+ const lifecycle = extractTerminatingLifecycle(rows);
461
+ const models = extractModelFields(rows);
462
+ const invocation = await readInvocation(runDir);
463
+ const { station, stationSource } = resolveStation({
464
+ toolNames: lifecycle.toolNames,
465
+ runId,
466
+ ...(invocation?.role !== undefined ? { invocationRole: invocation.role } : {}),
467
+ });
468
+
469
+ // Session mechanical fields win; invocation.json fills gaps only.
470
+ let { model, provider, thinking } = models;
471
+ if (invocation) {
472
+ if (!thinking && invocation.thinking) thinking = invocation.thinking;
473
+ if (!provider && invocation.provider) provider = invocation.provider;
474
+ if (!model && invocation.model) model = invocation.model;
475
+ }
476
+
477
+ return {
478
+ runId,
479
+ ledgerCoord,
480
+ evidenceHref,
481
+ ...(startedAt !== undefined ? { startedAt } : {}),
482
+ ...(endedAt !== undefined ? { endedAt } : {}),
483
+ ...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
484
+ mtimeMs,
485
+ costUsd,
486
+ totalTokens,
487
+ axisWallMs,
488
+ station,
489
+ stationSource,
490
+ attemptCount: lifecycle.attemptCount,
491
+ hasResult: lifecycle.hasResult,
492
+ resultStatus: lifecycle.resultStatus,
493
+ model,
494
+ provider,
495
+ thinking,
496
+ };
497
+ }
498
+
499
+ async function listRunIds(ledgerDir: string, issueNumber: number): Promise<string[]> {
500
+ const runsDir = join(ledgerDir, "issues", String(issueNumber), "runs");
501
+ try {
502
+ const entries = await readdir(runsDir, { withFileTypes: true });
503
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
504
+ } catch (error) {
505
+ if (isMissingPathError(error)) return [];
506
+ throw error;
507
+ }
508
+ }
509
+
510
+ function sortRuns(runs: readonly ParsedRun[]): ParsedRun[] {
511
+ return [...runs].sort((a, b) => {
512
+ const at = a.startedAt ?? "";
513
+ const bt = b.startedAt ?? "";
514
+ if (at !== bt) return at.localeCompare(bt);
515
+ return a.runId.localeCompare(b.runId);
516
+ });
517
+ }
518
+
519
+ /**
520
+ * Render station/run blocks from already-loaded S1 runs.
521
+ * Shared by the single-ticket page and the S2 factory board (no second receipt parser).
522
+ */
523
+ function formatUsd(value: number): string {
524
+ // Full precision mechanical string — presentation may round; machines parse the attr.
525
+ return Number.isFinite(value) ? String(value) : "0";
526
+ }
527
+
528
+ function parentWallMs(run: TicketTrajectoryRun): number {
529
+ if (typeof run.wallMs === "number" && Number.isFinite(run.wallMs) && run.wallMs >= 0) {
530
+ return run.wallMs;
531
+ }
532
+ return wallMsBetween(run.startedAt, run.endedAt);
533
+ }
534
+
535
+ export function renderTicketTrajectoryStationHtml(runs: readonly TicketTrajectoryRun[]): string {
536
+ const stationOrder: string[] = [];
537
+ const byStation = new Map<string, ParsedRun[]>();
538
+ const sortedRuns = sortRuns(runs);
539
+ for (const run of sortedRuns) {
540
+ if (!byStation.has(run.station)) {
541
+ byStation.set(run.station, []);
542
+ stationOrder.push(run.station);
543
+ }
544
+ byStation.get(run.station)!.push(run);
545
+ }
546
+
547
+ return stationOrder
548
+ .map((station) => {
549
+ const rounds = byStation.get(station)!;
550
+ const stationLabel = station === "unknown" ? "未知站" : station;
551
+ let stationCost = 0;
552
+ let stationTokens = 0;
553
+ let stationWall = 0;
554
+ for (const run of rounds) {
555
+ stationCost += run.costUsd;
556
+ stationTokens += run.totalTokens;
557
+ // Station wall = each run's (possibly now-extended) wall + axis legs folded in.
558
+ stationWall += parentWallMs(run) + run.axisWallMs;
559
+ }
560
+ const roundHtml = rounds
561
+ .map((run) => {
562
+ const resultDisplay = run.hasResult
563
+ ? run.resultStatus ||
564
+ run.resultStatus
565
+ : "";
566
+ // Machine channel: space-separated closed-enum tokens (no custom status dialect).
567
+ const wall = parentWallMs(run);
568
+ return [
569
+ `<article class="run"`,
570
+ ` data-run-id="${attr(run.runId)}"`,
571
+ ` data-station="${attr(run.station)}"`,
572
+ ` data-station-source="${attr(run.stationSource)}"`,
573
+ ` data-ledger-coord="${attr(run.ledgerCoord)}"`,
574
+ ` data-attempt-count="${attr(String(run.attemptCount))}"`,
575
+ ` data-has-result="${run.hasResult ? "true" : "false"}"`,
576
+ ` data-result-status="${attr(run.resultStatus)}"`,
577
+ ` data-model="${attr(run.model)}"`,
578
+ ` data-provider="${attr(run.provider)}"`,
579
+ ` data-thinking="${attr(run.thinking)}"`,
580
+ run.startedAt ? ` data-started-at="${attr(run.startedAt)}"` : "",
581
+ run.endedAt ? ` data-ended-at="${attr(run.endedAt)}"` : "",
582
+ ` data-mtime-ms="${attr(String(run.mtimeMs))}"`,
583
+ ` data-cost-usd="${attr(formatUsd(run.costUsd))}"`,
584
+ ` data-total-tokens="${attr(String(run.totalTokens))}"`,
585
+ ` data-wall-ms="${attr(String(wall))}"`,
586
+ ` data-axis-wall-ms="${attr(String(run.axisWallMs))}"`,
587
+ `>`,
588
+ `<header class="run-head">`,
589
+ `<span class="run-id">${escapeHtml(run.runId)}</span>`,
590
+ run.model || run.provider || run.thinking
591
+ ? `<span class="run-model">${escapeHtml([run.provider, run.model].filter(Boolean).join("/"))}${run.thinking ? ` · ${escapeHtml(run.thinking)}` : ""}</span>`
592
+ : "",
593
+ `</header>`,
594
+ `<p class="run-meta">`,
595
+ `<span class="attempts">attempts: ${run.attemptCount}</span>`,
596
+ run.hasResult
597
+ ? `<span class="result">result: ${escapeHtml(resultDisplay)}</span>`
598
+ : `<span class="result">result: (none — attempts only)</span>`,
599
+ `<span class="cost">$${escapeHtml(formatUsdPrecise(run.costUsd))} · ${escapeHtml(formatTokensCompact(run.totalTokens))} tok</span>`,
600
+ `<span class="wall">墙钟 ${escapeHtml(formatDurationZh(wall))}</span>`,
601
+ `</p>`,
602
+ `<p class="ledger"><a data-ledger-link="${attr(run.ledgerCoord)}" href="${attr(run.evidenceHref)}">${escapeHtml(run.ledgerCoord)}</a></p>`,
603
+ `</article>`,
604
+ ].join("");
605
+ })
606
+ .join("\n");
607
+
608
+ return [
609
+ `<section class="station" data-station-block="${attr(station)}" data-round-count="${rounds.length}" data-station-cost-usd="${attr(formatUsd(stationCost))}" data-station-total-tokens="${attr(String(stationTokens))}" data-station-wall-ms="${attr(String(stationWall))}">`,
610
+ `<h2 class="station-title">${escapeHtml(stationLabel)} · ${rounds.length} 轮 · $${escapeHtml(formatUsdPrecise(stationCost))} · ${escapeHtml(formatTokensCompact(stationTokens))} tok · 墙钟 ${escapeHtml(formatDurationZh(stationWall))}</h2>`,
611
+ roundHtml,
612
+ `</section>`,
613
+ ].join("\n");
614
+ })
615
+ .join("\n");
616
+ }
617
+
618
+ function renderHtml(input: {
619
+ issueNumber: number;
620
+ generatedAt: string;
621
+ /** When set, page declares a refresh bound backed by active regeneration. Omit for one-shot. */
622
+ refreshBoundarySeconds?: number;
623
+ runs: ParsedRun[];
624
+ }): string {
625
+ const refreshActive =
626
+ input.refreshBoundarySeconds !== undefined &&
627
+ Number.isFinite(input.refreshBoundarySeconds) &&
628
+ input.refreshBoundarySeconds > 0;
629
+ const refreshBoundarySeconds = refreshActive ? input.refreshBoundarySeconds! : undefined;
630
+ const sortedRuns = sortRuns(input.runs);
631
+ const stationBlocks = renderTicketTrajectoryStationHtml(sortedRuns);
632
+
633
+ const lifecycleAttrs = refreshActive
634
+ ? ` data-lifecycle="refresh" data-refresh-boundary-seconds="${attr(String(refreshBoundarySeconds))}"`
635
+ : ` data-lifecycle="oneshot"`;
636
+ const refreshMeta = refreshActive
637
+ ? `
638
+ <meta http-equiv="refresh" content="${attr(String(refreshBoundarySeconds))}"/>`
639
+ : "";
640
+ const refreshNote = refreshActive
641
+ ? `\n · refresh ≤ ${escapeHtml(String(refreshBoundarySeconds))}s`
642
+ : "";
643
+
644
+ return `<!DOCTYPE html>
645
+ <html lang="zh-CN"
646
+ data-issue="${attr(String(input.issueNumber))}"
647
+ data-generated-at="${attr(input.generatedAt)}"${lifecycleAttrs}
648
+ >
649
+ <head>
650
+ <meta charset="utf-8"/>
651
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>${refreshMeta}
652
+ <title>Ticket #${escapeHtml(String(input.issueNumber))} trajectory</title>
653
+ <style>
654
+ :root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.45; }
655
+ body { margin: 0 auto; padding: 1rem; max-width: 52rem; }
656
+ header.page { margin-bottom: 1rem; }
657
+ .generated { font-size: 0.9rem; opacity: 0.8; }
658
+ .station { border: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); border-radius: 0.5rem; padding: 0.75rem; margin: 0.75rem 0; }
659
+ .station-title { margin: 0 0 0.5rem; font-size: 1.1rem; }
660
+ .run { padding: 0.5rem 0; border-top: 1px solid color-mix(in srgb, CanvasText 12%, Canvas); }
661
+ .run:first-of-type { border-top: 0; }
662
+ .run-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; justify-content: space-between; }
663
+ .run-id { font-family: ui-monospace, monospace; font-size: 0.9rem; word-break: break-all; }
664
+ .run-model { font-size: 0.85rem; opacity: 0.85; }
665
+ .run-meta { margin: 0.25rem 0; font-size: 0.9rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
666
+ .ledger { margin: 0.25rem 0 0; font-size: 0.8rem; word-break: break-all; }
667
+ @media (max-width: 640px) {
668
+ body { padding: 0.75rem; }
669
+ .run-head { flex-direction: column; }
670
+ }
671
+ </style>
672
+ </head>
673
+ <body>
674
+ <header class="page">
675
+ <h1>Ticket #${escapeHtml(String(input.issueNumber))} · 驿传轨迹</h1>
676
+ <p class="generated">生成于 <time datetime="${attr(input.generatedAt)}">${escapeHtml(formatLocalDateTime(input.generatedAt))}</time>${refreshNote}</p>
677
+ </header>
678
+ <main data-run-count="${sortedRuns.length}">
679
+ ${stationBlocks || "<p data-empty=\"true\">no runs</p>"}
680
+ </main>
681
+ </body>
682
+ </html>
683
+ `;
684
+ }
685
+
686
+ /**
687
+ * Load one ticket's runs via the S1 tracer path (read-only ledger scan).
688
+ * Factory board reuses this — no parallel receipt parser.
689
+ */
690
+ export async function loadTicketTrajectoryRuns(
691
+ ledgerDir: string,
692
+ issueNumber: number,
693
+ ): Promise<TicketTrajectoryRun[]> {
694
+ if (!Number.isInteger(issueNumber) || issueNumber < 1) {
695
+ throw new Error("issueNumber must be a positive integer");
696
+ }
697
+ const root = resolve(ledgerDir);
698
+ const runIds = await listRunIds(root, issueNumber);
699
+ const runs: ParsedRun[] = [];
700
+ for (const runId of runIds) {
701
+ runs.push(await parseRun(root, issueNumber, runId));
702
+ }
703
+ return runs;
704
+ }
705
+
706
+ /**
707
+ * Unique production seam: pure scan of the ledger + snapshot + now → HTML.
708
+ * Read-only against the ledger. Snapshot is the S1 minimal stub.
709
+ */
710
+ export async function renderTicketTrajectoryHtml(
711
+ ledgerDir: string,
712
+ ticketSnapshot: TicketSnapshot,
713
+ now: Date,
714
+ options?: { refreshBoundarySeconds?: number },
715
+ ): Promise<string> {
716
+ if (!isRecord(ticketSnapshot) || typeof ticketSnapshot.issueNumber !== "number" || !Number.isInteger(ticketSnapshot.issueNumber) || ticketSnapshot.issueNumber < 1) {
717
+ throw new Error("ticketSnapshot.issueNumber must be a positive integer");
718
+ }
719
+ const issueNumber = ticketSnapshot.issueNumber;
720
+ const runs = await loadTicketTrajectoryRuns(ledgerDir, issueNumber);
721
+ const generatedAt = now.toISOString();
722
+ // One-shot by default: only an explicit positive bound declares self-refresh,
723
+ // and only callers that actually regenerate (startTicketTrajectoryPage) pass it.
724
+ return renderHtml({
725
+ issueNumber,
726
+ generatedAt,
727
+ runs,
728
+ ...(options?.refreshBoundarySeconds !== undefined
729
+ ? { refreshBoundarySeconds: options.refreshBoundarySeconds }
730
+ : {}),
731
+ });
732
+ }
733
+
734
+ function isPathInside(parent: string, child: string): boolean {
735
+ const rel = relative(parent, child);
736
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !rel.startsWith(".."));
737
+ }
738
+
739
+ /**
740
+ * Resolve the prospective on-disk target of outputPath and refuse any landing
741
+ * inside the ledger — including when the path, a parent, or a trailing segment
742
+ * is a symlink into the ledger tree.
743
+ */
744
+ export async function assertTrajectoryOutputOutsideLedger(
745
+ ledgerDir: string,
746
+ outputPath: string,
747
+ ): Promise<{ ledgerRoot: string; outputAbsolute: string; prospectiveReal: string }> {
748
+ const ledgerResolved = resolve(ledgerDir);
749
+ let ledgerRoot: string;
750
+ try {
751
+ ledgerRoot = await realpath(ledgerResolved);
752
+ } catch (error) {
753
+ if (!isMissingPathError(error)) throw error;
754
+ ledgerRoot = ledgerResolved;
755
+ }
756
+ const outputAbsolute = resolve(outputPath);
757
+
758
+ // Walk up until an existing filesystem node is found; realpath that prefix
759
+ // and rejoin the missing tail so symlink parents are fully followed.
760
+ const missingTail: string[] = [];
761
+ let cursor = outputAbsolute;
762
+ for (;;) {
763
+ try {
764
+ await lstat(cursor);
765
+ break;
766
+ } catch (error) {
767
+ if (!isMissingPathError(error)) throw error;
768
+ const parent = dirname(cursor);
769
+ if (parent === cursor) break;
770
+ missingTail.push(basename(cursor));
771
+ cursor = parent;
772
+ }
773
+ }
774
+
775
+ let realPrefix: string;
776
+ try {
777
+ realPrefix = await realpath(cursor);
778
+ } catch (error) {
779
+ if (!isMissingPathError(error)) throw error;
780
+ realPrefix = resolve(cursor);
781
+ }
782
+
783
+ const prospectiveReal =
784
+ missingTail.length === 0 ? realPrefix : resolve(realPrefix, ...missingTail.reverse());
785
+
786
+ if (isPathInside(ledgerRoot, prospectiveReal) || isPathInside(ledgerRoot, realPrefix)) {
787
+ throw new Error("ticket trajectory outputPath must be outside the ledger directory");
788
+ }
789
+
790
+ // Lexical absolute path must also stay outside (defense in depth before mkdir).
791
+ if (isPathInside(ledgerRoot, outputAbsolute)) {
792
+ throw new Error("ticket trajectory outputPath must be outside the ledger directory");
793
+ }
794
+
795
+ return { ledgerRoot, outputAbsolute, prospectiveReal };
796
+ }
797
+
798
+ /**
799
+ * Write HTML to an explicit path outside the ledger without following an
800
+ * existing destination inode (hard link / prior file). Temp file + rename
801
+ * replaces the directory entry so a hard-linked ledger twin keeps its bytes.
802
+ */
803
+ async function writeHtmlAtomicallyOutsideLedger(input: {
804
+ ledgerRoot: string;
805
+ outputAbsolute: string;
806
+ html: string;
807
+ }): Promise<string> {
808
+ const parent = dirname(input.outputAbsolute);
809
+ await mkdir(parent, { recursive: true });
810
+
811
+ // Re-resolve after mkdir: a race or symlink parent must still land outside.
812
+ const parentReal = await realpath(parent);
813
+ if (isPathInside(input.ledgerRoot, parentReal)) {
814
+ throw new Error("ticket trajectory outputPath must be outside the ledger directory");
815
+ }
816
+
817
+ const destinationReal = resolve(parentReal, basename(input.outputAbsolute));
818
+ if (isPathInside(input.ledgerRoot, destinationReal)) {
819
+ throw new Error("ticket trajectory outputPath must be outside the ledger directory");
820
+ }
821
+
822
+ // Refuse to write through an existing symlink whose target is inside the ledger.
823
+ try {
824
+ const existing = await lstat(input.outputAbsolute);
825
+ if (existing.isSymbolicLink()) {
826
+ const target = await realpath(input.outputAbsolute);
827
+ if (isPathInside(input.ledgerRoot, target)) {
828
+ throw new Error("ticket trajectory outputPath must be outside the ledger directory");
829
+ }
830
+ }
831
+ } catch (error) {
832
+ if (!isMissingPathError(error)) throw error;
833
+ }
834
+
835
+ // Same-directory temp + rename: does not open/truncate an existing inode, so a
836
+ // hard link from outputPath into the ledger cannot smuggle writes back home.
837
+ const temporary = join(parent, `.ticket-trajectory-${randomUUID()}.html.tmp`);
838
+ try {
839
+ await writeFile(temporary, input.html, "utf8");
840
+ await rename(temporary, input.outputAbsolute);
841
+ } catch (error) {
842
+ await rm(temporary, { force: true }).catch(() => undefined);
843
+ throw error;
844
+ }
845
+ return realpath(input.outputAbsolute);
846
+ }
847
+
848
+ /**
849
+ * Page write seam: render via the unique seam and write ONLY to an explicit
850
+ * path outside the ledger. Caller owns output location.
851
+ */
852
+ export async function writeTicketTrajectoryPage(input: {
853
+ ledgerDir: string;
854
+ ticketSnapshot: TicketSnapshot;
855
+ now: Date;
856
+ outputPath: string;
857
+ refreshBoundarySeconds?: number;
858
+ }): Promise<{ outputPath: string; html: string }> {
859
+ const gate = await assertTrajectoryOutputOutsideLedger(input.ledgerDir, input.outputPath);
860
+
861
+ const html = await renderTicketTrajectoryHtml(
862
+ input.ledgerDir,
863
+ input.ticketSnapshot,
864
+ input.now,
865
+ input.refreshBoundarySeconds !== undefined
866
+ ? { refreshBoundarySeconds: input.refreshBoundarySeconds }
867
+ : undefined,
868
+ );
869
+
870
+ const outputPath = await writeHtmlAtomicallyOutsideLedger({
871
+ ledgerRoot: gate.ledgerRoot,
872
+ outputAbsolute: gate.outputAbsolute,
873
+ html,
874
+ });
875
+ return { outputPath, html };
876
+ }
877
+
878
+ const defaultScheduler: TrajectoryScheduler = {
879
+ every(ms, tick) {
880
+ const timer = setInterval(tick, ms);
881
+ // Allow the process to exit naturally if the caller forgets stop in scripts.
882
+ timer.unref?.();
883
+ return () => clearInterval(timer);
884
+ },
885
+ };
886
+
887
+ /**
888
+ * Production page lifecycle: write immediately, then regenerate on the declared
889
+ * refresh boundary so the same viewing surface observes new runs / generated-at.
890
+ * Stop cancels further regeneration. A post-start regeneration failure faults the
891
+ * lifecycle with the original cause (no silent continuation). Ledger remains read-only.
892
+ */
893
+ export function startTicketTrajectoryPage(input: {
894
+ ledgerDir: string;
895
+ ticketSnapshot: TicketSnapshot;
896
+ outputPath: string;
897
+ refreshBoundarySeconds?: number;
898
+ clock?: TrajectoryClock;
899
+ scheduler?: TrajectoryScheduler;
900
+ }): TicketTrajectoryPageHandle {
901
+ const refreshBoundarySeconds = input.refreshBoundarySeconds ?? DEFAULT_REFRESH_BOUNDARY_SECONDS;
902
+ if (!(refreshBoundarySeconds > 0) || !Number.isFinite(refreshBoundarySeconds)) {
903
+ throw new Error("refreshBoundarySeconds must be a positive finite number");
904
+ }
905
+ const clock = input.clock ?? (() => new Date());
906
+ const scheduler = input.scheduler ?? defaultScheduler;
907
+
908
+ let stopped = false;
909
+ let cancel: (() => void) | undefined;
910
+ let inFlight: Promise<void> | undefined;
911
+ let lastRejection: unknown;
912
+ let closedSettled = false;
913
+ let resolveClosed!: () => void;
914
+ let rejectClosed!: (error: unknown) => void;
915
+ const closed = new Promise<void>((resolve, reject) => {
916
+ resolveClosed = resolve;
917
+ rejectClosed = reject;
918
+ });
919
+ // Prevent unhandled-rejection crashes when callers only await stop()/started.
920
+ void closed.catch(() => undefined);
921
+
922
+ const fault = (error: unknown): void => {
923
+ if (lastRejection !== undefined) return;
924
+ lastRejection = error;
925
+ stopped = true;
926
+ cancel?.();
927
+ cancel = undefined;
928
+ if (!closedSettled) {
929
+ closedSettled = true;
930
+ rejectClosed(error);
931
+ }
932
+ };
933
+
934
+ const settleClean = (): void => {
935
+ if (closedSettled) return;
936
+ closedSettled = true;
937
+ resolveClosed();
938
+ };
939
+
940
+ const writeOnce = async (): Promise<{ outputPath: string; html: string }> => {
941
+ if (stopped && lastRejection === undefined) {
942
+ throw new Error("ticket trajectory page lifecycle already stopped");
943
+ }
944
+ if (lastRejection !== undefined) throw lastRejection;
945
+ return writeTicketTrajectoryPage({
946
+ ledgerDir: input.ledgerDir,
947
+ ticketSnapshot: input.ticketSnapshot,
948
+ now: clock(),
949
+ outputPath: input.outputPath,
950
+ refreshBoundarySeconds,
951
+ });
952
+ };
953
+
954
+ const queueWrite = (): void => {
955
+ if (stopped || lastRejection !== undefined) return;
956
+ inFlight = (inFlight ?? Promise.resolve()).then(async () => {
957
+ if (stopped || lastRejection !== undefined) return;
958
+ try {
959
+ await writeOnce();
960
+ } catch (error) {
961
+ fault(error);
962
+ }
963
+ });
964
+ };
965
+
966
+ const started = writeOnce()
967
+ .then((first) => {
968
+ if (stopped || lastRejection !== undefined) return first;
969
+ const intervalMs = Math.max(1, Math.round(refreshBoundarySeconds * 1000));
970
+ cancel = scheduler.every(intervalMs, () => {
971
+ queueWrite();
972
+ });
973
+ return first;
974
+ })
975
+ .catch((error) => {
976
+ fault(error);
977
+ throw error;
978
+ });
979
+
980
+ // Capture output path from the absolute resolution even before first write settles.
981
+ const outputPath = resolve(input.outputPath);
982
+
983
+ return {
984
+ outputPath,
985
+ started,
986
+ closed,
987
+ async stop() {
988
+ stopped = true;
989
+ cancel?.();
990
+ cancel = undefined;
991
+ await started.catch(() => undefined);
992
+ if (inFlight) await inFlight.catch(() => undefined);
993
+ if (lastRejection !== undefined) {
994
+ // closed already rejected with the original cause
995
+ throw lastRejection;
996
+ }
997
+ settleClean();
998
+ },
999
+ };
1000
+ }