@akagilnc/pi-workflow-roles 0.1.1941 → 0.1.2004

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.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Taishi issue metrics page envelope + atomic persistence (ADR 0068 / PRD #298).
3
+ *
4
+ * A1 minimum fields: issue scope (projectRoot) + leg list + unreadable exclusion.
5
+ * A2: metric-family modules under taishi-metric-families/ contribute optional
6
+ * top-level sections via directory discovery — B/C waves add family files
7
+ * without forking the page writer or editing a shared registry list.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { dirname, join } from "node:path";
11
+
12
+ import { writeFileAtomically } from "./atomic-write.ts";
13
+ import {
14
+ assertLedgerFileInsideHome,
15
+ ensureRealDirectoryTree,
16
+ physicalPathIdentity,
17
+ } from "./activation-ledger-topology.ts";
18
+ import type { TaishiReadableRunFacts } from "./taishi-ledger.ts";
19
+ import { loadTaishiIssueMetricFamilies } from "./taishi-metric-families.ts";
20
+ import { composeTaishiMetricFamilySections } from "./taishi-metric-family.ts";
21
+
22
+ /** Required run sources that may render a loud unreadable exclusion. */
23
+ export type TaishiMissingSource =
24
+ | "session-timeline"
25
+ | "tool-association"
26
+ | "terminal-artifact"
27
+ /** Model-groups mode: leg has no usable session model identity. */
28
+ | "session-model";
29
+
30
+ /**
31
+ * First usable session timestamp retained for an unreadable run when the
32
+ * unique session/ledger owner obtained it before the loud failure.
33
+ * Absent only when no usable timestamp was available — not a silent drop.
34
+ */
35
+ export type TaishiFirstFrameAt =
36
+ | { readonly status: "present"; readonly at: string }
37
+ | { readonly status: "absent" };
38
+
39
+ export type TaishiUnreadableRun = {
40
+ readonly runId: string;
41
+ readonly book: string;
42
+ readonly missingSources: readonly TaishiMissingSource[];
43
+ readonly reason: string;
44
+ /** Partial typed fact from A2 seam — B-wave projections sort/annotate from this. */
45
+ readonly firstFrameAt: TaishiFirstFrameAt;
46
+ /**
47
+ * Available session end-frame when classify obtained one before loud failure.
48
+ * Feeds lastActivityAt (PRD ②: max end-frame across ALL runs); still excluded
49
+ * from totalElapsedMs / ranking stats.
50
+ */
51
+ readonly lastFrameAt: TaishiOptionalTimestamp;
52
+ };
53
+
54
+ /** One readable in-scope leg (A1 identity only; metric families enrich via sections). */
55
+ export type TaishiLegEntry = {
56
+ readonly runId: string;
57
+ readonly book: string;
58
+ readonly role: string;
59
+ };
60
+
61
+ /**
62
+ * C4 scope conflict fact (typed ticketNumber over projectRoot).
63
+ * - Ledger-run path: run admitted by typed ticket while its invocation
64
+ * projectRoot differed — records runId + ticketNumber + run projectRoot + fact.
65
+ * - Caller dual-param path: ticket/index root won over a concurrent caller
66
+ * projectRoot — records ticketNumber + losing projectRoot + fact (no runId;
67
+ * the conflict is the call faces themselves, not a ledger alien run).
68
+ */
69
+ export type TaishiScopeConflict = {
70
+ /** Present only for ledger-run conflicts; omitted for caller dual-param. */
71
+ readonly runId?: string;
72
+ readonly ticketNumber: number;
73
+ readonly projectRoot: string;
74
+ readonly fact: "typed-ticketNumber-over-projectRoot";
75
+ };
76
+
77
+ /**
78
+ * Typed 空缺 for optional numeric metrics (LOC / 耗时每千行).
79
+ * Discriminated — never encode absence as 0 or Infinity.
80
+ */
81
+ export type TaishiOptionalMetricNumber =
82
+ | { readonly status: "present"; readonly value: number }
83
+ | { readonly status: "absent" };
84
+
85
+ /**
86
+ * Optional timestamp face (same shape as firstFrameAt).
87
+ * Used for 末次活动时间戳 when no available end-frame exists.
88
+ */
89
+ export type TaishiOptionalTimestamp =
90
+ | { readonly status: "present"; readonly at: string }
91
+ | { readonly status: "absent" };
92
+
93
+ /**
94
+ * Per-issue typed metrics page.
95
+ * Extension seam: metric-family modules add optional top-level sections
96
+ * through directory discovery — keep this envelope stable.
97
+ * C1 efficiency fields (完全耗时 / 排除后改动行数 / 耗时每千行 / 末次活动)
98
+ * live on the envelope so sweep can project index rows without family dig.
99
+ * issueNumber = caller typed field retained for cohort index join (ADR 0068
100
+ * page key remains projectRoot; issueNumber is not the mechanical address).
101
+ */
102
+ export type TaishiIssueMetricsPage = {
103
+ readonly kind: "taishi-issue-metrics";
104
+ readonly mode: "issue";
105
+ readonly projectRoot: string;
106
+ /** Caller typed issue number — present only when supplied on the entry. */
107
+ readonly issueNumber?: number;
108
+ readonly legs: readonly TaishiLegEntry[];
109
+ readonly unreadable: readonly TaishiUnreadableRun[];
110
+ readonly unreadableCount: number;
111
+ /**
112
+ * C4: typed-ticketNumber-over-projectRoot facts — ledger-run admits whose
113
+ * invocation projectRoot differed, and/or caller dual-param conflicts where
114
+ * ticket/index root won over a concurrent projectRoot face.
115
+ */
116
+ readonly scopeConflicts: readonly TaishiScopeConflict[];
117
+ /** 完全耗时 — Σ readable leg wall clocks (0 when no readable runs). */
118
+ readonly totalElapsedMs: number;
119
+ /** 排除后改动行数 — caller typed input retained; absent when omitted or 0. */
120
+ readonly changedLines: TaishiOptionalMetricNumber;
121
+ /** 耗时/千行 — typed 空缺 when LOC absent/0 (no division, never 0/∞). */
122
+ readonly msPerKLines: TaishiOptionalMetricNumber;
123
+ /** 末次活动时间戳 — max end-frame across ALL runs (incl. unreadable available). */
124
+ readonly lastActivityAt: TaishiOptionalTimestamp;
125
+ };
126
+
127
+ export function taishiIssuePageKey(projectRoot: string): string {
128
+ const identity = physicalPathIdentity(projectRoot);
129
+ return createHash("sha256").update(identity).digest("hex").slice(0, 32);
130
+ }
131
+
132
+ export function taishiIssuePagePath(ledgerHome: string, projectRoot: string): string {
133
+ return join(ledgerHome, "taishi", "issues", `${taishiIssuePageKey(projectRoot)}.json`);
134
+ }
135
+
136
+ function sortLegs(legs: readonly TaishiLegEntry[]): TaishiLegEntry[] {
137
+ return [...legs].sort((a, b) => {
138
+ if (a.book !== b.book) return a.book.localeCompare(b.book);
139
+ if (a.role !== b.role) return a.role.localeCompare(b.role);
140
+ return a.runId.localeCompare(b.runId);
141
+ });
142
+ }
143
+
144
+ function sortUnreadable(
145
+ unreadable: readonly TaishiUnreadableRun[],
146
+ ): TaishiUnreadableRun[] {
147
+ return [...unreadable].sort((a, b) => {
148
+ if (a.book !== b.book) return a.book.localeCompare(b.book);
149
+ return a.runId.localeCompare(b.runId);
150
+ });
151
+ }
152
+
153
+ function sortScopeConflicts(
154
+ conflicts: readonly TaishiScopeConflict[],
155
+ ): TaishiScopeConflict[] {
156
+ return [...conflicts].sort((a, b) => {
157
+ // Caller dual-param (no runId) sorts before run conflicts; then by root.
158
+ const aRun = a.runId ?? "";
159
+ const bRun = b.runId ?? "";
160
+ if (aRun !== bRun) return aRun.localeCompare(bRun);
161
+ return a.projectRoot.localeCompare(b.projectRoot);
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Admit caller LOC at the typed input boundary.
167
+ * Finite non-negative only; 0 remains a lawful typed 空缺 signal (not rejected).
168
+ * NaN / negative / ±Infinity are structural rejects (sweep attach + issue mode).
169
+ */
170
+ export function assertTaishiChangedLinesInput(
171
+ changedLines: number | undefined,
172
+ ): void {
173
+ if (changedLines === undefined) return;
174
+ if (typeof changedLines !== "number" || !Number.isFinite(changedLines) || changedLines < 0) {
175
+ throw new Error(
176
+ `taishi changedLines must be a finite non-negative number, got ${String(changedLines)}`,
177
+ );
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Normalize caller LOC: only omit or 0 → typed 空缺 (PRD efficiency口径).
183
+ * Callers must pass {@link assertTaishiChangedLinesInput} first.
184
+ */
185
+ export function normalizeTaishiChangedLines(
186
+ changedLines: number | undefined,
187
+ ): TaishiOptionalMetricNumber {
188
+ assertTaishiChangedLinesInput(changedLines);
189
+ if (changedLines === undefined || changedLines === 0) {
190
+ return { status: "absent" };
191
+ }
192
+ return { status: "present", value: changedLines };
193
+ }
194
+
195
+ /**
196
+ * 耗时/千行 = totalElapsedMs ÷ (changedLines/1000).
197
+ * LOC absent/0 → typed 空缺 (no division; never emit 0 or ∞ as stand-in).
198
+ */
199
+ export function computeTaishiMsPerKLines(
200
+ totalElapsedMs: number,
201
+ changedLines: TaishiOptionalMetricNumber,
202
+ ): TaishiOptionalMetricNumber {
203
+ if (changedLines.status === "absent") return { status: "absent" };
204
+ return {
205
+ status: "present",
206
+ value: totalElapsedMs / (changedLines.value / 1000),
207
+ };
208
+ }
209
+
210
+ function frameSpanWallMs(span: {
211
+ readonly startedAt: string;
212
+ readonly endedAt: string;
213
+ }): number {
214
+ return Date.parse(span.endedAt) - Date.parse(span.startedAt);
215
+ }
216
+
217
+ /**
218
+ * Σ readable wall clocks + max end-frame across ALL runs (C1 efficiency / index).
219
+ * Unreadable runs never enter totalElapsedMs; their available lastFrameAt still
220
+ * competes for lastActivityAt (PRD ②: 全部 run 末帧之最大者).
221
+ */
222
+ export function summarizeTaishiRunEfficiency(
223
+ runs: readonly TaishiReadableRunFacts[],
224
+ unreadable: readonly TaishiUnreadableRun[] = [],
225
+ ): {
226
+ readonly totalElapsedMs: number;
227
+ readonly lastActivityAt: TaishiOptionalTimestamp;
228
+ } {
229
+ let totalElapsedMs = 0;
230
+ let latestEndedAt: string | undefined;
231
+ for (const run of runs) {
232
+ totalElapsedMs += frameSpanWallMs(run.frameSpan);
233
+ const endedAt = run.frameSpan.endedAt;
234
+ if (latestEndedAt === undefined || endedAt > latestEndedAt) {
235
+ latestEndedAt = endedAt;
236
+ }
237
+ }
238
+ for (const entry of unreadable) {
239
+ if (entry.lastFrameAt.status !== "present") continue;
240
+ const endedAt = entry.lastFrameAt.at;
241
+ if (latestEndedAt === undefined || endedAt > latestEndedAt) {
242
+ latestEndedAt = endedAt;
243
+ }
244
+ }
245
+ const lastActivityAt: TaishiOptionalTimestamp =
246
+ latestEndedAt === undefined
247
+ ? { status: "absent" }
248
+ : { status: "present", at: latestEndedAt };
249
+ return { totalElapsedMs, lastActivityAt };
250
+ }
251
+
252
+ export async function buildTaishiIssueMetricsPage(input: {
253
+ readonly projectRoot: string;
254
+ readonly runs: readonly TaishiReadableRunFacts[];
255
+ readonly unreadable: readonly TaishiUnreadableRun[];
256
+ /** C4: scope conflicts observed while admitting runs (default none). */
257
+ readonly scopeConflicts?: readonly TaishiScopeConflict[];
258
+ /** 排除后改动行数 — optional caller typed input (issue/sweep). */
259
+ readonly changedLines?: number;
260
+ /** Caller typed issue number — retained on page for cohort index join. */
261
+ readonly issueNumber?: number;
262
+ }): Promise<TaishiIssueMetricsPage> {
263
+ // Discover before compose/write — missing family tree fails loud with native
264
+ // ENOENT/ENOTDIR (no empty-registry wash) and never emits a success page.
265
+ const families = await loadTaishiIssueMetricFamilies();
266
+ // Sole run→leg projection owner: page envelope maps typed runs to A1 legs.
267
+ const legs = sortLegs(
268
+ input.runs.map((run) => ({
269
+ runId: run.runId,
270
+ book: run.book,
271
+ role: run.role,
272
+ })),
273
+ );
274
+ const unreadable = sortUnreadable(input.unreadable);
275
+ const scopeConflicts = sortScopeConflicts(input.scopeConflicts ?? []);
276
+ const projectRoot = physicalPathIdentity(input.projectRoot);
277
+ const { totalElapsedMs, lastActivityAt } = summarizeTaishiRunEfficiency(
278
+ input.runs,
279
+ unreadable,
280
+ );
281
+ const changedLines = normalizeTaishiChangedLines(input.changedLines);
282
+ const msPerKLines = computeTaishiMsPerKLines(totalElapsedMs, changedLines);
283
+ const envelope = {
284
+ kind: "taishi-issue-metrics" as const,
285
+ mode: "issue" as const,
286
+ projectRoot,
287
+ // exactOptionalPropertyTypes: only materialize when caller supplied it.
288
+ ...(input.issueNumber === undefined ? {} : { issueNumber: input.issueNumber }),
289
+ legs,
290
+ unreadable,
291
+ unreadableCount: unreadable.length,
292
+ scopeConflicts,
293
+ totalElapsedMs,
294
+ changedLines,
295
+ msPerKLines,
296
+ lastActivityAt,
297
+ };
298
+ const sections = composeTaishiMetricFamilySections(families, {
299
+ projectRoot,
300
+ runs: input.runs,
301
+ unreadable,
302
+ });
303
+ return { ...envelope, ...sections };
304
+ }
305
+
306
+ /**
307
+ * Atomically replace the issue metrics page (idempotent overwrite).
308
+ * Directory creation and file placement go through the ledger home physical
309
+ * containment owner (ADR 0038 / 0048) — never plain recursive mkdir alone.
310
+ */
311
+ export async function writeTaishiIssueMetricsPage(
312
+ ledgerHome: string,
313
+ page: TaishiIssueMetricsPage,
314
+ ): Promise<string> {
315
+ const path = taishiIssuePagePath(ledgerHome, page.projectRoot);
316
+ ensureRealDirectoryTree(ledgerHome, dirname(path));
317
+ assertLedgerFileInsideHome(path, ledgerHome);
318
+ await writeFileAtomically(path, `${JSON.stringify(page, null, 2)}\n`);
319
+ return path;
320
+ }
@@ -29,6 +29,12 @@ import {
29
29
  formatTokensCompact,
30
30
  formatUsdPrecise,
31
31
  } from "./human-format.ts";
32
+ import {
33
+ extractSessionTimestampSpan,
34
+ readLedgerSessionJsonl,
35
+ type LedgerSessionRow,
36
+ } from "./ledger-session-read.ts";
37
+ export { readLedgerSessionJsonl } from "./ledger-session-read.ts";
32
38
  import {
33
39
  AcceptedDetailsContractError,
34
40
  acceptedFacts,
@@ -73,7 +79,7 @@ export type TicketTrajectoryPageHandle = {
73
79
  stop: () => Promise<void>;
74
80
  };
75
81
 
76
- type SessionRow = Record<string, unknown>;
82
+ type SessionRow = LedgerSessionRow;
77
83
 
78
84
  /** One ledger run as loaded by the S1 tracer (shared with the S2/S3 board). */
79
85
  export type TicketTrajectoryRun = {
@@ -168,50 +174,6 @@ function attr(value: string): string {
168
174
  return escapeHtml(value);
169
175
  }
170
176
 
171
- /**
172
- * Read session JSONL with honest live-tail semantics:
173
- * a malformed line is tolerated only when it is an unfinished final
174
- * fragment at EOF (no record terminator after it). Any malformed line
175
- * completed by a line terminator must fail loudly with file and 1-based
176
- * line context — even when no non-empty record follows — never silently
177
- * under-count.
178
- */
179
- export async function readLedgerSessionJsonl(path: string): Promise<SessionRow[]> {
180
- const text = await readFile(path, "utf8");
181
- // split keeps a trailing empty segment iff text ends with "\n", so
182
- // index < lines.length - 1 means this segment was terminated.
183
- const lines = text.split("\n");
184
- const rows: SessionRow[] = [];
185
- for (let index = 0; index < lines.length; index += 1) {
186
- const line = lines[index]!;
187
- if (!line.trim()) continue;
188
- let row: unknown;
189
- try {
190
- row = JSON.parse(line);
191
- } catch (error) {
192
- if (!(error instanceof SyntaxError)) throw error;
193
- const completedByTerminator = index < lines.length - 1;
194
- if (completedByTerminator) {
195
- throw new Error(
196
- `malformed JSONL record in ${path} at line ${index + 1}: ${error.message}`,
197
- );
198
- }
199
- // unfinished fragment at EOF — keep prior complete rows
200
- break;
201
- }
202
- // Syntactically complete line: must be a session object. Silent omission
203
- // would under-count ledger evidence (failure honesty).
204
- if (!isRecord(row)) {
205
- const kind = row === null ? "null" : Array.isArray(row) ? "array" : typeof row;
206
- throw new Error(
207
- `complete non-object JSONL record in ${path} at line ${index + 1}: expected object, got ${kind}`,
208
- );
209
- }
210
- rows.push(row);
211
- }
212
- return rows;
213
- }
214
-
215
177
  function extractModelFields(rows: SessionRow[]): { model: string; provider: string; thinking: string } {
216
178
  let model = "";
217
179
  let provider = "";
@@ -233,21 +195,6 @@ function extractModelFields(rows: SessionRow[]): { model: string; provider: stri
233
195
  return { model, provider, thinking };
234
196
  }
235
197
 
236
- /** First and last record timestamps in encounter order. */
237
- function extractTimestampSpan(rows: SessionRow[]): { startedAt?: string; endedAt?: string } {
238
- let startedAt: string | undefined;
239
- let endedAt: string | undefined;
240
- for (const row of rows) {
241
- if (typeof row.timestamp !== "string" || !row.timestamp) continue;
242
- if (startedAt === undefined) startedAt = row.timestamp;
243
- endedAt = row.timestamp;
244
- }
245
- return {
246
- ...(startedAt !== undefined ? { startedAt } : {}),
247
- ...(endedAt !== undefined ? { endedAt } : {}),
248
- };
249
- }
250
-
251
198
  /** Sum budget dollars and tokens from message.usage on session rows. */
252
199
  function extractUsageTotals(rows: SessionRow[]): { costUsd: number; totalTokens: number } {
253
200
  let costUsd = 0;
@@ -442,7 +389,7 @@ async function parseRunDirectory(runDir: string, ledgerCoord: string): Promise<P
442
389
  }
443
390
  if (!startedAt && typeof row.timestamp === "string") startedAt = row.timestamp;
444
391
  }
445
- const parentSpan = extractTimestampSpan(rows);
392
+ const parentSpan = extractSessionTimestampSpan(rows);
446
393
  if (startedAt === undefined) startedAt = parentSpan.startedAt;
447
394
  const endedAt = parentSpan.endedAt;
448
395
 
@@ -457,7 +404,7 @@ async function parseRunDirectory(runDir: string, ledgerCoord: string): Promise<P
457
404
  const legUsage = extractUsageTotals(legRows);
458
405
  costUsd += legUsage.costUsd;
459
406
  totalTokens += legUsage.totalTokens;
460
- const legSpan = extractTimestampSpan(legRows);
407
+ const legSpan = extractSessionTimestampSpan(legRows);
461
408
  axisWallMs += wallMsBetween(legSpan.startedAt, legSpan.endedAt);
462
409
  if (
463
410
  legSpan.endedAt !== undefined &&