@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.
- package/dist/public-cli/main.js +2448 -75
- package/package.json +1 -1
- package/src/atomic-write.ts +26 -0
- package/src/ledger-session-read.ts +260 -0
- package/src/public-cli/cli.ts +24 -0
- package/src/public-cli/invocation.ts +352 -1
- package/src/public-cli/main.ts +21 -2
- package/src/public-cli/registry.ts +18 -1
- package/src/public-cli/settlement.ts +9 -0
- package/src/public-cli/taishi-run.ts +235 -0
- package/src/run-terminal-artifacts.ts +231 -0
- package/src/taishi-cohort.ts +232 -0
- package/src/taishi-entry.ts +429 -0
- package/src/taishi-index.ts +269 -0
- package/src/taishi-ledger.ts +466 -0
- package/src/taishi-median.ts +15 -0
- package/src/taishi-metric-families/acceptance-success-rework.ts +346 -0
- package/src/taishi-metric-families/b2-frame-buckets-actions.ts +274 -0
- package/src/taishi-metric-families/leg-wall-clock.ts +90 -0
- package/src/taishi-metric-families/round-timeline.ts +201 -0
- package/src/taishi-metric-families.ts +36 -0
- package/src/taishi-metric-family.ts +41 -0
- package/src/taishi-model-groups.ts +198 -0
- package/src/taishi-page.ts +320 -0
- package/src/ticket-trajectory.ts +9 -62
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taishi ledger scan: S-family book/runs topology, issue scope by invocation
|
|
3
|
+
* projectRoot, and loud unreadable exclusion for required sources.
|
|
4
|
+
* Reuses canonical session/artifact readers — does not parse session JSONL itself.
|
|
5
|
+
*
|
|
6
|
+
* A2: classifyScopedRun retains typed per-run facts (frame span, tool intervals,
|
|
7
|
+
* terminal face) for metric-family modules — no longer discarded after checks.
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
physicalPathIdentity,
|
|
14
|
+
resolveActivationLedgerHome,
|
|
15
|
+
} from "./activation-ledger-topology.ts";
|
|
16
|
+
import {
|
|
17
|
+
extractSessionModelSequence,
|
|
18
|
+
extractSessionTimestampSpan,
|
|
19
|
+
extractSessionToolIntervals,
|
|
20
|
+
LedgerSessionJsonlError,
|
|
21
|
+
readLedgerSessionJsonl,
|
|
22
|
+
type SessionToolInterval,
|
|
23
|
+
} from "./ledger-session-read.ts";
|
|
24
|
+
import {
|
|
25
|
+
readRunTerminalArtifact,
|
|
26
|
+
type RunTerminalArtifactFile,
|
|
27
|
+
} from "./run-terminal-artifacts.ts";
|
|
28
|
+
import type {
|
|
29
|
+
TaishiFirstFrameAt,
|
|
30
|
+
TaishiMissingSource,
|
|
31
|
+
TaishiOptionalTimestamp,
|
|
32
|
+
TaishiScopeConflict,
|
|
33
|
+
TaishiUnreadableRun,
|
|
34
|
+
} from "./taishi-page.ts";
|
|
35
|
+
|
|
36
|
+
function isMissingPathError(error: unknown): boolean {
|
|
37
|
+
return (
|
|
38
|
+
error instanceof Error
|
|
39
|
+
&& "code" in error
|
|
40
|
+
&& (error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function errorText(error: unknown): string {
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Existing public-cli run-state live phases — not a new state machine. */
|
|
53
|
+
const LIVE_RUN_STATES = new Set(["admitted", "running", "resumable"]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read lifecycle state from the existing run-state.json face.
|
|
57
|
+
* Used only to distinguish live in-flight runs from terminal no-receipt.
|
|
58
|
+
*/
|
|
59
|
+
async function readExistingRunLifecycleState(
|
|
60
|
+
runDirectory: string,
|
|
61
|
+
): Promise<string | undefined> {
|
|
62
|
+
try {
|
|
63
|
+
const raw: unknown = JSON.parse(
|
|
64
|
+
await readFile(join(runDirectory, "run-state.json"), "utf8"),
|
|
65
|
+
);
|
|
66
|
+
if (!isRecord(raw) || typeof raw.state !== "string") return undefined;
|
|
67
|
+
return raw.state;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseRunDirectoryName(
|
|
74
|
+
name: string,
|
|
75
|
+
): { runId: string; role: string } | undefined {
|
|
76
|
+
const at = name.lastIndexOf("@");
|
|
77
|
+
if (at <= 0 || at === name.length - 1) return undefined;
|
|
78
|
+
return { runId: name.slice(0, at), role: name.slice(at + 1) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Invocation scope faces used for issue 圈定 (C4).
|
|
83
|
+
* projectRoot remains required to place a run on any scope path;
|
|
84
|
+
* ticketNumber is the #176 typed face when present (integer ≥ 1).
|
|
85
|
+
* Single read of invocation.json — no second parse kernel.
|
|
86
|
+
*/
|
|
87
|
+
type InvocationScopeFields = {
|
|
88
|
+
readonly projectRoot: string;
|
|
89
|
+
readonly ticketNumber?: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
async function readInvocationScopeFields(
|
|
93
|
+
runDirectory: string,
|
|
94
|
+
): Promise<InvocationScopeFields | undefined> {
|
|
95
|
+
let raw: string;
|
|
96
|
+
try {
|
|
97
|
+
raw = await readFile(join(runDirectory, "invocation.json"), "utf8");
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (isMissingPathError(error)) return undefined;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
const parsed: unknown = JSON.parse(raw);
|
|
103
|
+
if (!isRecord(parsed)) return undefined;
|
|
104
|
+
if (typeof parsed.projectRoot !== "string" || parsed.projectRoot.trim() === "") {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const projectRoot = parsed.projectRoot;
|
|
108
|
+
// Same #176 contract: positive integer ticketNumber only.
|
|
109
|
+
if (
|
|
110
|
+
typeof parsed.ticketNumber === "number"
|
|
111
|
+
&& Number.isInteger(parsed.ticketNumber)
|
|
112
|
+
&& parsed.ticketNumber >= 1
|
|
113
|
+
) {
|
|
114
|
+
return { projectRoot, ticketNumber: parsed.ticketNumber };
|
|
115
|
+
}
|
|
116
|
+
return { projectRoot };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* C4 issue scope decision for one run.
|
|
121
|
+
* - Both sides carry ticketNumber → typed ticket decides; mismatch projectRoot = conflict.
|
|
122
|
+
* - Otherwise → projectRoot mechanical-key fallback.
|
|
123
|
+
*/
|
|
124
|
+
function decideIssueScope(input: {
|
|
125
|
+
readonly scopeProjectRootIdentity: string;
|
|
126
|
+
readonly scopeTicketNumber: number | undefined;
|
|
127
|
+
readonly runProjectRootIdentity: string;
|
|
128
|
+
readonly runTicketNumber: number | undefined;
|
|
129
|
+
}): { readonly inScope: boolean; readonly conflict: boolean } {
|
|
130
|
+
const projectRootMatch =
|
|
131
|
+
input.runProjectRootIdentity === input.scopeProjectRootIdentity;
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
input.scopeTicketNumber !== undefined
|
|
135
|
+
&& input.runTicketNumber !== undefined
|
|
136
|
+
) {
|
|
137
|
+
if (input.runTicketNumber === input.scopeTicketNumber) {
|
|
138
|
+
return { inScope: true, conflict: !projectRootMatch };
|
|
139
|
+
}
|
|
140
|
+
// Run bound to a different ticket — typed face wins over projectRoot match.
|
|
141
|
+
return { inScope: false, conflict: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { inScope: projectRootMatch, conflict: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function resolveSessionFile(
|
|
148
|
+
runDirectory: string,
|
|
149
|
+
): Promise<string> {
|
|
150
|
+
// Prefer invocation.sessionFile when present; fall back to S-family principal.
|
|
151
|
+
try {
|
|
152
|
+
const raw = await readFile(join(runDirectory, "invocation.json"), "utf8");
|
|
153
|
+
const parsed: unknown = JSON.parse(raw);
|
|
154
|
+
if (
|
|
155
|
+
isRecord(parsed)
|
|
156
|
+
&& typeof parsed.sessionFile === "string"
|
|
157
|
+
&& parsed.sessionFile.trim() !== ""
|
|
158
|
+
) {
|
|
159
|
+
return parsed.sessionFile;
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (!isMissingPathError(error)) throw error;
|
|
163
|
+
}
|
|
164
|
+
return join(runDirectory, "session", "session.jsonl");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Session first/last usable timestamps retained for B-wave wall-clock kernels. */
|
|
168
|
+
export type TaishiRunFrameSpan = {
|
|
169
|
+
readonly startedAt: string;
|
|
170
|
+
readonly endedAt: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Typed terminal face retained for B-wave outcome mapping.
|
|
175
|
+
* Absence is a valid no-receipt state; unreadable never appears here (excluded).
|
|
176
|
+
*/
|
|
177
|
+
export type TaishiRunTerminalFace =
|
|
178
|
+
| { readonly status: "absent" }
|
|
179
|
+
| {
|
|
180
|
+
readonly status: "present";
|
|
181
|
+
readonly file: RunTerminalArtifactFile;
|
|
182
|
+
readonly body: Record<string, unknown>;
|
|
183
|
+
/** Nonblank producer role — sole owner is readRunTerminalArtifact. */
|
|
184
|
+
readonly role: string;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Per readable in-scope run: identity + facts classifyScopedRun already read.
|
|
189
|
+
* Metric families consume this structure read-only; they do not re-scan disk.
|
|
190
|
+
*/
|
|
191
|
+
export type TaishiReadableRunFacts = {
|
|
192
|
+
readonly runId: string;
|
|
193
|
+
readonly book: string;
|
|
194
|
+
readonly role: string;
|
|
195
|
+
readonly frameSpan: TaishiRunFrameSpan;
|
|
196
|
+
readonly toolIntervals: readonly SessionToolInterval[];
|
|
197
|
+
readonly terminal: TaishiRunTerminalFace;
|
|
198
|
+
/**
|
|
199
|
+
* Ordered-unique session model ids for this leg (C3 model-group key material).
|
|
200
|
+
* Empty when no model face was present — not a scan-level unreadable condition
|
|
201
|
+
* (timeline/tools/terminal still admit the run for issue-mode families).
|
|
202
|
+
* Model-groups mode lists empty as typed session-model vacancy, never as "".
|
|
203
|
+
*/
|
|
204
|
+
readonly models: readonly string[];
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export type TaishiScopedRunScan = {
|
|
208
|
+
/** Readable in-scope runs with retained typed facts (A2). */
|
|
209
|
+
readonly runs: readonly TaishiReadableRunFacts[];
|
|
210
|
+
readonly unreadable: readonly TaishiUnreadableRun[];
|
|
211
|
+
/** C4: typed-ticket admits whose projectRoot mechanical key conflicted. */
|
|
212
|
+
readonly scopeConflicts: readonly TaishiScopeConflict[];
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
async function classifyScopedRun(input: {
|
|
216
|
+
readonly book: string;
|
|
217
|
+
readonly runId: string;
|
|
218
|
+
readonly role: string;
|
|
219
|
+
readonly runDirectory: string;
|
|
220
|
+
}): Promise<
|
|
221
|
+
| { readonly kind: "readable"; readonly facts: TaishiReadableRunFacts }
|
|
222
|
+
| { readonly kind: "unreadable"; readonly entry: TaishiUnreadableRun }
|
|
223
|
+
| { readonly kind: "live" }
|
|
224
|
+
> {
|
|
225
|
+
const missingSources: TaishiMissingSource[] = [];
|
|
226
|
+
const reasons: string[] = [];
|
|
227
|
+
|
|
228
|
+
let frameSpan: TaishiRunFrameSpan | undefined;
|
|
229
|
+
let toolIntervals: readonly SessionToolInterval[] | undefined;
|
|
230
|
+
let terminal: TaishiRunTerminalFace | undefined;
|
|
231
|
+
/** Ordered-unique models retained whenever session rows were readable. */
|
|
232
|
+
let models: readonly string[] = [];
|
|
233
|
+
/** Partial first/last frame retained when full session span cannot be admitted. */
|
|
234
|
+
let partialFirstFrameAt: TaishiFirstFrameAt = { status: "absent" };
|
|
235
|
+
let partialLastFrameAt: TaishiOptionalTimestamp = { status: "absent" };
|
|
236
|
+
|
|
237
|
+
// 1) session timeline
|
|
238
|
+
const sessionFile = await resolveSessionFile(input.runDirectory);
|
|
239
|
+
let rows: Awaited<ReturnType<typeof readLedgerSessionJsonl>> | undefined;
|
|
240
|
+
try {
|
|
241
|
+
rows = await readLedgerSessionJsonl(sessionFile);
|
|
242
|
+
models = extractSessionModelSequence(rows);
|
|
243
|
+
const span = extractSessionTimestampSpan(rows);
|
|
244
|
+
if (span.startedAt === undefined || span.endedAt === undefined) {
|
|
245
|
+
missingSources.push("session-timeline");
|
|
246
|
+
reasons.push("session timeline has no usable timestamps");
|
|
247
|
+
// Span incomplete, but any usable timestamp remains a typed partial fact.
|
|
248
|
+
if (span.startedAt !== undefined) {
|
|
249
|
+
partialFirstFrameAt = { status: "present", at: span.startedAt };
|
|
250
|
+
}
|
|
251
|
+
if (span.endedAt !== undefined) {
|
|
252
|
+
partialLastFrameAt = { status: "present", at: span.endedAt };
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
// frameSpan gate: both edges must parse and end must not precede start.
|
|
256
|
+
// Damaged spans stay page-local unreadable — never throw or emit negative/NaN wallMs.
|
|
257
|
+
const startedMs = Date.parse(span.startedAt);
|
|
258
|
+
const endedMs = Date.parse(span.endedAt);
|
|
259
|
+
if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs)) {
|
|
260
|
+
missingSources.push("session-timeline");
|
|
261
|
+
reasons.push("session timeline timestamps are not parseable instants");
|
|
262
|
+
partialFirstFrameAt = { status: "present", at: span.startedAt };
|
|
263
|
+
partialLastFrameAt = { status: "present", at: span.endedAt };
|
|
264
|
+
} else if (endedMs < startedMs) {
|
|
265
|
+
missingSources.push("session-timeline");
|
|
266
|
+
reasons.push("session timeline end is earlier than start");
|
|
267
|
+
partialFirstFrameAt = { status: "present", at: span.startedAt };
|
|
268
|
+
partialLastFrameAt = { status: "present", at: span.endedAt };
|
|
269
|
+
} else {
|
|
270
|
+
frameSpan = { startedAt: span.startedAt, endedAt: span.endedAt };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
missingSources.push("session-timeline");
|
|
275
|
+
reasons.push(errorText(error));
|
|
276
|
+
// Single parse kernel: recover first/last frame and models from rows read before the loud line.
|
|
277
|
+
if (error instanceof LedgerSessionJsonlError) {
|
|
278
|
+
const span = extractSessionTimestampSpan(error.prefixRows);
|
|
279
|
+
if (span.startedAt !== undefined) {
|
|
280
|
+
partialFirstFrameAt = { status: "present", at: span.startedAt };
|
|
281
|
+
}
|
|
282
|
+
if (span.endedAt !== undefined) {
|
|
283
|
+
partialLastFrameAt = { status: "present", at: span.endedAt };
|
|
284
|
+
}
|
|
285
|
+
models = extractSessionModelSequence(error.prefixRows);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 2) tool association (only when session rows are available)
|
|
290
|
+
if (rows !== undefined && !missingSources.includes("session-timeline")) {
|
|
291
|
+
try {
|
|
292
|
+
toolIntervals = extractSessionToolIntervals(rows);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
missingSources.push("tool-association");
|
|
295
|
+
reasons.push(errorText(error));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 3) typed terminal artifact — absence is no-receipt only for terminal runs.
|
|
300
|
+
// Live admitted/running/resumable runs (existing run-state) are not dead legs.
|
|
301
|
+
try {
|
|
302
|
+
const artifact = await readRunTerminalArtifact(input.runDirectory);
|
|
303
|
+
if (artifact.status === "unreadable") {
|
|
304
|
+
missingSources.push("terminal-artifact");
|
|
305
|
+
reasons.push(`${artifact.file}: ${artifact.reason}`);
|
|
306
|
+
} else if (artifact.status === "absent") {
|
|
307
|
+
const lifecycle = await readExistingRunLifecycleState(input.runDirectory);
|
|
308
|
+
if (lifecycle !== undefined && LIVE_RUN_STATES.has(lifecycle)) {
|
|
309
|
+
return { kind: "live" };
|
|
310
|
+
}
|
|
311
|
+
terminal = { status: "absent" };
|
|
312
|
+
} else {
|
|
313
|
+
// role already required nonblank by readRunTerminalArtifact (single owner).
|
|
314
|
+
terminal = {
|
|
315
|
+
status: "present",
|
|
316
|
+
file: artifact.file,
|
|
317
|
+
body: artifact.body,
|
|
318
|
+
role: artifact.body.role as string,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
missingSources.push("terminal-artifact");
|
|
323
|
+
reasons.push(errorText(error));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (missingSources.length > 0) {
|
|
327
|
+
// Prefer full-span edges when session was admitted; else partial prefix facts.
|
|
328
|
+
const firstFrameAt: TaishiFirstFrameAt =
|
|
329
|
+
frameSpan !== undefined
|
|
330
|
+
? { status: "present", at: frameSpan.startedAt }
|
|
331
|
+
: partialFirstFrameAt;
|
|
332
|
+
const lastFrameAt: TaishiOptionalTimestamp =
|
|
333
|
+
frameSpan !== undefined
|
|
334
|
+
? { status: "present", at: frameSpan.endedAt }
|
|
335
|
+
: partialLastFrameAt;
|
|
336
|
+
return {
|
|
337
|
+
kind: "unreadable",
|
|
338
|
+
entry: {
|
|
339
|
+
runId: input.runId,
|
|
340
|
+
book: input.book,
|
|
341
|
+
missingSources,
|
|
342
|
+
reason: reasons.join("; "),
|
|
343
|
+
firstFrameAt,
|
|
344
|
+
lastFrameAt,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// All three faces present after the gate above (TypeScript cannot see the link).
|
|
350
|
+
if (frameSpan === undefined || toolIntervals === undefined || terminal === undefined) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`classifyScopedRun internal invariant: missing retained facts for ${input.runId}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
kind: "readable",
|
|
358
|
+
facts: {
|
|
359
|
+
runId: input.runId,
|
|
360
|
+
book: input.book,
|
|
361
|
+
role: input.role,
|
|
362
|
+
frameSpan,
|
|
363
|
+
toolIntervals,
|
|
364
|
+
terminal,
|
|
365
|
+
models,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Scan ledger home books/<book>/runs for runs in the issue scope.
|
|
372
|
+
* C4: typed ticketNumber (when present on both scope and run) decides membership;
|
|
373
|
+
* otherwise projectRoot mechanical key is the fallback. Ticket-vs-projectRoot
|
|
374
|
+
* conflicts still admit the run and surface on scopeConflicts.
|
|
375
|
+
* Damaged required sources become unreadable exclusions.
|
|
376
|
+
* Readable runs retain typed facts for metric-family composition.
|
|
377
|
+
*/
|
|
378
|
+
export async function scanTaishiIssueRuns(input: {
|
|
379
|
+
readonly projectRoot: string;
|
|
380
|
+
/** Caller typed ticket face — when set, prefer #176 invocation ticketNumber. */
|
|
381
|
+
readonly ticketNumber?: number;
|
|
382
|
+
}): Promise<TaishiScopedRunScan> {
|
|
383
|
+
// Package-owned machine home only (ADR 0048) — no invocation-varying override.
|
|
384
|
+
const ledgerHome = resolveActivationLedgerHome();
|
|
385
|
+
const scopeIdentity = physicalPathIdentity(input.projectRoot);
|
|
386
|
+
const scopeTicketNumber = input.ticketNumber;
|
|
387
|
+
const booksRoot = join(ledgerHome, "books");
|
|
388
|
+
|
|
389
|
+
let bookNames: string[];
|
|
390
|
+
try {
|
|
391
|
+
const entries = await readdir(booksRoot, { withFileTypes: true });
|
|
392
|
+
bookNames = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (isMissingPathError(error)) {
|
|
395
|
+
return { runs: [], unreadable: [], scopeConflicts: [] };
|
|
396
|
+
}
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const runs: TaishiReadableRunFacts[] = [];
|
|
401
|
+
const unreadable: TaishiUnreadableRun[] = [];
|
|
402
|
+
const scopeConflicts: TaishiScopeConflict[] = [];
|
|
403
|
+
|
|
404
|
+
for (const book of bookNames) {
|
|
405
|
+
const runsDir = join(booksRoot, book, "runs");
|
|
406
|
+
let runNames: string[];
|
|
407
|
+
try {
|
|
408
|
+
const entries = await readdir(runsDir, { withFileTypes: true });
|
|
409
|
+
runNames = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (isMissingPathError(error)) continue;
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
for (const runName of runNames) {
|
|
416
|
+
const parsed = parseRunDirectoryName(runName);
|
|
417
|
+
if (parsed === undefined) continue;
|
|
418
|
+
const runDirectory = join(runsDir, runName);
|
|
419
|
+
|
|
420
|
+
let scopeFields: InvocationScopeFields | undefined;
|
|
421
|
+
try {
|
|
422
|
+
scopeFields = await readInvocationScopeFields(runDirectory);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
// Corrupt invocation cannot be scoped to this issue — skip.
|
|
425
|
+
if (error instanceof SyntaxError) continue;
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
if (scopeFields === undefined) continue;
|
|
429
|
+
|
|
430
|
+
const runProjectRootIdentity = physicalPathIdentity(scopeFields.projectRoot);
|
|
431
|
+
const decision = decideIssueScope({
|
|
432
|
+
scopeProjectRootIdentity: scopeIdentity,
|
|
433
|
+
scopeTicketNumber,
|
|
434
|
+
runProjectRootIdentity,
|
|
435
|
+
runTicketNumber: scopeFields.ticketNumber,
|
|
436
|
+
});
|
|
437
|
+
if (!decision.inScope) continue;
|
|
438
|
+
|
|
439
|
+
if (decision.conflict) {
|
|
440
|
+
// ticketNumber is defined on both sides whenever conflict is true.
|
|
441
|
+
scopeConflicts.push({
|
|
442
|
+
runId: parsed.runId,
|
|
443
|
+
ticketNumber: scopeFields.ticketNumber as number,
|
|
444
|
+
projectRoot: runProjectRootIdentity,
|
|
445
|
+
fact: "typed-ticketNumber-over-projectRoot",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const classified = await classifyScopedRun({
|
|
450
|
+
book,
|
|
451
|
+
runId: parsed.runId,
|
|
452
|
+
role: parsed.role,
|
|
453
|
+
runDirectory,
|
|
454
|
+
});
|
|
455
|
+
if (classified.kind === "readable") runs.push(classified.facts);
|
|
456
|
+
else if (classified.kind === "unreadable") unreadable.push(classified.entry);
|
|
457
|
+
// live in-flight runs are omitted from legs and unreadable (not failure/death).
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
runs,
|
|
463
|
+
unreadable,
|
|
464
|
+
scopeConflicts,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared taishi median primitive (family-wide convention).
|
|
3
|
+
* Odd sample count → single middle value after ascending sort.
|
|
4
|
+
* Even sample count → arithmetic mean of the two middle values.
|
|
5
|
+
* Empty input → undefined (callers record typed vacancy; no invented zero).
|
|
6
|
+
*/
|
|
7
|
+
export function medianNumber(values: readonly number[]): number | undefined {
|
|
8
|
+
if (values.length === 0) return undefined;
|
|
9
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
10
|
+
const mid = Math.floor(sorted.length / 2);
|
|
11
|
+
if (sorted.length % 2 === 1) {
|
|
12
|
+
return sorted[mid]!;
|
|
13
|
+
}
|
|
14
|
+
return (sorted[mid - 1]! + sorted[mid]!) / 2;
|
|
15
|
+
}
|