@akagilnc/pi-workflow-roles 0.1.5797 → 0.1.5820

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 (80) hide show
  1. package/dist/acp-host/production-host.js +464 -355
  2. package/dist/acp-host/role-turn-host.js +484 -0
  3. package/dist/activation-ledger-session.js +105 -0
  4. package/dist/activation-ledger.js +8 -0
  5. package/dist/activation-trace.js +33 -0
  6. package/dist/audit-escalation.js +7 -0
  7. package/dist/audited-run-identity.js +21 -0
  8. package/dist/auditor-role.js +7 -0
  9. package/dist/book-topology-migration-placement.js +404 -0
  10. package/dist/book-topology-migration.js +125 -0
  11. package/dist/book-topology-migrators-t11.js +677 -0
  12. package/dist/book-topology-mixed-volume-migrators.js +322 -0
  13. package/dist/book-topology-partition-migrators.js +23 -0
  14. package/dist/book-topology-record-class-migrators.js +671 -0
  15. package/dist/book-topology-runs-migrator.js +272 -0
  16. package/dist/collector-role.js +109 -0
  17. package/dist/compliance-transport.js +5 -3
  18. package/dist/countersign-role.js +17 -0
  19. package/dist/diarist-role.js +8 -0
  20. package/dist/diarist.js +15 -0
  21. package/dist/doctor-role.js +80 -0
  22. package/dist/engine-detour-tool.js +51 -37
  23. package/dist/engine-detour.js +22 -11
  24. package/dist/factory-board.js +1478 -0
  25. package/dist/fixer-bash-seatbelt.js +13 -0
  26. package/dist/gatekeeper-role.js +1 -2
  27. package/dist/gh-api-runner.js +81 -0
  28. package/dist/gleaner-left-role.js +8 -0
  29. package/dist/headless-host/production-host.js +464 -355
  30. package/dist/headless-host/role-turn-host.js +691 -0
  31. package/dist/human-format.js +67 -0
  32. package/dist/inspector-role.js +12 -0
  33. package/dist/judge-recording-anti-forge.js +39 -0
  34. package/dist/judge-role.js +21 -3
  35. package/dist/merger-role.js +57 -0
  36. package/dist/migrate-book-topology.js +0 -2
  37. package/dist/navigator-role.js +6 -0
  38. package/dist/navigator-work-context.js +72 -0
  39. package/dist/notary-role.js +113 -0
  40. package/dist/oauth-keepalive.js +149 -0
  41. package/dist/package-contracts/terminating-infrastructure.js +0 -35
  42. package/dist/pi/adapter.js +272 -0
  43. package/dist/public-cli/cli.js +9 -3
  44. package/dist/public-cli/instruction-seat-run.js +163 -44
  45. package/dist/public-cli/main.js +241 -123
  46. package/dist/report-ticket-tool.js +24 -13
  47. package/dist/reviewer-role.js +33 -0
  48. package/dist/role-envelope.js +707 -0
  49. package/dist/role-runtime-dependencies.js +57 -0
  50. package/dist/role-runtime.js +1476 -0
  51. package/dist/secretariat-role.js +5 -0
  52. package/dist/stderr-jsonl.js +26 -0
  53. package/dist/submission-correctable-error.js +2 -6
  54. package/dist/submission-errors.js +0 -30
  55. package/dist/submission-gate.js +61 -54
  56. package/dist/submission-ledger.js +105 -98
  57. package/dist/ticket-snapshot.js +486 -0
  58. package/dist/ticket-trajectory.js +843 -0
  59. package/dist/worker-role.js +184 -0
  60. package/dist/worker-submission-gates.js +333 -0
  61. package/package.json +1 -1
  62. package/resources/engine-dispatch.md +6 -6
  63. package/resources/engines/agy.md +1 -1
  64. package/src/compliance-transport.ts +7 -3
  65. package/src/engine-detour-tool.ts +88 -57
  66. package/src/engine-detour.ts +31 -14
  67. package/src/gatekeeper-role.ts +1 -15
  68. package/src/host-contracts.ts +9 -12
  69. package/src/judge-role.ts +30 -7
  70. package/src/package-contracts/terminating-infrastructure.ts +8 -81
  71. package/src/pi/adapter.ts +0 -1
  72. package/src/public-cli/cli.ts +21 -14
  73. package/src/public-cli/instruction-seat-run.ts +227 -44
  74. package/src/report-ticket-tool.ts +27 -13
  75. package/src/role-envelope.ts +35 -35
  76. package/src/role-runtime.ts +63 -56
  77. package/src/submission-correctable-error.ts +1 -6
  78. package/src/submission-errors.ts +0 -34
  79. package/src/submission-gate.ts +71 -56
  80. package/src/submission-ledger.ts +109 -104
@@ -0,0 +1,843 @@
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) current.json invocation 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 { readSectionSync } from "./run-dossier.js";
22
+ import { lstat, mkdir, readdir, readFile, realpath } from "node:fs/promises";
23
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { writeFileAtomically } from "./atomic-write.js";
26
+ import { errnoCode, pathContainedIn, physicalPathIdentity, } from "./activation-ledger-topology.js";
27
+ import { formatDurationZh, formatLocalDateTime, formatTokensCompact, formatUsdPrecise, } from "./human-format.js";
28
+ import { listBookRunDirectories, sessionDirectoryOf } from "./role-run-placement.js";
29
+ import { sitianVolumeDirectory } from "./sitian-appender.js";
30
+ import { isPositiveTicketNumber, readRunTicketNumber } from "./run-ticket-number.js";
31
+ import { extractSessionTimestampSpan, readLedgerSessionJsonl, } from "./ledger-session-read.js";
32
+ export { readLedgerSessionJsonl } from "./ledger-session-read.js";
33
+ import { AcceptedDetailsContractError, acceptedFacts, isTerminatingToolName, validateAcceptedDetails, } from "./package-contracts/terminating-tools.js";
34
+ import { LEGACY_REVIEW_OUTPUT_ROLES, PACKAGED_ROLE_REGISTRY } from "./packaged-role-registry.js";
35
+ import { isRecord, isMissingPathError } from "./unknown-value.js";
36
+ /** Declared refresh bound for the same viewing surface (seconds). */
37
+ export const DEFAULT_REFRESH_BOUNDARY_SECONDS = 30;
38
+ /** realpath when the node exists; lexical path only for recognized absence — never for other errors. */
39
+ async function realpathOrLexicalIfMissing(path) {
40
+ try {
41
+ return await realpath(path);
42
+ }
43
+ catch (error) {
44
+ if (isMissingPathError(error))
45
+ return path;
46
+ throw error;
47
+ }
48
+ }
49
+ const TOOL_TO_ROLE = new Map([
50
+ ...PACKAGED_ROLE_REGISTRY.filter((entry) => entry.outputTool !== "ak_submission_output")
51
+ .map((entry) => [entry.outputTool, entry.role]),
52
+ ...LEGACY_REVIEW_OUTPUT_ROLES,
53
+ ]);
54
+ const NAME_PREFIX_ROLES = PACKAGED_ROLE_REGISTRY.map((entry) => entry.role);
55
+ function roleFromToolName(toolName) {
56
+ return TOOL_TO_ROLE.get(toolName);
57
+ }
58
+ function roleFromRunName(runId) {
59
+ const base = runId.split("@")[0] ?? runId;
60
+ const lower = base.toLowerCase();
61
+ // plan-court / *-court* → judge (ticket-court family)
62
+ if (/(^|[-_])court([-_]|$)/.test(lower) || lower.startsWith("plan-court"))
63
+ return "judge";
64
+ for (const role of NAME_PREFIX_ROLES) {
65
+ if (lower === role || lower.startsWith(`${role}-`) || lower.startsWith(`${role}_`))
66
+ return role;
67
+ }
68
+ // review-* shorthand used heavily in the home ledger
69
+ if (lower.startsWith("review"))
70
+ return "reviewer";
71
+ return undefined;
72
+ }
73
+ function escapeHtml(text) {
74
+ return text
75
+ .replaceAll("&", "&amp;")
76
+ .replaceAll("<", "&lt;")
77
+ .replaceAll(">", "&gt;")
78
+ .replaceAll('"', "&quot;")
79
+ .replaceAll("'", "&#39;");
80
+ }
81
+ function attr(value) {
82
+ return escapeHtml(value);
83
+ }
84
+ function extractModelFields(rows) {
85
+ let model = "";
86
+ let provider = "";
87
+ let thinking = "";
88
+ for (const row of rows) {
89
+ if (row.type === "model_change") {
90
+ if (typeof row.provider === "string" && row.provider)
91
+ provider = row.provider;
92
+ if (typeof row.modelId === "string" && row.modelId)
93
+ model = row.modelId;
94
+ }
95
+ if (row.type === "thinking_level_change" && typeof row.thinkingLevel === "string" && row.thinkingLevel) {
96
+ thinking = row.thinkingLevel;
97
+ }
98
+ const message = isRecord(row.message) ? row.message : undefined;
99
+ if (message?.role === "assistant") {
100
+ if (typeof message.model === "string" && message.model)
101
+ model = message.model;
102
+ if (typeof message.provider === "string" && message.provider)
103
+ provider = message.provider;
104
+ }
105
+ }
106
+ return { model, provider, thinking };
107
+ }
108
+ /** Sum budget dollars and tokens from message.usage on session rows. */
109
+ function extractUsageTotals(rows) {
110
+ let costUsd = 0;
111
+ let totalTokens = 0;
112
+ for (const row of rows) {
113
+ const message = isRecord(row.message) ? row.message : undefined;
114
+ const usage = message && isRecord(message.usage) ? message.usage : isRecord(row.usage) ? row.usage : undefined;
115
+ if (!usage)
116
+ continue;
117
+ if (typeof usage.totalTokens === "number" && Number.isFinite(usage.totalTokens)) {
118
+ totalTokens += usage.totalTokens;
119
+ }
120
+ const cost = isRecord(usage.cost) ? usage.cost : undefined;
121
+ if (cost && typeof cost.total === "number" && Number.isFinite(cost.total)) {
122
+ costUsd += cost.total;
123
+ }
124
+ }
125
+ return { costUsd, totalTokens };
126
+ }
127
+ function wallMsBetween(startedAt, endedAt) {
128
+ if (!startedAt || !endedAt)
129
+ return 0;
130
+ const start = Date.parse(startedAt);
131
+ const end = Date.parse(endedAt);
132
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start)
133
+ return 0;
134
+ return end - start;
135
+ }
136
+ function extractTerminatingLifecycle(rows) {
137
+ let callAttempts = 0;
138
+ let resultAttempts = 0;
139
+ const toolNames = [];
140
+ let hasResult = false;
141
+ let resultStatus = "";
142
+ for (const row of rows) {
143
+ const message = isRecord(row.message) ? row.message : undefined;
144
+ if (!message)
145
+ continue;
146
+ if (message.role === "assistant" && Array.isArray(message.content)) {
147
+ for (const part of message.content) {
148
+ if (!isRecord(part) || part.type !== "toolCall")
149
+ continue;
150
+ const name = part.name;
151
+ if (typeof name === "string" && isTerminatingToolName(name)) {
152
+ callAttempts += 1;
153
+ toolNames.push(name);
154
+ }
155
+ }
156
+ }
157
+ if (message.role === "toolResult" && typeof message.toolName === "string" && isTerminatingToolName(message.toolName)) {
158
+ resultAttempts += 1;
159
+ toolNames.push(message.toolName);
160
+ if (message.isError === true)
161
+ continue;
162
+ if (!isRecord(message.details))
163
+ continue;
164
+ try {
165
+ const details = validateAcceptedDetails(message.toolName, message.details);
166
+ const facts = acceptedFacts(message.toolName, details);
167
+ hasResult = true;
168
+ resultStatus = facts.status ?? "";
169
+ }
170
+ catch (error) {
171
+ if (error instanceof AcceptedDetailsContractError)
172
+ continue;
173
+ throw error;
174
+ }
175
+ }
176
+ }
177
+ // Prefer toolCall count; fall back to toolResult count when calls were clipped away.
178
+ const attemptCount = callAttempts > 0 ? callAttempts : resultAttempts;
179
+ return { attemptCount, toolNames, hasResult, resultStatus };
180
+ }
181
+ async function readInvocation(runDir) {
182
+ try {
183
+ const parsed = readSectionSync(runDir, "invocation");
184
+ if (parsed === undefined)
185
+ return undefined;
186
+ const info = {};
187
+ if (typeof parsed.role === "string" && parsed.role.trim())
188
+ info.role = parsed.role.trim();
189
+ if (typeof parsed.thinking === "string" && parsed.thinking.trim())
190
+ info.thinking = parsed.thinking.trim();
191
+ if (typeof parsed.provider === "string" && parsed.provider.trim()) {
192
+ info.provider = parsed.provider.trim();
193
+ }
194
+ if (typeof parsed.model === "string" && parsed.model.trim()) {
195
+ const rawModel = parsed.model.trim();
196
+ if (rawModel.includes("/")) {
197
+ const slash = rawModel.indexOf("/");
198
+ // Combined provider/model form (legacy pages).
199
+ if (info.provider === undefined)
200
+ info.provider = rawModel.slice(0, slash);
201
+ info.model = rawModel.slice(slash + 1);
202
+ }
203
+ else {
204
+ info.model = rawModel;
205
+ }
206
+ }
207
+ if (typeof parsed.correlationId === "string" && parsed.correlationId.trim() !== "") {
208
+ info.correlationId = parsed.correlationId;
209
+ }
210
+ // Display placement: board pages first, then migration-derived (never forges board).
211
+ const ticketNumber = await readRunTicketNumber(runDir);
212
+ if (ticketNumber !== undefined)
213
+ info.ticketNumber = ticketNumber;
214
+ return info;
215
+ }
216
+ catch (error) {
217
+ // Only genuine absence activates the invocation fallback. Malformed JSON and
218
+ // unexpected IO failures retain their cause — never relabeled as "no invocation."
219
+ if (isMissingPathError(error))
220
+ return undefined;
221
+ throw error;
222
+ }
223
+ }
224
+ function resolveStation(input) {
225
+ if (input.invocationRole && input.toolNames.includes("ak_submission_output")) {
226
+ return { station: input.invocationRole, stationSource: "invocation" };
227
+ }
228
+ for (const toolName of input.toolNames) {
229
+ const role = roleFromToolName(toolName);
230
+ if (role)
231
+ return { station: role, stationSource: "tool" };
232
+ }
233
+ // Also accept tool names observed only via accepted results order
234
+ if (input.invocationRole) {
235
+ return { station: input.invocationRole, stationSource: "invocation" };
236
+ }
237
+ const byName = roleFromRunName(input.runId);
238
+ if (byName)
239
+ return { station: byName, stationSource: "name" };
240
+ return { station: "unknown", stationSource: "unknown" };
241
+ }
242
+ async function listSessionFiles(sessionDir) {
243
+ try {
244
+ const entries = await readdir(sessionDir, { withFileTypes: true });
245
+ return entries
246
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
247
+ .map((entry) => join(sessionDir, entry.name))
248
+ .sort();
249
+ }
250
+ catch (error) {
251
+ if (isMissingPathError(error))
252
+ return [];
253
+ throw error;
254
+ }
255
+ }
256
+ /** Reviewer parallel axis-leg sessions live under session/reviewer-legs/. */
257
+ async function listAxisLegSessionFiles(sessionDir) {
258
+ return listSessionFiles(sitianVolumeDirectory(sessionDir, "reviewer-legs"));
259
+ }
260
+ async function maxMtimeMs(paths) {
261
+ let max = 0;
262
+ for (const path of paths) {
263
+ try {
264
+ const st = await lstat(path);
265
+ const ms = st.mtimeMs;
266
+ if (Number.isFinite(ms) && ms > max)
267
+ max = ms;
268
+ }
269
+ catch (error) {
270
+ if (isMissingPathError(error))
271
+ continue;
272
+ throw error;
273
+ }
274
+ }
275
+ return max;
276
+ }
277
+ async function parseRunDirectory(runDir, ledgerCoord) {
278
+ const runId = basename(runDir);
279
+ const evidenceTarget = await realpathOrLexicalIfMissing(runDir);
280
+ const evidenceHref = pathToFileURL(evidenceTarget).href;
281
+ const sessionDir = sessionDirectoryOf(runDir);
282
+ const sessionFiles = await listSessionFiles(sessionDir);
283
+ const axisLegFiles = await listAxisLegSessionFiles(sessionDir);
284
+ const rows = [];
285
+ for (const file of sessionFiles) {
286
+ rows.push(...(await readLedgerSessionJsonl(file)));
287
+ }
288
+ // Prefer explicit session header timestamp as start; else first record.
289
+ let startedAt;
290
+ for (const row of rows) {
291
+ if (row.type === "session" && typeof row.timestamp === "string") {
292
+ startedAt = row.timestamp;
293
+ break;
294
+ }
295
+ if (!startedAt && typeof row.timestamp === "string")
296
+ startedAt = row.timestamp;
297
+ }
298
+ const parentSpan = extractSessionTimestampSpan(rows);
299
+ if (startedAt === undefined)
300
+ startedAt = parentSpan.startedAt;
301
+ const endedAt = parentSpan.endedAt;
302
+ const parentUsage = extractUsageTotals(rows);
303
+ let costUsd = parentUsage.costUsd;
304
+ let totalTokens = parentUsage.totalTokens;
305
+ let axisWallMs = 0;
306
+ // Newest content activity across parent + axis (not parent-only endedAt).
307
+ let lastActivityAt = parentSpan.endedAt;
308
+ for (const file of axisLegFiles) {
309
+ const legRows = await readLedgerSessionJsonl(file);
310
+ const legUsage = extractUsageTotals(legRows);
311
+ costUsd += legUsage.costUsd;
312
+ totalTokens += legUsage.totalTokens;
313
+ const legSpan = extractSessionTimestampSpan(legRows);
314
+ axisWallMs += wallMsBetween(legSpan.startedAt, legSpan.endedAt);
315
+ if (legSpan.endedAt !== undefined &&
316
+ (lastActivityAt === undefined || legSpan.endedAt > lastActivityAt)) {
317
+ lastActivityAt = legSpan.endedAt;
318
+ }
319
+ }
320
+ const mtimeMs = await maxMtimeMs([...sessionFiles, ...axisLegFiles]);
321
+ const lifecycle = extractTerminatingLifecycle(rows);
322
+ const models = extractModelFields(rows);
323
+ const invocation = await readInvocation(runDir);
324
+ const { station, stationSource } = resolveStation({
325
+ toolNames: lifecycle.toolNames,
326
+ runId,
327
+ ...(invocation?.role !== undefined ? { invocationRole: invocation.role } : {}),
328
+ });
329
+ // Session mechanical fields win; the invocation section fills gaps only.
330
+ let { model, provider, thinking } = models;
331
+ if (invocation) {
332
+ if (!thinking && invocation.thinking)
333
+ thinking = invocation.thinking;
334
+ if (!provider && invocation.provider)
335
+ provider = invocation.provider;
336
+ if (!model && invocation.model)
337
+ model = invocation.model;
338
+ }
339
+ return {
340
+ runId,
341
+ ledgerCoord,
342
+ evidenceHref,
343
+ ...(startedAt !== undefined ? { startedAt } : {}),
344
+ ...(endedAt !== undefined ? { endedAt } : {}),
345
+ ...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
346
+ mtimeMs,
347
+ costUsd,
348
+ totalTokens,
349
+ axisWallMs,
350
+ station,
351
+ stationSource,
352
+ attemptCount: lifecycle.attemptCount,
353
+ hasResult: lifecycle.hasResult,
354
+ resultStatus: lifecycle.resultStatus,
355
+ model,
356
+ provider,
357
+ thinking,
358
+ };
359
+ }
360
+ async function parseRun(ledgerDir, issueNumber, runId) {
361
+ const runDir = join(ledgerDir, "issues", String(issueNumber), "runs", runId);
362
+ const ledgerCoord = ["issues", String(issueNumber), "runs", runId].join("/");
363
+ return parseRunDirectory(runDir, ledgerCoord);
364
+ }
365
+ async function listRunIds(ledgerDir, issueNumber) {
366
+ const runsDir = join(ledgerDir, "issues", String(issueNumber), "runs");
367
+ try {
368
+ const entries = await readdir(runsDir, { withFileTypes: true });
369
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
370
+ }
371
+ catch (error) {
372
+ if (isMissingPathError(error))
373
+ return [];
374
+ throw error;
375
+ }
376
+ }
377
+ function sortRuns(runs) {
378
+ return [...runs].sort((a, b) => {
379
+ const at = a.startedAt ?? "";
380
+ const bt = b.startedAt ?? "";
381
+ if (at !== bt)
382
+ return at.localeCompare(bt);
383
+ return a.runId.localeCompare(b.runId);
384
+ });
385
+ }
386
+ /**
387
+ * Render station/run blocks from already-loaded S1 runs.
388
+ * Shared by the single-ticket page and the S2 factory board (no second receipt parser).
389
+ */
390
+ function formatUsd(value) {
391
+ // Full precision mechanical string — presentation may round; machines parse the attr.
392
+ return Number.isFinite(value) ? String(value) : "0";
393
+ }
394
+ function parentWallMs(run) {
395
+ if (typeof run.wallMs === "number" && Number.isFinite(run.wallMs) && run.wallMs >= 0) {
396
+ return run.wallMs;
397
+ }
398
+ return wallMsBetween(run.startedAt, run.endedAt);
399
+ }
400
+ export function renderTicketTrajectoryStationHtml(runs) {
401
+ const stationOrder = [];
402
+ const byStation = new Map();
403
+ const sortedRuns = sortRuns(runs);
404
+ for (const run of sortedRuns) {
405
+ if (!byStation.has(run.station)) {
406
+ byStation.set(run.station, []);
407
+ stationOrder.push(run.station);
408
+ }
409
+ byStation.get(run.station).push(run);
410
+ }
411
+ return stationOrder
412
+ .map((station) => {
413
+ const rounds = byStation.get(station);
414
+ const stationLabel = station === "unknown" ? "未知站" : station;
415
+ let stationCost = 0;
416
+ let stationTokens = 0;
417
+ let stationWall = 0;
418
+ for (const run of rounds) {
419
+ stationCost += run.costUsd;
420
+ stationTokens += run.totalTokens;
421
+ // Station wall = each run's (possibly now-extended) wall + axis legs folded in.
422
+ stationWall += parentWallMs(run) + run.axisWallMs;
423
+ }
424
+ const roundHtml = rounds
425
+ .map((run) => {
426
+ const resultDisplay = run.hasResult
427
+ ? run.resultStatus ||
428
+ run.resultStatus
429
+ : "";
430
+ // Machine channel: space-separated closed-enum tokens (no custom status dialect).
431
+ const wall = parentWallMs(run);
432
+ return [
433
+ `<article class="run"`,
434
+ ` data-run-id="${attr(run.runId)}"`,
435
+ ` data-station="${attr(run.station)}"`,
436
+ ` data-station-source="${attr(run.stationSource)}"`,
437
+ ` data-ledger-coord="${attr(run.ledgerCoord)}"`,
438
+ ` data-attempt-count="${attr(String(run.attemptCount))}"`,
439
+ ` data-has-result="${run.hasResult ? "true" : "false"}"`,
440
+ ` data-result-status="${attr(run.resultStatus)}"`,
441
+ ` data-model="${attr(run.model)}"`,
442
+ ` data-provider="${attr(run.provider)}"`,
443
+ ` data-thinking="${attr(run.thinking)}"`,
444
+ run.startedAt ? ` data-started-at="${attr(run.startedAt)}"` : "",
445
+ run.endedAt ? ` data-ended-at="${attr(run.endedAt)}"` : "",
446
+ ` data-mtime-ms="${attr(String(run.mtimeMs))}"`,
447
+ ` data-cost-usd="${attr(formatUsd(run.costUsd))}"`,
448
+ ` data-total-tokens="${attr(String(run.totalTokens))}"`,
449
+ ` data-wall-ms="${attr(String(wall))}"`,
450
+ ` data-axis-wall-ms="${attr(String(run.axisWallMs))}"`,
451
+ `>`,
452
+ `<header class="run-head">`,
453
+ `<span class="run-id">${escapeHtml(run.runId)}</span>`,
454
+ run.model || run.provider || run.thinking
455
+ ? `<span class="run-model">${escapeHtml([run.provider, run.model].filter(Boolean).join("/"))}${run.thinking ? ` · ${escapeHtml(run.thinking)}` : ""}</span>`
456
+ : "",
457
+ `</header>`,
458
+ `<p class="run-meta">`,
459
+ `<span class="attempts">attempts: ${run.attemptCount}</span>`,
460
+ run.hasResult
461
+ ? `<span class="result">result: ${escapeHtml(resultDisplay)}</span>`
462
+ : `<span class="result">result: (none — attempts only)</span>`,
463
+ `<span class="cost">$${escapeHtml(formatUsdPrecise(run.costUsd))} · ${escapeHtml(formatTokensCompact(run.totalTokens))} tok</span>`,
464
+ `<span class="wall">墙钟 ${escapeHtml(formatDurationZh(wall))}</span>`,
465
+ `</p>`,
466
+ `<p class="ledger"><a data-ledger-link="${attr(run.ledgerCoord)}" href="${attr(run.evidenceHref)}">${escapeHtml(run.ledgerCoord)}</a></p>`,
467
+ `</article>`,
468
+ ].join("");
469
+ })
470
+ .join("\n");
471
+ return [
472
+ `<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))}">`,
473
+ `<h2 class="station-title">${escapeHtml(stationLabel)} · ${rounds.length} 轮 · $${escapeHtml(formatUsdPrecise(stationCost))} · ${escapeHtml(formatTokensCompact(stationTokens))} tok · 墙钟 ${escapeHtml(formatDurationZh(stationWall))}</h2>`,
474
+ roundHtml,
475
+ `</section>`,
476
+ ].join("\n");
477
+ })
478
+ .join("\n");
479
+ }
480
+ function renderHtml(input) {
481
+ const refreshActive = input.refreshBoundarySeconds !== undefined &&
482
+ Number.isFinite(input.refreshBoundarySeconds) &&
483
+ input.refreshBoundarySeconds > 0;
484
+ const refreshBoundarySeconds = refreshActive ? input.refreshBoundarySeconds : undefined;
485
+ const sortedRuns = sortRuns(input.runs);
486
+ const stationBlocks = renderTicketTrajectoryStationHtml(sortedRuns);
487
+ const lifecycleAttrs = refreshActive
488
+ ? ` data-lifecycle="refresh" data-refresh-boundary-seconds="${attr(String(refreshBoundarySeconds))}"`
489
+ : ` data-lifecycle="oneshot"`;
490
+ const refreshMeta = refreshActive
491
+ ? `
492
+ <meta http-equiv="refresh" content="${attr(String(refreshBoundarySeconds))}"/>`
493
+ : "";
494
+ const refreshNote = refreshActive
495
+ ? `\n · refresh ≤ ${escapeHtml(String(refreshBoundarySeconds))}s`
496
+ : "";
497
+ return `<!DOCTYPE html>
498
+ <html lang="zh-CN"
499
+ data-issue="${attr(String(input.issueNumber))}"
500
+ data-generated-at="${attr(input.generatedAt)}"${lifecycleAttrs}
501
+ >
502
+ <head>
503
+ <meta charset="utf-8"/>
504
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>${refreshMeta}
505
+ <title>Ticket #${escapeHtml(String(input.issueNumber))} trajectory</title>
506
+ <style>
507
+ :root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.45; }
508
+ body { margin: 0 auto; padding: 1rem; max-width: 52rem; }
509
+ header.page { margin-bottom: 1rem; }
510
+ .generated { font-size: 0.9rem; opacity: 0.8; }
511
+ .station { border: 1px solid color-mix(in srgb, CanvasText 20%, Canvas); border-radius: 0.5rem; padding: 0.75rem; margin: 0.75rem 0; }
512
+ .station-title { margin: 0 0 0.5rem; font-size: 1.1rem; }
513
+ .run { padding: 0.5rem 0; border-top: 1px solid color-mix(in srgb, CanvasText 12%, Canvas); }
514
+ .run:first-of-type { border-top: 0; }
515
+ .run-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; justify-content: space-between; }
516
+ .run-id { font-family: ui-monospace, monospace; font-size: 0.9rem; word-break: break-all; }
517
+ .run-model { font-size: 0.85rem; opacity: 0.85; }
518
+ .run-meta { margin: 0.25rem 0; font-size: 0.9rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
519
+ .ledger { margin: 0.25rem 0 0; font-size: 0.8rem; word-break: break-all; }
520
+ @media (max-width: 640px) {
521
+ body { padding: 0.75rem; }
522
+ .run-head { flex-direction: column; }
523
+ }
524
+ </style>
525
+ </head>
526
+ <body>
527
+ <header class="page">
528
+ <h1>Ticket #${escapeHtml(String(input.issueNumber))} · 驿传轨迹</h1>
529
+ <p class="generated">生成于 <time datetime="${attr(input.generatedAt)}">${escapeHtml(formatLocalDateTime(input.generatedAt))}</time>${refreshNote}</p>
530
+ </header>
531
+ <main data-run-count="${sortedRuns.length}">
532
+ ${stationBlocks || "<p data-empty=\"true\">no runs</p>"}
533
+ </main>
534
+ </body>
535
+ </html>
536
+ `;
537
+ }
538
+ export async function buildTicketTrajectoryBookIndex(ledgerDir) {
539
+ const root = resolve(ledgerDir);
540
+ const runsByTicket = new Map();
541
+ const unboundRuns = [];
542
+ for (const runDir of await listBookRunDirectories(root)) {
543
+ const invocation = await readInvocation(runDir);
544
+ const rel = relative(root, runDir).split(sep).join("/");
545
+ const binding = {
546
+ runDir,
547
+ runFolder: basename(runDir),
548
+ ledgerCoord: rel,
549
+ ...(invocation?.ticketNumber !== undefined
550
+ ? { ticketNumber: invocation.ticketNumber }
551
+ : {}),
552
+ ...(invocation?.role !== undefined ? { role: invocation.role } : {}),
553
+ ...(invocation?.correlationId !== undefined
554
+ ? { correlationId: invocation.correlationId }
555
+ : {}),
556
+ };
557
+ if (binding.ticketNumber !== undefined) {
558
+ const list = runsByTicket.get(binding.ticketNumber) ?? [];
559
+ list.push(binding);
560
+ runsByTicket.set(binding.ticketNumber, list);
561
+ }
562
+ else {
563
+ unboundRuns.push(binding);
564
+ }
565
+ }
566
+ return { runsByTicket, unboundRuns };
567
+ }
568
+ /**
569
+ * Load unbound flat runs for the board unknown seam.
570
+ * Never joins any ticket. Built from the book index (once).
571
+ */
572
+ export async function loadUnboundTrajectoryRuns(ledgerDir, bookIndex) {
573
+ const root = resolve(ledgerDir);
574
+ const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root));
575
+ const out = [];
576
+ for (const binding of index.unboundRuns) {
577
+ const run = await parseRunDirectory(binding.runDir, binding.ledgerCoord);
578
+ out.push({
579
+ run,
580
+ role: binding.role ?? run.station,
581
+ observedAt: run.startedAt ?? run.lastActivityAt ?? "",
582
+ ...(binding.correlationId === undefined
583
+ ? {}
584
+ : { correlationId: binding.correlationId }),
585
+ });
586
+ }
587
+ return out;
588
+ }
589
+ export async function loadTicketTrajectoryRuns(ledgerDir, issueNumber, bookIndex) {
590
+ if (!isPositiveTicketNumber(issueNumber)) {
591
+ throw new Error("issueNumber must be a positive integer");
592
+ }
593
+ const root = resolve(ledgerDir);
594
+ const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root));
595
+ const runs = [];
596
+ const seenRunDirs = new Set();
597
+ // Legacy hand-dispatch path: issues/<N>/runs (read-only compatibility).
598
+ const runIds = await listRunIds(root, issueNumber);
599
+ for (const runId of runIds) {
600
+ const parsed = await parseRun(root, issueNumber, runId);
601
+ const legacyDir = join(root, "issues", String(issueNumber), "runs", runId);
602
+ seenRunDirs.add(resolve(legacyDir));
603
+ runs.push(parsed);
604
+ }
605
+ // Book runs (flat + subject-tree) bound by typed invocation ticketNumber.
606
+ const flatBindings = index.runsByTicket.get(issueNumber) ?? [];
607
+ for (const binding of flatBindings) {
608
+ const resolvedRunDir = resolve(binding.runDir);
609
+ if (seenRunDirs.has(resolvedRunDir))
610
+ continue;
611
+ seenRunDirs.add(resolvedRunDir);
612
+ runs.push(await parseRunDirectory(binding.runDir, binding.ledgerCoord));
613
+ }
614
+ return runs;
615
+ }
616
+ /**
617
+ * Unique production seam: pure scan of the ledger + snapshot + now → HTML.
618
+ * Read-only against the ledger. Snapshot is the S1 minimal stub.
619
+ */
620
+ export async function renderTicketTrajectoryHtml(ledgerDir, ticketSnapshot, now, options) {
621
+ if (!isRecord(ticketSnapshot) || !isPositiveTicketNumber(ticketSnapshot.issueNumber)) {
622
+ throw new Error("ticketSnapshot.issueNumber must be a positive integer");
623
+ }
624
+ const issueNumber = ticketSnapshot.issueNumber;
625
+ const runs = await loadTicketTrajectoryRuns(ledgerDir, issueNumber);
626
+ const generatedAt = now.toISOString();
627
+ // One-shot by default: only an explicit positive bound declares self-refresh,
628
+ // and only callers that actually regenerate (startTicketTrajectoryPage) pass it.
629
+ return renderHtml({
630
+ issueNumber,
631
+ generatedAt,
632
+ runs,
633
+ ...(options?.refreshBoundarySeconds !== undefined
634
+ ? { refreshBoundarySeconds: options.refreshBoundarySeconds }
635
+ : {}),
636
+ });
637
+ }
638
+ function landsInLedger(ledgerRoot, candidate) {
639
+ // mkdir may have materialized a previously dangling ledger alias since the first gate.
640
+ const physicalRoot = physicalPathIdentity(ledgerRoot);
641
+ const lexical = resolve(candidate);
642
+ const physical = physicalPathIdentity(candidate);
643
+ const inside = (id) => physicalRoot === id || pathContainedIn(physicalRoot, id);
644
+ return inside(lexical) || inside(physical);
645
+ }
646
+ /**
647
+ * Prospective output must stay outside every ledger root.
648
+ * Equality counts as inside. Symlink parents are followed by physical identity.
649
+ */
650
+ export async function assertOutputOutsideLedgers(ledgerDirs, outputPath, refusalMessage) {
651
+ const outputAbsolute = resolve(outputPath);
652
+ const prospectiveReal = physicalPathIdentity(outputAbsolute);
653
+ const ledgerRoots = [];
654
+ for (const ledgerDir of ledgerDirs) {
655
+ const ledgerResolved = resolve(ledgerDir);
656
+ let ledgerRoot;
657
+ try {
658
+ ledgerRoot = await realpath(ledgerResolved);
659
+ }
660
+ catch (error) {
661
+ if (!isMissingPathError(error))
662
+ throw error;
663
+ ledgerRoot = physicalPathIdentity(ledgerResolved);
664
+ }
665
+ ledgerRoots.push(ledgerRoot);
666
+ if (landsInLedger(ledgerRoot, prospectiveReal) || landsInLedger(ledgerRoot, outputAbsolute)) {
667
+ throw new Error(refusalMessage);
668
+ }
669
+ }
670
+ return { ledgerRoots, outputAbsolute, prospectiveReal };
671
+ }
672
+ /**
673
+ * Write HTML outside the ledgers. Temp+rename does not open an existing inode.
674
+ * An existing symlink whose target is inside a ledger is refused.
675
+ */
676
+ export async function writePageOutsideLedgers(ledgerRoots, outputAbsolute, html, refusalMessage) {
677
+ const parent = dirname(outputAbsolute);
678
+ await mkdir(parent, { recursive: true });
679
+ const parentReal = await realpath(parent);
680
+ const destinationReal = resolve(parentReal, basename(outputAbsolute));
681
+ for (const root of ledgerRoots) {
682
+ if (landsInLedger(root, parentReal) || landsInLedger(root, destinationReal)) {
683
+ throw new Error(refusalMessage);
684
+ }
685
+ }
686
+ try {
687
+ const existing = await lstat(outputAbsolute);
688
+ if (existing.isSymbolicLink()) {
689
+ const target = await realpath(outputAbsolute);
690
+ for (const root of ledgerRoots) {
691
+ if (landsInLedger(root, target))
692
+ throw new Error(refusalMessage);
693
+ }
694
+ }
695
+ }
696
+ catch (error) {
697
+ if (errnoCode(error) !== "ENOENT" && errnoCode(error) !== "ENOTDIR")
698
+ throw error;
699
+ }
700
+ await writeFileAtomically(outputAbsolute, html);
701
+ return realpath(outputAbsolute);
702
+ }
703
+ const TRAJECTORY_OUTPUT_REFUSAL = "ticket trajectory outputPath must be outside the ledger directory";
704
+ /**
705
+ * Resolve the prospective on-disk target of outputPath and refuse any landing
706
+ * inside the ledger — including when the path, a parent, or a trailing segment
707
+ * is a symlink into the ledger tree.
708
+ */
709
+ export async function assertTrajectoryOutputOutsideLedger(ledgerDir, outputPath) {
710
+ const gate = await assertOutputOutsideLedgers([ledgerDir], outputPath, TRAJECTORY_OUTPUT_REFUSAL);
711
+ return {
712
+ ledgerRoot: gate.ledgerRoots[0],
713
+ outputAbsolute: gate.outputAbsolute,
714
+ prospectiveReal: gate.prospectiveReal,
715
+ };
716
+ }
717
+ /**
718
+ * Page write seam: render via the unique seam and write ONLY to an explicit
719
+ * path outside the ledger. Caller owns output location.
720
+ */
721
+ export async function writeTicketTrajectoryPage(input) {
722
+ const gate = await assertTrajectoryOutputOutsideLedger(input.ledgerDir, input.outputPath);
723
+ const html = await renderTicketTrajectoryHtml(input.ledgerDir, input.ticketSnapshot, input.now, input.refreshBoundarySeconds !== undefined
724
+ ? { refreshBoundarySeconds: input.refreshBoundarySeconds }
725
+ : undefined);
726
+ const outputPath = await writePageOutsideLedgers([gate.ledgerRoot], gate.outputAbsolute, html, TRAJECTORY_OUTPUT_REFUSAL);
727
+ return { outputPath, html };
728
+ }
729
+ const defaultScheduler = {
730
+ every(ms, tick) {
731
+ const timer = setInterval(tick, ms);
732
+ // Allow the process to exit naturally if the caller forgets stop in scripts.
733
+ timer.unref?.();
734
+ return () => clearInterval(timer);
735
+ },
736
+ };
737
+ /**
738
+ * Production page lifecycle: write immediately, then regenerate on the declared
739
+ * refresh boundary so the same viewing surface observes new runs / generated-at.
740
+ * Stop cancels further regeneration. A post-start regeneration failure faults the
741
+ * lifecycle with the original cause (no silent continuation). Ledger remains read-only.
742
+ */
743
+ export function startTicketTrajectoryPage(input) {
744
+ const refreshBoundarySeconds = input.refreshBoundarySeconds ?? DEFAULT_REFRESH_BOUNDARY_SECONDS;
745
+ if (!(refreshBoundarySeconds > 0) || !Number.isFinite(refreshBoundarySeconds)) {
746
+ throw new Error("refreshBoundarySeconds must be a positive finite number");
747
+ }
748
+ const clock = input.clock ?? (() => new Date());
749
+ const scheduler = input.scheduler ?? defaultScheduler;
750
+ let stopped = false;
751
+ let cancel;
752
+ let inFlight;
753
+ let lastRejection;
754
+ let closedSettled = false;
755
+ let resolveClosed;
756
+ let rejectClosed;
757
+ const closed = new Promise((resolve, reject) => {
758
+ resolveClosed = resolve;
759
+ rejectClosed = reject;
760
+ });
761
+ // Prevent unhandled-rejection crashes when callers only await stop()/started.
762
+ void closed.catch(() => undefined);
763
+ const fault = (error) => {
764
+ if (lastRejection !== undefined)
765
+ return;
766
+ lastRejection = error;
767
+ stopped = true;
768
+ cancel?.();
769
+ cancel = undefined;
770
+ if (!closedSettled) {
771
+ closedSettled = true;
772
+ rejectClosed(error);
773
+ }
774
+ };
775
+ const settleClean = () => {
776
+ if (closedSettled)
777
+ return;
778
+ closedSettled = true;
779
+ resolveClosed();
780
+ };
781
+ const writeOnce = async () => {
782
+ if (stopped && lastRejection === undefined) {
783
+ throw new Error("ticket trajectory page lifecycle already stopped");
784
+ }
785
+ if (lastRejection !== undefined)
786
+ throw lastRejection;
787
+ return writeTicketTrajectoryPage({
788
+ ledgerDir: input.ledgerDir,
789
+ ticketSnapshot: input.ticketSnapshot,
790
+ now: clock(),
791
+ outputPath: input.outputPath,
792
+ refreshBoundarySeconds,
793
+ });
794
+ };
795
+ const queueWrite = () => {
796
+ if (stopped || lastRejection !== undefined)
797
+ return;
798
+ inFlight = (inFlight ?? Promise.resolve()).then(async () => {
799
+ if (stopped || lastRejection !== undefined)
800
+ return;
801
+ try {
802
+ await writeOnce();
803
+ }
804
+ catch (error) {
805
+ fault(error);
806
+ }
807
+ });
808
+ };
809
+ const started = writeOnce()
810
+ .then((first) => {
811
+ if (stopped || lastRejection !== undefined)
812
+ return first;
813
+ const intervalMs = Math.max(1, Math.round(refreshBoundarySeconds * 1000));
814
+ cancel = scheduler.every(intervalMs, () => {
815
+ queueWrite();
816
+ });
817
+ return first;
818
+ })
819
+ .catch((error) => {
820
+ fault(error);
821
+ throw error;
822
+ });
823
+ // Capture output path from the absolute resolution even before first write settles.
824
+ const outputPath = resolve(input.outputPath);
825
+ return {
826
+ outputPath,
827
+ started,
828
+ closed,
829
+ async stop() {
830
+ stopped = true;
831
+ cancel?.();
832
+ cancel = undefined;
833
+ await started.catch(() => undefined);
834
+ if (inFlight)
835
+ await inFlight.catch(() => undefined);
836
+ if (lastRejection !== undefined) {
837
+ // closed already rejected with the original cause
838
+ throw lastRejection;
839
+ }
840
+ settleClean();
841
+ },
842
+ };
843
+ }